Merge stream A (session lifecycle: startup orphan, sweep metrics, stopping gate, kill-fallback Closed, unattached grace, worker-exit faults session)

This commit is contained in:
Joseph Doherty
2026-10-22 08:21:47 -04:00
15 changed files with 1669 additions and 169 deletions
@@ -325,6 +325,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
options.FaultedGraceSeconds,
"MxGateway:Sessions:FaultedGraceSeconds must be zero or greater (0 reaps a faulted session on the next sweep).",
builder);
AddIfNegative(
options.UnattachedSessionGraceSeconds,
"MxGateway:Sessions:UnattachedSessionGraceSeconds must be zero or greater (0 disables reaping of never-subscribed sessions).",
builder);
AddIfNegative(
options.WorkerReadyWaitTimeoutMs,
"MxGateway:Sessions:WorkerReadyWaitTimeoutMs must be greater than or equal to zero.",
@@ -57,6 +57,28 @@ public sealed class SessionOptions
/// </summary>
public int FaultedGraceSeconds { get; init; }
/// <summary>
/// Gets the idle window, in seconds, after which the lease monitor reaps a session that
/// has <em>never</em> attached an event-stream subscriber. A client that opens a session
/// and vanishes before it ever streams (for example OtOpcUa's <c>GalaxyDriver</c> failing
/// part-way through its start-up) otherwise pins a session slot and a live x86 worker for
/// the full <see cref="DefaultLeaseSeconds"/> (30 minutes by default). The window is
/// measured from the later of the session becoming Ready and its most recent unary
/// command, so a slow worker start does not eat it and a client that only ever reads and
/// writes is not reaped while it keeps calling; it stops applying the moment the session's
/// first subscriber — external, or the gateway's own internal alarm-monitor subscriber —
/// attaches (from then on <see cref="DetachGraceSeconds"/> and the normal lease govern
/// it). A value of <c>0</c> (the default) disables the sweep. Must be greater than or
/// equal to zero.
/// </summary>
/// <remarks>
/// Set it comfortably above the time a well-behaved client needs between
/// <c>OpenSession</c> and its first <c>StreamEvents</c>, and remember the close lands on
/// the next sweep after the window elapses (up to <see cref="LeaseSweepIntervalSeconds"/>
/// later).
/// </remarks>
public int UnattachedSessionGraceSeconds { get; init; }
/// <summary>
/// Gets a value indicating whether multiple event subscribers are allowed per session.
/// </summary>
@@ -1104,6 +1104,7 @@ public sealed class MxAccessGatewayService(
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
SessionManagerErrorCode.PermissionDenied => StatusCode.PermissionDenied,
SessionManagerErrorCode.GatewayStopping => StatusCode.Unavailable,
_ => StatusCode.Unavailable,
};
@@ -26,9 +26,11 @@ public sealed class GatewaySession
private DateTimeOffset _lastClientActivityAt;
private DateTimeOffset? _leaseExpiresAt;
private bool _closeStarted;
private bool _openRecorded;
private int _activeEventSubscriberCount;
private readonly TimeSpan _detachGrace;
private readonly TimeSpan _faultedGrace;
private readonly TimeSpan _unattachedGrace;
private readonly TimeSpan _workerReadyWaitTimeout;
private DateTimeOffset? _detachedAtUtc;
private DateTimeOffset? _faultedAtUtc;
@@ -37,6 +39,12 @@ public sealed class GatewaySession
// FAILED first attach — which still runs the rollback DetachEventSubscriber from the
// attach catch path — does not push a never-subscribed session into the grace window.
private bool _everHadEventSubscriber;
// True once an in-process (internal, non-counted) subscriber attached via
// AttachInternalEventSubscriber. Consulted only by the unattached-grace sweep rule.
private bool _everHadInternalEventSubscriber;
// Stamped once, when the session first becomes Ready; the unattached-grace idle clock cannot
// start before it.
private DateTimeOffset? _readyAtUtc;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
// Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
@@ -168,6 +176,12 @@ public sealed class GatewaySession
/// The tags come from the owner key, never from the client's wire request, so a client
/// cannot label its own session with another tenant's tag.
/// </param>
/// <param name="unattachedGrace">
/// Idle window after which the lease monitor reaps a Ready session that has never attached
/// an event subscriber (see <see cref="IsUnattachedGraceExpired"/>), so a client that
/// opens and vanishes does not pin a worker for the full lease. <see cref="TimeSpan.Zero"/>
/// (the default) disables it.
/// </param>
public GatewaySession(
string sessionId,
string backendName,
@@ -187,7 +201,8 @@ public sealed class GatewaySession
TimeSpan workerReadyWaitTimeout = default,
ArrayAddressNormalizer? addressNormalizer = null,
TimeSpan faultedGrace = default,
IReadOnlyList<string>? ownerDashboardTags = null)
IReadOnlyList<string>? ownerDashboardTags = null,
TimeSpan unattachedGrace = default)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -230,6 +245,7 @@ public sealed class GatewaySession
_eventStreaming = eventStreaming ?? SessionEventStreaming.Default;
_detachGrace = detachGrace > TimeSpan.Zero ? detachGrace : TimeSpan.Zero;
_faultedGrace = faultedGrace > TimeSpan.Zero ? faultedGrace : TimeSpan.Zero;
_unattachedGrace = unattachedGrace > TimeSpan.Zero ? unattachedGrace : TimeSpan.Zero;
_workerReadyWaitTimeout = workerReadyWaitTimeout > TimeSpan.Zero ? workerReadyWaitTimeout : TimeSpan.Zero;
_addressNormalizer = addressNormalizer;
}
@@ -413,13 +429,128 @@ public sealed class GatewaySession
/// Attaches the worker client for this session.
/// </summary>
/// <param name="workerClient">Worker client to attach.</param>
public void AttachWorkerClient(IWorkerClient workerClient)
/// <returns>
/// <see langword="true"/> when the client was attached; <see langword="false"/> when the
/// session is already <see cref="SessionState.Closing"/>, <see cref="SessionState.Closed"/>
/// or <see cref="SessionState.Faulted"/>, or has been disposed — the caller owns the client
/// and must discard it.
/// </returns>
/// <remarks>
/// The session is registered before its worker is created, so an admin kill or the host
/// drain can close it while the worker is still starting (up to the startup timeout). A
/// worker client arriving after that must not be stored on the dead session: nothing would
/// ever shut it down (the close already ran with no client to stop) and the open would hand
/// a closed session back to the caller as if it had succeeded. The decision is taken under
/// <c>_syncRoot</c> so it cannot interleave with the close's state write.
/// </remarks>
public bool AttachWorkerClient(IWorkerClient workerClient)
{
ArgumentNullException.ThrowIfNull(workerClient);
lock (_syncRoot)
{
if (_state is SessionState.Closing or SessionState.Closed or SessionState.Faulted
|| _eventDistributorDisposed)
{
return false;
}
_workerClient = workerClient;
return true;
}
}
/// <summary>
/// Runs the end of the open sequence — attach the worker client, mark the session
/// <see cref="SessionState.Ready"/>, record it as opened — under the per-session close
/// gate, so a concurrent <see cref="CloseAsync"/> or
/// <see cref="KillWorkerWithCloseGateAsync"/> runs either entirely before it (the attach
/// is refused) or entirely after it (an ordinary close of a Ready session). Without the
/// gate a kill could land between the attach and the open accounting and the two teardown
/// bookkeepings would disagree about whether the session ever counted as open.
/// </summary>
/// <param name="workerClient">Worker client to attach.</param>
/// <param name="onReady">
/// Invoked inside the gate once the session is Ready; the caller records its open-session
/// accounting here, and <see cref="OpenRecorded"/> is set right after it returns.
/// </param>
/// <returns>
/// <see langword="true"/> when the session became Ready; <see langword="false"/> when the
/// attach was refused (see <see cref="AttachWorkerClient"/>), including when the session was
/// disposed before the gate could be taken.
/// </returns>
/// <remarks>
/// Not cancellable: the worker client already exists, so the only acceptable outcomes are
/// "attached" or "refused, caller discards it". The gate is held only for the duration of
/// a close or kill, so the wait is bounded by the worker shutdown timeout. The caller
/// still decides what to do about a cancelled open before calling this.
/// </remarks>
internal async ValueTask<bool> TryAttachWorkerClientAndMarkReadyAsync(
IWorkerClient workerClient,
Action onReady)
{
ArgumentNullException.ThrowIfNull(workerClient);
ArgumentNullException.ThrowIfNull(onReady);
try
{
await _closeLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// Disposed while the worker was starting: the session has been torn down.
return false;
}
try
{
if (!AttachWorkerClient(workerClient))
{
return false;
}
MarkReady();
onReady();
lock (_syncRoot)
{
_openRecorded = true;
}
return true;
}
finally
{
try
{
_closeLock.Release();
}
catch (ObjectDisposedException)
{
// A double-dispose that slipped past DisposeAsync's own gate; the session is torn
// down either way and there is no one left to hand the slot to.
}
}
}
/// <summary>
/// Gets a value indicating whether the open-session accounting
/// (<c>GatewayMetrics.SessionOpened</c>) has been recorded for this session. Every
/// teardown path consults it so a session closed or killed while its worker was still
/// starting — never counted as opened — is not counted as closed either.
/// </summary>
/// <remarks>
/// Written only by <see cref="TryAttachWorkerClientAndMarkReadyAsync"/>, inside the close
/// gate; read by teardown paths that hold (or just released) the same gate, so the two
/// accountings never observe a half-finished open.
/// </remarks>
internal bool OpenRecorded
{
get
{
lock (_syncRoot)
{
return _openRecorded;
}
}
}
@@ -481,6 +612,14 @@ public sealed class GatewaySession
public void MarkReady()
{
TransitionTo(SessionState.Ready);
lock (_syncRoot)
{
// The unattached-grace idle clock starts here, not at open: the client cannot
// subscribe before it has the session, and a slow worker start must not hand it a
// session the next sweep reaps.
_readyAtUtc ??= _eventStreaming.TimeProvider.GetUtcNow();
}
StartDashboardMirror();
}
@@ -634,6 +773,15 @@ public sealed class GatewaySession
SessionEventDistributor distributor = EnsureDistributorCreated(out bool startNow);
IEventSubscriberLease lease = distributor.Register(isInternal: true);
StartPumpIfRequested(distributor, startNow);
lock (_syncRoot)
{
// An in-process consumer (the alarm monitor) is a real subscriber for the
// unattached-grace rule: its session is attached, not abandoned. It stays out of
// _everHadEventSubscriber on purpose — that latch drives detach-grace, which is keyed
// on EXTERNAL subscribers only.
_everHadInternalEventSubscriber = true;
}
return lease;
}
@@ -856,12 +1004,54 @@ public sealed class GatewaySession
IWorkerClient workerClient = await GetReadyWorkerClientAsync(cancellationToken).ConfigureAwait(false);
TouchClientActivity(_eventStreaming.TimeProvider.GetUtcNow());
await foreach (WorkerEvent workerEvent in workerClient
.ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
try
{
yield return mapper.MapEvent(workerEvent);
await foreach (WorkerEvent workerEvent in workerClient
.ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return mapper.MapEvent(workerEvent);
}
}
finally
{
// The worker event stream only ends when the worker client has reached a terminal
// state (it enters Closed/Faulted BEFORE completing the channel) or the pump is being
// torn down. This is the session's one always-on view of the worker, so it is where
// a worker that went away on its own is noticed.
ObserveWorkerTerminalState();
}
}
// IWorkerClient has no state-change callback, so a worker that reached Closed/Faulted
// without the gateway closing it — the client sent ShutdownWorker and the worker exited,
// the worker crashed, its pipe broke, a heartbeat timed out — used to leave the session
// reporting Ready until its lease expired (live finding). The session follows the worker
// into Faulted: it is permanently unusable (every command fails the readiness check), stays
// observable via GetSessionStatus with the reason, and the faulted-session sweep reaps it
// after FaultedGraceSeconds, accounting SessionClosed exactly once there. Not Closed: Closed
// means the gateway closed it, and a Closed session still in the registry would linger until
// its lease expired with nothing reaping it. Only a Ready session follows — during a
// gateway-initiated close the worker closing is the expected outcome, not a fault.
private void ObserveWorkerTerminalState()
{
lock (_syncRoot)
{
if (_state is not SessionState.Ready || _workerClient is null)
{
return;
}
WorkerClientState workerState = _workerClient.State;
if (workerState is not (WorkerClientState.Closed or WorkerClientState.Faulted))
{
return;
}
MarkFaulted(
$"Worker for session {SessionId} reached {workerState} outside a gateway-initiated close "
+ "(worker exited or faulted).");
}
}
@@ -901,18 +1091,6 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Extends the session lease to the specified expiration time.
/// </summary>
/// <param name="leaseExpiresAt">Timestamp when the lease expires.</param>
public void ExtendLease(DateTimeOffset leaseExpiresAt)
{
lock (_syncRoot)
{
_leaseExpiresAt = leaseExpiresAt;
}
}
/// <summary>
/// Determines whether the session lease has expired.
/// </summary>
@@ -969,6 +1147,25 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Determines whether a <see cref="SessionState.Ready"/> session that has <em>never</em>
/// attached an event-stream subscriber has been idle — no unary command since open or
/// since its last one — for at least the configured unattached grace. The lease monitor
/// reaps such a session exactly as it reaps an expired lease, so a client that opened and
/// vanished does not pin a worker for the full lease. Always <see langword="false"/> when
/// the grace is disabled, when the session ever had a subscriber (detach-grace and the
/// normal lease govern it from then on) or when one is attached now.
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the session is reapable as never-subscribed and idle; otherwise <see langword="false"/>.</returns>
public bool IsUnattachedGraceExpired(DateTimeOffset now)
{
lock (_syncRoot)
{
return IsUnattachedGraceExpiredCore(now);
}
}
/// <summary>
/// Attaches an event subscriber and returns a lease whose
/// <see cref="IEventSubscriberLease.Reader"/> reads the fanned public
@@ -1627,6 +1824,7 @@ public sealed class GatewaySession
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
}
Exception? workerShutdownFailure = null;
if (_workerClient is not null)
{
try
@@ -1635,6 +1833,12 @@ public sealed class GatewaySession
}
catch (Exception exception)
{
// The kill is the documented fallback for a worker that will not shut down
// (typically a ShutdownTimeout on a hung worker). When it succeeds the
// worker is gone and this close has done its job, so it completes as a
// normal close carrying the shutdown failure for the caller to log; only a
// kill that ALSO fails is a close failure (SessionCloseStartedException →
// CloseFailed), because then a worker may still be running.
try
{
_workerClient.Kill(reason);
@@ -1646,12 +1850,12 @@ public sealed class GatewaySession
new AggregateException(exception, killException));
}
throw;
workerShutdownFailure = exception;
}
}
MarkClosed();
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing, workerShutdownFailure);
}
catch (Exception exception) when (exception is not SessionCloseStartedException)
{
@@ -1718,6 +1922,15 @@ public sealed class GatewaySession
/// committing to <c>Closing</c>, so a reattach that wins the race leaves the session
/// in <c>Ready</c> and usable.
/// </para>
/// <para>
/// This is a <em>reservation</em>, not the close itself: it flips the state so no
/// reattach can revive the session and no other sweep pass re-selects it, but it
/// deliberately leaves <c>_closeStarted</c> untouched. The sweep's own
/// <see cref="CloseAsync"/> that follows is the first close to run, and must report
/// <c>AlreadyClosed: false</c> so the manager accounts <c>SessionClosed</c> for it;
/// setting <c>_closeStarted</c> here made that close look like a second, concurrent one
/// and every sweep-driven close went unaccounted.
/// </para>
/// </remarks>
internal bool TryBeginCloseIfExpired(DateTimeOffset now, out bool alreadyClosing)
{
@@ -1731,7 +1944,10 @@ public sealed class GatewaySession
// Re-verify eligibility atomically. If a subscriber reattached between the sweep's
// eligibility check and this point, neither condition holds and we decline.
bool eligible = IsLeaseExpiredCore(now) || IsFaultedReapableCore(now) || IsDetachGraceExpiredCore(now);
bool eligible = IsLeaseExpiredCore(now)
|| IsFaultedReapableCore(now)
|| IsDetachGraceExpiredCore(now)
|| IsUnattachedGraceExpiredCore(now);
if (!eligible)
{
alreadyClosing = false;
@@ -1739,7 +1955,6 @@ public sealed class GatewaySession
}
alreadyClosing = _closeStarted;
_closeStarted = true;
_state = SessionState.Closing;
return true;
}
@@ -1763,6 +1978,29 @@ public sealed class GatewaySession
|| _faultedAtUtc is null
|| now - _faultedAtUtc.Value >= _faultedGrace);
// Ready only: a session still starting its worker is not idle, it is waiting, and reaping it
// mid-startup would turn a slow worker start into an OpenFailed for a client that did nothing
// wrong. Idle is measured from the later of Ready and the last client activity (last unary
// command) rather than from open, so a slow start does not eat the window and a unary-only
// client that keeps calling is never reaped by this rule. An internal (in-process) subscriber
// — the gateway's own alarm monitor — counts as attached, or the gateway would reap its own
// monitor session every cycle.
private bool IsUnattachedGraceExpiredCore(DateTimeOffset now)
{
if (_unattachedGrace <= TimeSpan.Zero
|| _state is not SessionState.Ready
|| _readyAtUtc is null
|| _everHadEventSubscriber
|| _everHadInternalEventSubscriber
|| _activeEventSubscriberCount != 0)
{
return false;
}
DateTimeOffset idleSince = _lastClientActivityAt > _readyAtUtc.Value ? _lastClientActivityAt : _readyAtUtc.Value;
return now - idleSince >= _unattachedGrace;
}
// Final terminal transition; under _syncRoot to keep _state writes single-lock.
// Closed is unconditionally terminal — TransitionTo refuses to overwrite it —
// so we don't need to re-check the precondition here.
@@ -2,7 +2,21 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>Outcome of closing a session.</summary>
/// <param name="SessionId">Identifier of the closed session.</param>
/// <param name="FinalState">State the session ended in.</param>
/// <param name="AlreadyClosed">
/// <see langword="true"/> when the session was already closed (or being closed by another
/// caller) when this close acquired the close lock, so this call did no teardown of its own.
/// </param>
/// <param name="WorkerShutdownFailure">
/// The exception the worker's graceful shutdown threw when the close had to fall back to
/// killing the worker; <see langword="null"/> when the worker shut down cleanly. The close is
/// still a success — the worker is gone and the session is closed — so this is diagnostic
/// detail for the caller's log, not an error.
/// </param>
public sealed record SessionCloseResult(
string SessionId,
SessionState FinalState,
bool AlreadyClosed);
bool AlreadyClosed,
Exception? WorkerShutdownFailure = null);
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -20,6 +21,7 @@ public sealed class SessionManager : ISessionManager
public const string LeaseExpiredReason = "lease-expired";
public const string DetachGraceExpiredReason = "detach-grace-expired";
public const string FaultedReason = "faulted-reaped";
public const string UnattachedGraceExpiredReason = "unattached-grace-expired";
// Bounded so a mass expiry (or a host stop with a full registry) cannot stampede
// worker-process teardown: every concurrent close is one x86 worker being shut down or
@@ -27,6 +29,13 @@ public sealed class SessionManager : ISessionManager
// registry down at once.
private const int MaxParallelSessionCloses = 4;
// ShutdownAsync re-snapshots the registry until it is empty so an open that registered its
// session just after a snapshot is still drained. The stopping gate in OpenSessionAsync is
// what actually closes that window (it is re-checked after registration), so the loop is a
// backstop and a small bound is enough; anything still registered after the last pass is
// logged and left to the orphan terminator on the next start.
private const int MaxShutdownDrainPasses = 3;
private readonly ISessionRegistry _registry;
private readonly ISessionWorkerClientFactory _workerClientFactory;
private readonly GatewayMetrics _metrics;
@@ -39,6 +48,12 @@ public sealed class SessionManager : ISessionManager
private readonly Dashboard.Hubs.IDashboardEventBroadcaster? _dashboardEventBroadcaster;
private readonly ArrayAddressNormalizer? _addressNormalizer;
// 0 = accepting opens, 1 = stopping. Set by the host's ApplicationStopping signal (which fires
// before hosted services stop, while Kestrel still accepts gRPC calls) and, as a fallback for
// hosts without a lifetime, at the start of ShutdownAsync. Never reset: a manager that has
// begun draining does not come back.
private int _stopping;
/// <summary>
/// Initializes a new instance of <see cref="SessionManager"/>.
/// </summary>
@@ -61,6 +76,12 @@ public sealed class SessionManager : ISessionManager
/// metadata; handed to each session so the normalization runs at the outbound choke point.
/// Null in unit tests that do not exercise array-write ergonomics.
/// </param>
/// <param name="applicationLifetime">
/// Host lifetime whose <c>ApplicationStopping</c> closes the open gate as soon as the host
/// begins stopping — before hosted services stop and while Kestrel is still accepting gRPC
/// calls — so no session is opened behind the shutdown drain. Null in unit tests; the gate
/// then closes when <see cref="ShutdownAsync"/> starts.
/// </param>
public SessionManager(
ISessionRegistry registry,
ISessionWorkerClientFactory workerClientFactory,
@@ -71,7 +92,8 @@ public sealed class SessionManager : ISessionManager
Grpc.MxAccessGrpcMapper? eventMapper = null,
ILogger<SessionEventDistributor>? distributorLogger = null,
Dashboard.Hubs.IDashboardEventBroadcaster? dashboardEventBroadcaster = null,
ArrayAddressNormalizer? addressNormalizer = null)
ArrayAddressNormalizer? addressNormalizer = null,
IHostApplicationLifetime? applicationLifetime = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_workerClientFactory = workerClientFactory ?? throw new ArgumentNullException(nameof(workerClientFactory));
@@ -85,6 +107,10 @@ public sealed class SessionManager : ISessionManager
_addressNormalizer = addressNormalizer;
_options = options.Value;
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
// The registration is never disposed: the manager is a host singleton that lives exactly
// as long as the lifetime it observes.
applicationLifetime?.ApplicationStopping.Register(MarkStopping);
}
/// <inheritdoc />
@@ -104,10 +130,11 @@ public sealed class SessionManager : ISessionManager
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfStopping();
EnsureSessionCapacity();
GatewaySession? session = null;
bool sessionOpenedRecorded = false;
bool registered = false;
try
{
session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags);
@@ -118,36 +145,58 @@ public sealed class SessionManager : ISessionManager
$"Session id collision while opening session {session.SessionId}.");
}
registered = true;
// Re-check after registration. The stopping flag is written before ShutdownAsync takes
// its registry snapshot, so an open that does not observe it here registered its
// session before that snapshot and will be drained; one that does observe it rolls
// back itself. Either way no session is created behind the drain.
ThrowIfStopping();
session.TransitionTo(SessionState.StartingWorker);
IWorkerClient workerClient = await _workerClientFactory
.CreateAsync(session, cancellationToken)
.ConfigureAwait(false);
session.AttachWorkerClient(workerClient);
session.MarkReady();
_metrics.SessionOpened();
sessionOpenedRecorded = true;
// From here on a live worker exists and nothing but this method knows about it: every
// way out of this block other than "attached and Ready" must discard (kill + dispose)
// it, or it is an orphan the gateway cannot reattach. The session has been registered
// (and killable / drainable) for the whole worker startup, so the attach is refused
// if a kill or the host drain closed it meanwhile — it must not be returned on a dead
// session; and a caller that abandoned the open while the worker was starting gets
// no session either.
bool ready;
try
{
cancellationToken.ThrowIfCancellationRequested();
ready = await session
.TryAttachWorkerClientAndMarkReadyAsync(workerClient, _metrics.SessionOpened)
.ConfigureAwait(false);
}
catch
{
await DiscardWorkerClientAsync(session, workerClient).ConfigureAwait(false);
throw;
}
if (!ready)
{
await DiscardWorkerClientAsync(session, workerClient).ConfigureAwait(false);
throw new InvalidOperationException(
$"Session {session.SessionId} was closed while its worker was starting.");
}
return session;
}
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.GatewayStopping)
{
// Not an open failure: the gateway is going away and the caller should retry against
// the restarted one, so no fault is recorded and the code is surfaced as-is.
await RollBackOpenAsync(session, registered, exception).ConfigureAwait(false);
throw;
}
catch (Exception exception)
{
session?.MarkFaulted(exception.Message);
if (session is not null)
{
_registry.TryRemove(session.SessionId, out _);
await session.DisposeAsync().ConfigureAwait(false);
}
// If SessionOpened() already incremented the open-session gauge,
// a failure after that point (e.g. auto-subscribe rejection) must
// decrement it again so mxgateway.sessions.open does not leak.
if (sessionOpenedRecorded)
{
_metrics.SessionRemoved();
}
ReleaseSessionSlot();
await RollBackOpenAsync(session, registered, exception).ConfigureAwait(false);
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
_logger.LogWarning(
exception,
@@ -161,6 +210,62 @@ public sealed class SessionManager : ISessionManager
}
}
// Order — fault, deregister (dropping per-session event metrics), release the slot, dispose —
// matters: releasing the slot before disposal would let the next open race this worker's
// teardown on the same machine. A registered session goes through RemoveSessionAsync, the
// single removal path, which is a no-op when a concurrent kill or drain already removed it
// (that teardown released the slot and disposed the session). The open-session gauge is never
// touched here: SessionOpened is recorded inside the attach gate, after which nothing in the
// open sequence can fail.
private async Task RollBackOpenAsync(GatewaySession? session, bool registered, Exception cause)
{
if (session is null)
{
ReleaseSessionSlot();
return;
}
session.MarkFaulted(cause.Message);
if (registered)
{
await RemoveSessionAsync(session).ConfigureAwait(false);
return;
}
ReleaseSessionSlot();
await session.DisposeAsync().ConfigureAwait(false);
}
// Kills and disposes a worker client that arrived after its session was closed. Best-effort:
// the client is already orphaned from the session, so a failure here is logged rather than
// allowed to mask the open failure the caller is about to receive.
private async Task DiscardWorkerClientAsync(GatewaySession session, IWorkerClient workerClient)
{
try
{
workerClient.Kill("session-closed-during-startup");
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to kill the late worker for closed session {SessionId}.",
session.SessionId);
}
try
{
await workerClient.DisposeAsync().ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to dispose the late worker client for closed session {SessionId}.",
session.SessionId);
}
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
@@ -226,16 +331,27 @@ public sealed class SessionManager : ISessionManager
{
wasClosed = await session.KillWorkerWithCloseGateAsync(reason, cancellationToken).ConfigureAwait(false);
}
catch (ObjectDisposedException) when (!_registry.TryGet(sessionId, out _))
{
// Lost the race to a concurrent close: between the lookup above and the gate wait, the
// close finished, removed the session, accounted SessionClosed and disposed the gate.
// Nothing is left to kill and nothing to account; report it as already closed.
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: true);
}
catch (Exception exception)
{
session.MarkFaulted(exception.Message);
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
// The open-session gauge was incremented in OpenSessionAsync; every
// session reaching KillWorkerAsync had SessionOpened recorded. If the
// kill path throws, decrement the gauge here so mxgateway.sessions.open
// does not leak — mirroring the equivalent guard in OpenSessionAsync.
_metrics.SessionRemoved();
// If the open-session gauge was incremented for this session (it was not when the
// kill landed while the worker was still starting) and nobody has removed it yet
// (a removal accounts the gauge itself), decrement it here so mxgateway.sessions.open
// does not leak when the kill path throws.
if (session.OpenRecorded && _registry.TryGet(sessionId, out _))
{
_metrics.SessionRemoved();
}
await RemoveSessionAsync(session).ConfigureAwait(false);
throw new SessionManagerException(
SessionManagerErrorCode.CloseFailed,
@@ -243,7 +359,7 @@ public sealed class SessionManager : ISessionManager
exception);
}
if (!wasClosed)
if (!wasClosed && session.OpenRecorded)
{
_metrics.SessionClosed();
}
@@ -270,12 +386,14 @@ public sealed class SessionManager : ISessionManager
{
// A session is swept when its normal lease has expired, it has FAULTED (a faulted
// session is permanently unusable yet otherwise pins a session slot and a live x86
// worker until its DefaultLeaseSeconds lease expires), OR its detach-grace retention
// worker until its DefaultLeaseSeconds lease expires), its detach-grace retention
// window has elapsed (last external subscriber dropped and no client reconnected
// within DetachGraceSeconds). All three are the same teardown; only the reason differs
// so operators can tell a fault reap from a short reconnect-window expiry from a long
// idle-lease expiry in logs/metrics. Precedence when several fire simultaneously:
// lease-expired, then faulted, then detach-grace.
// within DetachGraceSeconds), OR it never attached a subscriber and has been idle for
// UnattachedSessionGraceSeconds (a client that opened and vanished). All four are the
// same teardown; only the reason differs so operators can tell a fault reap from a
// short reconnect-window expiry from a never-subscribed reap from a long idle-lease
// expiry in logs/metrics. Precedence when several fire simultaneously: lease-expired,
// then faulted, then detach-grace, then unattached-grace.
//
// TOCTOU note: eligibility is re-verified atomically inside TryBeginCloseIfExpired
// under _syncRoot, so a client that reattaches a subscriber between the check above
@@ -286,7 +404,9 @@ public sealed class SessionManager : ISessionManager
? FaultedReason
: session.IsDetachGraceExpired(now)
? DetachGraceExpiredReason
: null;
: session.IsUnattachedGraceExpired(now)
? UnattachedGraceExpiredReason
: null;
if (reason is null)
{
continue;
@@ -330,8 +450,11 @@ public sealed class SessionManager : ISessionManager
{
try
{
await CloseSessionCoreAsync(candidate.Session, candidate.Reason, closeToken).ConfigureAwait(false);
Interlocked.Increment(ref closedCount);
SessionCloseResult result = await CloseSessionCoreAsync(candidate.Session, candidate.Reason, closeToken).ConfigureAwait(false);
if (!result.AlreadyClosed)
{
Interlocked.Increment(ref closedCount);
}
}
catch (Exception exception)
{
@@ -381,49 +504,86 @@ public sealed class SessionManager : ISessionManager
// intentional: that sweep is periodic maintenance whose missed sessions are picked up by
// the next pass and, ultimately, by this drain. This drain is terminal — nothing runs after
// it — so it must not be abandoned partway.
await Parallel.ForEachAsync(
_registry.Snapshot(),
new ParallelOptions { MaxDegreeOfParallelism = MaxParallelSessionCloses },
async (session, _) =>
//
// Close the open gate BEFORE the first snapshot (see the re-check in OpenSessionAsync), then
// re-snapshot until the registry is empty so an open that registered just before the gate
// closed is drained too.
MarkStopping();
for (int pass = 0; pass < MaxShutdownDrainPasses; pass++)
{
if (_registry.Count == 0)
{
try
{
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Graceful shutdown failed for session {SessionId}; killing worker.",
session.SessionId);
return;
}
// Defensive fallback: CloseSessionCoreAsync's inner
// SessionCloseStartedException catch normally removes the session
// and accounts the close. This outer fallback only fires for
// sessions still in the registry — route through KillWorkerAsync
// so the bookkeeping is identical to the dashboard kill path.
if (_registry.TryGet(session.SessionId, out GatewaySession? registeredSession)
&& registeredSession is not null)
await Parallel.ForEachAsync(
_registry.Snapshot(),
new ParallelOptions { MaxDegreeOfParallelism = MaxParallelSessionCloses },
async (session, _) =>
{
try
{
try
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Graceful shutdown failed for session {SessionId}; killing worker.",
session.SessionId);
// Defensive fallback: CloseSessionCoreAsync's inner
// SessionCloseStartedException catch normally removes the session
// and accounts the close. This outer fallback only fires for
// sessions still in the registry — route through KillWorkerAsync
// so the bookkeeping is identical to the dashboard kill path.
if (_registry.TryGet(session.SessionId, out GatewaySession? registeredSession)
&& registeredSession is not null)
{
// Deliberately NOT the caller's token: the kill is the last-resort orphan
// preventer, so it must still run when the host stop deadline (or a
// sibling body's failure) has already cancelled the drain. It is a
// synchronous Kill plus registry/dispose bookkeeping, not a wait on
// the worker, so it cannot extend the drain meaningfully.
await KillWorkerAsync(session.SessionId, GatewayShutdownReason, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception killException)
{
_logger.LogWarning(
killException,
"Worker kill fallback failed for session {SessionId}.",
session.SessionId);
try
{
// Deliberately NOT the caller's token: the kill is the last-resort orphan
// preventer, so it must still run when the host stop deadline (or a
// sibling body's failure) has already cancelled the drain. It is a
// synchronous Kill plus registry/dispose bookkeeping, not a wait on
// the worker, so it cannot extend the drain meaningfully.
await KillWorkerAsync(session.SessionId, GatewayShutdownReason, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception killException)
{
_logger.LogWarning(
killException,
"Worker kill fallback failed for session {SessionId}.",
session.SessionId);
}
}
}
}
}).ConfigureAwait(false);
}).ConfigureAwait(false);
}
int remaining = _registry.Count;
if (remaining > 0)
{
_logger.LogWarning(
"Session drain finished with {Remaining} session(s) still registered after {Passes} passes; their workers are left to the orphan terminator on the next start.",
remaining,
MaxShutdownDrainPasses);
}
}
private void MarkStopping()
{
Volatile.Write(ref _stopping, 1);
}
private void ThrowIfStopping()
{
if (Volatile.Read(ref _stopping) != 0)
{
throw new SessionManagerException(
SessionManagerErrorCode.GatewayStopping,
"The gateway is stopping and is not accepting new sessions.");
}
}
private async Task<SessionCloseResult> CloseSessionCoreAsync(
@@ -435,7 +595,19 @@ public sealed class SessionManager : ISessionManager
try
{
SessionCloseResult result = await session.CloseAsync(reason, cancellationToken).ConfigureAwait(false);
if (!wasClosed && !result.AlreadyClosed)
if (result.WorkerShutdownFailure is not null)
{
// A clean close from the session's point of view (the worker was killed and the
// session is Closed), but the operator should see that the worker would not stop
// gracefully — a run of these is the signal that workers are hanging on shutdown.
_logger.LogWarning(
result.WorkerShutdownFailure,
"Worker for session {SessionId} did not shut down gracefully and was killed; reason={Reason}.",
session.SessionId,
reason);
}
if (!wasClosed && !result.AlreadyClosed && session.OpenRecorded)
{
_metrics.SessionClosed();
}
@@ -447,10 +619,17 @@ public sealed class SessionManager : ISessionManager
{
throw;
}
catch (ObjectDisposedException) when (!_registry.TryGet(session.SessionId, out _))
{
// Lost the race to a concurrent close that removed, accounted and disposed the
// session after this caller picked it up (a sweep's snapshot, a client close that
// looked it up first). Not a failure and nothing to redo.
return new SessionCloseResult(session.SessionId, SessionState.Closed, AlreadyClosed: true);
}
catch (SessionCloseStartedException exception)
{
session.MarkFaulted(exception.Message);
if (!wasClosed)
if (!wasClosed && session.OpenRecorded)
{
// Account the close as a SessionClosed (decrements the open-session
// gauge AND increments the sessions.closed counter), not just
@@ -566,7 +745,8 @@ public sealed class SessionManager : ISessionManager
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
_addressNormalizer,
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)),
ownerDashboardTags);
ownerDashboardTags,
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.UnattachedSessionGraceSeconds)));
}
private static string CreateClientCorrelationId(
@@ -17,4 +17,12 @@ public enum SessionManagerErrorCode
/// Maps to gRPC <c>PermissionDenied</c>.
/// </summary>
PermissionDenied,
/// <summary>
/// The gateway is stopping, so no new session can be opened: a session created behind the
/// shutdown drain's snapshot would have nobody left to close it and its worker would
/// outlive the gateway as an orphan. Maps to gRPC <c>Unavailable</c> so the client retries
/// against the restarted gateway.
/// </summary>
GatewayStopping,
}
@@ -51,6 +51,7 @@
"DefaultLeaseSeconds": 1800,
"LeaseSweepIntervalSeconds": 30,
"DetachGraceSeconds": 30,
"UnattachedSessionGraceSeconds": 0,
"AllowMultipleEventSubscribers": false,
"MaxEventSubscribersPerSession": 8
},
@@ -619,6 +619,34 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a negative <see cref="SessionOptions.UnattachedSessionGraceSeconds"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenUnattachedSessionGraceSecondsIsNegative()
{
GatewayOptions options = CloneWithSessions(
ValidOptions(),
new SessionOptions { UnattachedSessionGraceSeconds = -1 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Sessions:UnattachedSessionGraceSeconds"));
}
/// <summary>Verifies a zero (off) and a positive <see cref="SessionOptions.UnattachedSessionGraceSeconds"/> pass validation.</summary>
/// <param name="seconds">Grace window under test.</param>
[Theory]
[InlineData(0)]
[InlineData(120)]
public void Validate_Succeeds_WhenUnattachedSessionGraceSecondsIsZeroOrPositive(int seconds)
{
GatewayOptions options = CloneWithSessions(
ValidOptions(),
new SessionOptions { UnattachedSessionGraceSeconds = seconds });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
// -------------------------------------------------------------------------
// ReplayBufferCapacity / ReplayRetentionSeconds validation
// -------------------------------------------------------------------------
@@ -76,6 +76,49 @@ public sealed class GatewaySessionTests
await session.DisposeAsync();
}
/// <summary>
/// A worker client that arrives after the session has already been closed (an admin kill
/// or the host drain landed while the worker was still starting) must be refused:
/// <see cref="GatewaySession.AttachWorkerClient"/> returns <see langword="false"/> and
/// leaves <see cref="GatewaySession.WorkerClient"/> unset, so the caller can discard the
/// client instead of handing a dead session back as an opened one.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachWorkerClient_AfterClose_RefusesLateWorkerClient()
{
GatewaySession session = CreateSession();
session.TransitionTo(SessionState.StartingWorker);
await session.CloseAsync("admin-kill", CancellationToken.None);
bool attached = session.AttachWorkerClient(new BlockingShutdownWorkerClient());
Assert.False(attached);
Assert.Null(session.WorkerClient);
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Disposal is the other end of the same window: once the session's distributor has been
/// torn down nothing can ever pump a late worker's events, so the attach is refused there
/// too rather than storing a client that is never read.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachWorkerClient_AfterDispose_RefusesLateWorkerClient()
{
GatewaySession session = CreateSession();
session.TransitionTo(SessionState.StartingWorker);
await session.DisposeAsync();
bool attached = session.AttachWorkerClient(new BlockingShutdownWorkerClient());
Assert.False(attached);
Assert.Null(session.WorkerClient);
}
/// <summary>
/// A <see cref="GatewaySession.MarkFaulted"/> issued
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
@@ -0,0 +1,151 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
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.Gateway.Workers.Fakes;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// A worker that goes away on its own — the client sent the <c>ShutdownWorker</c> control
/// command and the worker exited cleanly, or the worker crashed / its pipe broke — must be
/// noticed by its <see cref="GatewaySession"/>. The real <see cref="WorkerClient"/> reaches
/// <c>Closed</c> / <c>Faulted</c>, but nothing used to tell the session, which kept reporting
/// <c>Ready</c> (live windev finding) until its lease expired.
/// </summary>
public sealed class GatewaySessionWorkerExitTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// After the client's <c>ShutdownWorker</c> command the fake worker replies OK, sends the
/// shutdown ack and the worker client closes. The session must fault (the worker is gone
/// outside a gateway-initiated close), stay observable with a fault reason, and then be
/// reaped by the ordinary faulted-session sweep — which is where the session is accounted
/// closed, exactly once.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ClientShutdownWorker_WorkerExits_SessionFaultsAndSweepReapsItOnce()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
using GatewayMetrics metrics = new();
WorkerClient client = fakeWorker.CreateClient(metrics: metrics);
await StartClientAsync(fakeWorker, client);
SessionManager manager = CreateManager(client, metrics);
GatewaySession session = await OpenSessionAsync(manager);
using IEventSubscriberLease stream = session.AttachEventSubscriber(maxSubscribers: 1);
Task<WorkerCommandReply> invokeTask = manager.InvokeAsync(session.SessionId, CreateShutdownWorkerCommand(), CancellationToken.None);
await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
await WaitUntilAsync(() => session.State == SessionState.Faulted);
Assert.Equal(WorkerClientState.Closed, client.State);
Assert.Contains("worker", session.FinalFault, StringComparison.OrdinalIgnoreCase);
Assert.True(manager.TryGetSession(session.SessionId, out _));
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
Assert.Equal(0, metrics.GetSnapshot().SessionsClosed);
int closedCount = await manager.CloseExpiredLeasesAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Equal(1, closedCount);
Assert.Equal(SessionState.Closed, session.State);
Assert.False(manager.TryGetSession(session.SessionId, out _));
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.OpenSessions);
}
/// <summary>
/// The other way a worker goes away: its pipe breaks (crash). The worker client faults, and
/// the session must follow it to <c>Faulted</c> rather than stay <c>Ready</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WorkerPipeBreaks_SessionFaults()
{
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
using GatewayMetrics metrics = new();
WorkerClient client = fakeWorker.CreateClient(metrics: metrics);
await StartClientAsync(fakeWorker, client);
SessionManager manager = CreateManager(client, metrics);
GatewaySession session = await OpenSessionAsync(manager);
using IEventSubscriberLease stream = session.AttachEventSubscriber(maxSubscribers: 1);
await fakeWorker.DisposeWorkerSideAsync();
await WaitUntilAsync(() => session.State == SessionState.Faulted);
Assert.Equal(WorkerClientState.Faulted, client.State);
await manager.CloseExpiredLeasesAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(manager.TryGetSession(session.SessionId, out _));
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
private static async Task StartClientAsync(FakeWorkerHarness fakeWorker, WorkerClient client)
{
Task startTask = client.StartAsync(CancellationToken.None);
await fakeWorker.CompleteStartupAsync();
await startTask.WaitAsync(TestTimeout);
Assert.Equal(WorkerClientState.Ready, client.State);
}
private static SessionManager CreateManager(IWorkerClient client, GatewayMetrics metrics)
{
return new SessionManager(
new SessionRegistry(),
new SingleClientFactory(client),
Options.Create(new GatewayOptions()),
metrics);
}
private static Task<GatewaySession> OpenSessionAsync(SessionManager manager)
{
return manager.OpenSessionAsync(
new SessionOpenRequest(RequestedBackend: null, ClientSessionName: "worker-exit", ClientCorrelationId: null, CommandTimeout: null),
"client-1",
ownerKeyId: null,
CancellationToken.None);
}
private static WorkerCommand CreateShutdownWorkerCommand()
{
return new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.ShutdownWorker,
ShutdownWorker = new ShutdownWorkerCommand(),
},
};
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
DateTime deadline = DateTime.UtcNow + TestTimeout;
while (!condition())
{
if (DateTime.UtcNow > deadline)
{
Assert.Fail("Condition was not met within the test timeout.");
}
await Task.Delay(10);
}
}
private sealed class SingleClientFactory(IWorkerClient client) : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(GatewaySession session, CancellationToken cancellationToken)
{
return Task.FromResult(client);
}
}
}
@@ -641,10 +641,16 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
/// <summary>
/// A worker that does not shut down gracefully is killed, and that is a <em>successful</em>
/// close: the kill is the documented fallback, the worker is gone and the session is
/// <c>Closed</c>. Reporting it as <c>CloseFailed</c> + <c>Faulted</c> + a fault metric
/// misreported every hung-worker close as a teardown failure. <c>CloseFailed</c> is reserved
/// for both the shutdown and the kill failing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorkerAndReportsClosed()
{
FakeWorkerClient workerClient = new()
{
@@ -652,15 +658,24 @@ public sealed class SessionManagerTests
WorkerClientErrorCode.ShutdownTimeout,
"Worker shutdown timed out."),
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(session.SessionId, CancellationToken.None));
SessionCloseResult result = await manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.False(result.AlreadyClosed);
Assert.Same(workerClient.ShutdownException, result.WorkerShutdownFailure);
Assert.Equal(SessionState.Closed, session.State);
Assert.Null(session.FinalFault);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.Equal(1, workerClient.KillCount);
Assert.False(manager.TryGetSession(session.SessionId, out _));
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(0, snapshot.Faults);
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.OpenSessions);
}
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
@@ -689,31 +704,59 @@ public sealed class SessionManagerTests
CancellationToken.None);
metrics.EventReceived(firstSession.SessionId, MxEventFamily.OnDataChange.ToString());
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(firstSession.SessionId, CancellationToken.None));
SessionCloseResult result = await manager.CloseSessionAsync(firstSession.SessionId, CancellationToken.None);
GatewaySession secondSession = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-2",
ownerKeyId: null,
CancellationToken.None);
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.False(manager.TryGetSession(firstSession.SessionId, out _));
Assert.True(manager.TryGetSession(secondSession.SessionId, out _));
Assert.Equal(1, registry.Count);
Assert.Equal(1, failingWorkerClient.KillCount);
Assert.Equal(1, failingWorkerClient.DisposeCount);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
// A close-that-failed 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>
/// Only when the graceful shutdown AND the kill fallback both fail is the close a failure:
/// the session is faulted, removed, counted as closed (it is leaving the registry) and the
/// caller gets <c>CloseFailed</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_WhenShutdownAndKillBothFail_ThrowsCloseFailed()
{
FakeWorkerClient workerClient = new()
{
ShutdownException = new WorkerClientException(
WorkerClientErrorCode.ShutdownTimeout,
"Worker shutdown timed out."),
KillException = new InvalidOperationException("worker kill failed"),
};
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(session.SessionId, CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
Assert.Equal(SessionState.Faulted, session.State);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.Equal(1, workerClient.KillCount);
Assert.False(manager.TryGetSession(session.SessionId, out _));
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.Faults);
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, 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]
@@ -881,6 +924,71 @@ public sealed class SessionManagerTests
Assert.True(metrics.GetSnapshot().Faults > 0);
}
/// <summary>
/// A kill whose session lookup succeeded but whose close gate is disposed by the time it
/// waits on it lost the race to a close that already removed, disposed and accounted the
/// session. It must report <c>AlreadyClosed</c> and touch nothing: the old catch recorded a
/// fault, faulted the session and decremented the open gauge a second time (visible here as
/// the other open session's slot disappearing from the gauge).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_WhenCloseWonTheRace_ReportsAlreadyClosedWithoutDoubleAccounting()
{
FakeWorkerClient racedClient = new();
FakeWorkerClient otherClient = new();
StaleRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new QueueingSessionWorkerClientFactory(racedClient, otherClient), registry, metrics);
GatewaySession raced = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None);
await manager.CloseSessionAsync(raced.SessionId, CancellationToken.None);
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
// The kill looked the session up just before the close removed and disposed it.
registry.ServeStaleLookupOnce(raced);
SessionCloseResult result = await manager.KillWorkerAsync(raced.SessionId, "admin-kill", CancellationToken.None);
Assert.True(result.AlreadyClosed);
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(0, racedClient.KillCount);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.OpenSessions);
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.Faults);
}
/// <summary>
/// The sweep's counterpart of the kill race: a session that a concurrent close removed and
/// disposed between the snapshot and the close call is simply skipped — it is not a sweep
/// failure to log, and it is not counted as closed by the sweep.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_WhenCloseWonTheRace_DoesNotReportSweepFailure()
{
FakeWorkerClient workerClient = new();
StaleRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), registry, metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
SetLeaseExpiry(session, now.AddSeconds(-1));
await manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
registry.ServeStaleSnapshot(session);
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(1, workerClient.ShutdownCount);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.OpenSessions);
Assert.Equal(0, snapshot.Faults);
}
/// <summary>
/// Concurrent kills on the same session must not
/// double-increment <c>mxgateway.sessions.closed</c>. The first kill wins, the second
@@ -926,7 +1034,9 @@ public sealed class SessionManagerTests
/// <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.
/// graceful-close failure path under-counts the closed counter. Both the shutdown and the
/// in-close kill fail here, because a kill that succeeds is a clean close and never reaches
/// the fallback.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -935,6 +1045,7 @@ public sealed class SessionManagerTests
FakeWorkerClient throwingClient = new()
{
ShutdownException = new InvalidOperationException("worker shutdown failed"),
KillException = new InvalidOperationException("worker kill failed"),
};
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
@@ -978,6 +1089,133 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>
/// A failed open must leave no per-session event metrics behind: the rollback goes through
/// the same removal path every other teardown uses, so <c>EventsBySession</c> is cleared
/// for the session that never opened.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionEventMetrics()
{
using GatewayMetrics metrics = new();
EventRecordingFailingSessionWorkerClientFactory factory = new(metrics);
SessionManager manager = CreateManager(factory, metrics: metrics);
await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None));
Assert.NotNull(factory.StartedSessionId);
Assert.False(metrics.GetSnapshot().EventsBySession.ContainsKey(factory.StartedSessionId!));
}
/// <summary>
/// The session is registered before its worker is created, so an admin Kill can land while
/// the worker is still starting (up to <c>StartupTimeoutSeconds</c>). The late worker client
/// must not be attached to the already-closed session and handed back to the caller as if
/// the open had succeeded: it is killed and disposed, the open fails with
/// <c>OpenFailed</c>, and nothing is left in the registry or the metrics.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenKilledDuringWorkerStartup_FailsOpenAndDiscardsLateWorkerClient()
{
FakeWorkerClient workerClient = new();
GatedSessionWorkerClientFactory factory = new(workerClient);
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(factory, registry, metrics, CreateOptions(maxSessions: 1));
Task<GatewaySession> openTask = manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
GatewaySession starting = await factory.WaitForCreateStartAsync();
SessionCloseResult kill = await manager.KillWorkerAsync(starting.SessionId, "admin-kill", CancellationToken.None);
factory.ReleaseCreate();
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () => await openTask);
Assert.Equal(SessionState.Closed, kill.FinalState);
Assert.Equal(SessionManagerErrorCode.OpenFailed, exception.ErrorCode);
Assert.Equal(1, workerClient.KillCount);
Assert.Equal(1, workerClient.DisposeCount);
Assert.Null(starting.WorkerClient);
Assert.Equal(0, registry.Count);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(0, snapshot.OpenSessions);
Assert.Equal(0, snapshot.SessionsOpened);
Assert.Equal(0, snapshot.SessionsClosed);
// The slot the killed open held must have been released exactly once: a replacement open
// succeeds against MaxSessions = 1 (the released gate hands the same fake straight back).
GatewaySession reopened = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, reopened.State);
Assert.Equal(1, registry.Count);
}
/// <summary>
/// A caller that abandons its open (deadline, disconnect) while the worker is starting
/// must not leave the worker running: whatever fails between the worker arriving and the
/// session becoming Ready — here the caller's own cancellation — the late worker client is
/// killed and disposed before the failure surfaces.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenCallerCancelledDuringWorkerStartup_DiscardsWorkerClient()
{
FakeWorkerClient workerClient = new();
GatedSessionWorkerClientFactory factory = new(workerClient);
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(factory, registry, metrics);
using CancellationTokenSource cancellation = new();
Task<GatewaySession> openTask = manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, cancellation.Token);
GatewaySession starting = await factory.WaitForCreateStartAsync();
await cancellation.CancelAsync();
factory.ReleaseCreate();
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () => await openTask);
Assert.Equal(SessionManagerErrorCode.OpenFailed, exception.ErrorCode);
Assert.IsAssignableFrom<OperationCanceledException>(exception.InnerException);
Assert.Equal(1, workerClient.KillCount);
Assert.Equal(1, workerClient.DisposeCount);
Assert.NotEqual(SessionState.Ready, starting.State);
Assert.Equal(0, registry.Count);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
Assert.Equal(0, metrics.GetSnapshot().SessionsOpened);
}
/// <summary>
/// Same window as the kill case, driven by the host drain: a session still starting its
/// worker when <c>ShutdownAsync</c> runs is closed by the drain, and the worker client that
/// arrives afterwards is discarded rather than left running as an orphan.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenShutdownDuringWorkerStartup_FailsOpenAndDiscardsLateWorkerClient()
{
FakeWorkerClient workerClient = new();
GatedSessionWorkerClientFactory factory = new(workerClient);
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(factory, registry, metrics);
Task<GatewaySession> openTask = manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
GatewaySession starting = await factory.WaitForCreateStartAsync();
await manager.ShutdownAsync(CancellationToken.None);
factory.ReleaseCreate();
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () => await openTask);
Assert.Equal(SessionManagerErrorCode.OpenFailed, exception.ErrorCode);
Assert.Equal(SessionState.Closed, starting.State);
Assert.Equal(1, workerClient.KillCount);
Assert.Equal(1, workerClient.DisposeCount);
Assert.Equal(0, registry.Count);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
Assert.Equal(0, metrics.GetSnapshot().SessionsClosed);
}
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -990,8 +1228,8 @@ public sealed class SessionManagerTests
GatewaySession expiredSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
GatewaySession activeSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
expiredSession.ExtendLease(now.AddSeconds(-1));
activeSession.ExtendLease(now.AddMinutes(5));
SetLeaseExpiry(expiredSession, now.AddSeconds(-1));
SetLeaseExpiry(activeSession, now.AddMinutes(5));
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
@@ -1002,6 +1240,243 @@ public sealed class SessionManagerTests
Assert.Equal(0, activeClient.ShutdownCount);
}
/// <summary>
/// Every sweep-driven close — lease expiry, faulted reap and detach-grace expiry — must
/// account <c>SessionClosed</c> exactly once. The sweep begins the close itself
/// (<c>TryBeginCloseIfExpired</c>) before delegating to the shared close path, and that
/// reservation must not make the delegated close look like a second, already-running close
/// whose accounting is skipped: the open-session gauge would then never drop for swept
/// sessions and the closed counter would under-count them.
/// </summary>
/// <param name="sweepKind">Which sweep condition selects the session.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(SessionManager.LeaseExpiredReason)]
[InlineData(SessionManager.FaultedReason)]
[InlineData(SessionManager.DetachGraceExpiredReason)]
public async Task CloseExpiredLeasesAsync_SweptClose_AccountsSessionClosedExactlyOnce(string sweepKind)
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
metrics: metrics,
options: CreateOptions(detachGraceSeconds: 30),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
switch (sweepKind)
{
case SessionManager.LeaseExpiredReason:
SetLeaseExpiry(session, clock.GetUtcNow().AddSeconds(-1));
break;
case SessionManager.FaultedReason:
session.MarkFaulted("test-fault");
break;
case SessionManager.DetachGraceExpiredReason:
session.AttachEventSubscriber(maxSubscribers: 1).Dispose();
clock.Advance(TimeSpan.FromSeconds(31));
break;
default:
throw new ArgumentOutOfRangeException(nameof(sweepKind), sweepKind, "Unknown sweep kind.");
}
int closedCount = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(1, closedCount);
Assert.Equal(SessionState.Closed, session.State);
Assert.False(manager.TryGetSession(session.SessionId, out _));
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.OpenSessions);
}
/// <summary>
/// With <c>UnattachedSessionGraceSeconds</c> set, a session that never attached an event
/// subscriber and has been idle for the window is reaped by the ordinary lease sweep even
/// though its 30-minute lease is nowhere near expiry. This is the knob for clients that
/// open a session and vanish (OtOpcUa's GalaxyDriver on a failed start): without it each
/// such open pins an x86 worker for <c>DefaultLeaseSeconds</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_ReapsNeverSubscribedSessionAfterUnattachedGrace()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
metrics: metrics,
options: CreateOptions(unattachedSessionGraceSeconds: 60),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(59));
int closedBefore = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedBefore);
Assert.Equal(SessionState.Ready, session.State);
clock.Advance(TimeSpan.FromSeconds(1));
int closedAfter = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(1, closedAfter);
Assert.Equal(SessionState.Closed, session.State);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.False(manager.TryGetSession(session.SessionId, out _));
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>
/// The window is an idle timeout, not a hard deadline from open: unary activity restarts
/// it, so a client that only ever reads and writes (never streams) is not reaped while it
/// keeps calling.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_UnattachedGrace_IsRestartedByUnaryActivity()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(unattachedSessionGraceSeconds: 60),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(59));
await manager.InvokeAsync(session.SessionId, CreateCommand(MxCommandKind.Ping), CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(59));
int closedWhileActive = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedWhileActive);
Assert.Equal(SessionState.Ready, session.State);
clock.Advance(TimeSpan.FromSeconds(1));
int closedAfterIdle = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(1, closedAfterIdle);
Assert.Equal(SessionState.Closed, session.State);
}
/// <summary>
/// A session that ever had an event subscriber is outside this knob's remit — once it has
/// streamed, detach-grace and the normal lease govern it — and a session with a subscriber
/// attached right now is never idle.
/// </summary>
/// <param name="keepSubscriberAttached">Whether the subscriber is still attached at sweep time.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CloseExpiredLeasesAsync_UnattachedGrace_DoesNotReapSessionThatSubscribed(bool keepSubscriberAttached)
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(detachGraceSeconds: 0, unattachedSessionGraceSeconds: 60),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
IDisposable subscriber = session.AttachEventSubscriber(maxSubscribers: 1);
if (!keepSubscriberAttached)
{
subscriber.Dispose();
}
clock.Advance(TimeSpan.FromSeconds(61));
int closedCount = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, workerClient.ShutdownCount);
subscriber.Dispose();
}
/// <summary>
/// The gateway's own alarm-monitor session only ever attaches an <em>internal</em>
/// (non-counted) subscriber. That must count as "attached" for this rule, or the gateway
/// would reap its own monitor every cycle once the knob is on.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_UnattachedGrace_DoesNotReapSessionWithInternalSubscriber()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(unattachedSessionGraceSeconds: 60),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
using IEventSubscriberLease internalLease = session.AttachInternalEventSubscriber();
clock.Advance(TimeSpan.FromSeconds(61));
int closedCount = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, workerClient.ShutdownCount);
}
/// <summary>
/// The idle clock starts when the session becomes Ready, not when it was opened: a worker
/// that took longer than the window to start must not hand the client a session the very
/// next sweep reaps before it had a chance to subscribe.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_UnattachedGrace_IdleClockStartsAtReady()
{
FakeWorkerClient workerClient = new();
GatedSessionWorkerClientFactory factory = new(workerClient);
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
factory,
options: CreateOptions(unattachedSessionGraceSeconds: 60),
timeProvider: clock);
Task<GatewaySession> openTask = manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
await factory.WaitForCreateStartAsync();
clock.Advance(TimeSpan.FromSeconds(120));
factory.ReleaseCreate();
GatewaySession session = await openTask;
int closedRightAfterReady = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedRightAfterReady);
Assert.Equal(SessionState.Ready, session.State);
clock.Advance(TimeSpan.FromSeconds(60));
int closedAfterIdle = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(1, closedAfterIdle);
Assert.Equal(SessionState.Closed, session.State);
}
/// <summary>
/// The knob is off by default: a never-subscribed idle session lives for its full lease.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_UnattachedGrace_IsDisabledByDefault()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
clock.Advance(TimeSpan.FromMinutes(20));
int closedCount = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
}
/// <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]
@@ -1011,7 +1486,7 @@ public sealed class SessionManagerTests
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
session.ExtendLease(now.AddSeconds(-1));
SetLeaseExpiry(session, now.AddSeconds(-1));
using IDisposable eventSubscriber = session.AttachEventSubscriber(maxSubscribers: 1);
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
@@ -1118,8 +1593,8 @@ public sealed class SessionManagerTests
DateTimeOffset now = DateTimeOffset.UtcNow;
// Both leases are far in the future, so only the fault can reap the faulted session.
faultedSession.ExtendLease(now.AddMinutes(30));
healthySession.ExtendLease(now.AddMinutes(30));
SetLeaseExpiry(faultedSession, now.AddMinutes(30));
SetLeaseExpiry(healthySession, now.AddMinutes(30));
faultedSession.MarkFaulted("test fault");
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
@@ -1180,8 +1655,8 @@ public sealed class SessionManagerTests
GatewaySession firstSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
GatewaySession secondSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
firstSession.ExtendLease(now.AddSeconds(-1));
secondSession.ExtendLease(now.AddSeconds(-1));
SetLeaseExpiry(firstSession, now.AddSeconds(-1));
SetLeaseExpiry(secondSession, now.AddSeconds(-1));
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
@@ -1233,8 +1708,8 @@ public sealed class SessionManagerTests
GatewaySession failingSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
GatewaySession healthySession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
failingSession.ExtendLease(now.AddSeconds(-1));
healthySession.ExtendLease(now.AddSeconds(-1));
SetLeaseExpiry(failingSession, now.AddSeconds(-1));
SetLeaseExpiry(healthySession, now.AddSeconds(-1));
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseExpiredLeasesAsync(now, CancellationToken.None));
@@ -1273,6 +1748,84 @@ public sealed class SessionManagerTests
Assert.False(manager.TryGetSession(secondSession.SessionId, out _));
}
/// <summary>
/// Once the drain has started, a new open must be refused with <c>GatewayStopping</c>
/// rather than accepted: a session created behind the drain's snapshot has nobody left to
/// close it, and its worker would outlive the gateway as an orphan. The refusal is not an
/// open failure — no slot is taken, nothing is registered and no fault is recorded.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_AfterShutdownStarted_ThrowsGatewayStopping()
{
FakeWorkerClient workerClient = new();
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), registry, metrics);
await manager.ShutdownAsync(CancellationToken.None);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.GatewayStopping, exception.ErrorCode);
Assert.Equal(0, registry.Count);
Assert.Equal(0, metrics.GetSnapshot().SessionsOpened);
Assert.Equal(0, metrics.GetSnapshot().Faults);
}
/// <summary>
/// The host signals <c>ApplicationStopping</c> before hosted services stop, and Kestrel keeps
/// accepting gRPC calls until its own <c>StopAsync</c> runs — so the gate must close on the
/// lifetime signal, not only when <c>ShutdownAsync</c> is finally called.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenApplicationStopping_ThrowsGatewayStopping()
{
FakeWorkerClient workerClient = new();
using FakeHostApplicationLifetime lifetime = new();
SessionManager manager = new(
new SessionRegistry(),
new FakeSessionWorkerClientFactory(workerClient),
Options.Create(CreateOptions()),
new GatewayMetrics(),
applicationLifetime: lifetime);
GatewaySession beforeStopping = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, beforeStopping.State);
lifetime.SignalStopping();
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", ownerKeyId: null, CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.GatewayStopping, exception.ErrorCode);
}
/// <summary>
/// An open that passed the stopping gate but registered its session after the drain took
/// its snapshot is still caught: the drain re-snapshots until the registry is empty, so the
/// late session is closed by the drain rather than left running.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShutdownAsync_DrainsSessionRegisteredAfterFirstSnapshot()
{
FakeWorkerClient workerClient = new();
LateFirstSnapshotRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), registry, metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
await manager.ShutdownAsync(CancellationToken.None);
Assert.True(registry.SnapshotCalls >= 2);
Assert.Equal(SessionState.Closed, session.State);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.Equal(0, registry.Count);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1322,7 +1875,8 @@ public sealed class SessionManagerTests
int defaultLeaseSeconds = 1800,
int detachGraceSeconds = 0,
int workerReadyWaitTimeoutMs = 0,
int faultedGraceSeconds = 0)
int faultedGraceSeconds = 0,
int unattachedSessionGraceSeconds = 0)
{
return new GatewayOptions
{
@@ -1334,6 +1888,7 @@ public sealed class SessionManagerTests
DetachGraceSeconds = detachGraceSeconds,
WorkerReadyWaitTimeoutMs = workerReadyWaitTimeoutMs,
FaultedGraceSeconds = faultedGraceSeconds,
UnattachedSessionGraceSeconds = unattachedSessionGraceSeconds,
},
Worker = new WorkerOptions
{
@@ -1343,6 +1898,17 @@ public sealed class SessionManagerTests
};
}
/// <summary>
/// Moves a session's lease expiry by back-dating its last client activity, which is the only
/// production path that moves the lease (<c>_leaseExpiresAt = activityAt + LeaseDuration</c>).
/// </summary>
/// <param name="session">Session whose lease to move.</param>
/// <param name="leaseExpiresAt">Wanted lease expiry.</param>
private static void SetLeaseExpiry(GatewaySession session, DateTimeOffset leaseExpiresAt)
{
session.TouchClientActivity(leaseExpiresAt - session.LeaseDuration);
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(
@@ -1422,6 +1988,203 @@ public sealed class SessionManagerTests
}
}
/// <summary>
/// Factory that records a per-session event metric for the session it is starting and
/// then fails, so a test can check the open rollback clears that metric.
/// </summary>
private sealed class EventRecordingFailingSessionWorkerClientFactory(GatewayMetrics metrics) : ISessionWorkerClientFactory
{
/// <summary>Gets the id of the session whose worker creation was attempted.</summary>
public string? StartedSessionId { get; private set; }
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
StartedSessionId = session.SessionId;
metrics.EventReceived(session.SessionId, MxEventFamily.OnDataChange.ToString());
throw new InvalidOperationException("worker startup failed");
}
}
/// <summary>
/// Factory whose <see cref="CreateAsync"/> parks until the test releases it, so the test can
/// act on the registered-but-still-starting session (kill it, drain the manager) inside the
/// window between registry insertion and worker attach. Once released it hands the same
/// client to every later call.
/// </summary>
private sealed class GatedSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
private readonly TaskCompletionSource<GatewaySession> _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Waits until the manager has asked for a worker and returns the starting session.</summary>
/// <returns>The session whose worker is being created.</returns>
public Task<GatewaySession> WaitForCreateStartAsync()
{
return _entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Lets the parked worker creation complete.</summary>
public void ReleaseCreate()
{
_release.TrySetResult();
}
/// <inheritdoc />
public async Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
// Deliberately not bound to the caller's token: the tests that cancel the open while
// the worker is starting need the worker to still ARRIVE after the cancellation, which
// is the orphan-prone case.
_entered.TrySetResult(session);
await _release.Task.WaitAsync(TimeSpan.FromSeconds(5));
return workerClient;
}
}
/// <summary>
/// Registry that can replay a race the real registry only produces by timing: a session a
/// concurrent close has already removed and disposed is still returned by one lookup (the
/// kill looked it up just before the removal) or still appears in one snapshot (the sweep
/// snapshotted just before the removal).
/// </summary>
private sealed class StaleRegistry : ISessionRegistry
{
private readonly SessionRegistry _inner = new();
private GatewaySession? _staleLookup;
private GatewaySession? _staleSnapshot;
/// <summary>Makes the next lookup of <paramref name="session"/>'s id return it although it was removed.</summary>
/// <param name="session">Removed session to serve once.</param>
public void ServeStaleLookupOnce(GatewaySession session)
{
_staleLookup = session;
}
/// <summary>Makes the next snapshot include <paramref name="session"/> although it was removed.</summary>
/// <param name="session">Removed session to include once.</param>
public void ServeStaleSnapshot(GatewaySession session)
{
_staleSnapshot = session;
}
/// <inheritdoc />
public int Count => _inner.Count;
/// <inheritdoc />
public int ActiveCount => _inner.ActiveCount;
/// <inheritdoc />
public bool TryAdd(GatewaySession session) => _inner.TryAdd(session);
/// <inheritdoc />
public bool TryGet(string sessionId, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out GatewaySession session)
{
if (_staleLookup is not null && _staleLookup.SessionId == sessionId)
{
session = _staleLookup;
_staleLookup = null;
return true;
}
return _inner.TryGet(sessionId, out session);
}
/// <inheritdoc />
public bool TryRemove(string sessionId, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out GatewaySession session)
=> _inner.TryRemove(sessionId, out session);
/// <inheritdoc />
public IReadOnlyCollection<GatewaySession> Snapshot()
{
if (_staleSnapshot is null)
{
return _inner.Snapshot();
}
GatewaySession[] snapshot = [.. _inner.Snapshot(), _staleSnapshot];
_staleSnapshot = null;
return snapshot;
}
}
/// <summary>
/// Lifetime fake whose <see cref="ApplicationStopping"/> the test fires on demand; the
/// other two tokens never fire.
/// </summary>
private sealed class FakeHostApplicationLifetime : Microsoft.Extensions.Hosting.IHostApplicationLifetime, IDisposable
{
private readonly CancellationTokenSource _stopping = new();
/// <inheritdoc />
public CancellationToken ApplicationStarted => CancellationToken.None;
/// <inheritdoc />
public CancellationToken ApplicationStopping => _stopping.Token;
/// <inheritdoc />
public CancellationToken ApplicationStopped => CancellationToken.None;
/// <summary>Fires <see cref="ApplicationStopping"/>.</summary>
public void SignalStopping()
{
_stopping.Cancel();
}
/// <inheritdoc />
public void StopApplication()
{
SignalStopping();
}
/// <inheritdoc />
public void Dispose()
{
_stopping.Dispose();
}
}
/// <summary>
/// Registry whose first <see cref="Snapshot"/> is empty regardless of its contents,
/// simulating a session whose <c>TryAdd</c> landed just after the drain's first snapshot;
/// every later snapshot is the real one.
/// </summary>
private sealed class LateFirstSnapshotRegistry : ISessionRegistry
{
private readonly SessionRegistry _inner = new();
/// <summary>Gets how many snapshots have been taken.</summary>
public int SnapshotCalls { get; private set; }
/// <inheritdoc />
public int Count => _inner.Count;
/// <inheritdoc />
public int ActiveCount => _inner.ActiveCount;
/// <inheritdoc />
public bool TryAdd(GatewaySession session) => _inner.TryAdd(session);
/// <inheritdoc />
public bool TryGet(string sessionId, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out GatewaySession session)
=> _inner.TryGet(sessionId, out session);
/// <inheritdoc />
public bool TryRemove(string sessionId, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out GatewaySession session)
=> _inner.TryRemove(sessionId, out session);
/// <inheritdoc />
public IReadOnlyCollection<GatewaySession> Snapshot()
{
SnapshotCalls++;
return SnapshotCalls == 1 ? [] : _inner.Snapshot();
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
private WorkerClientState _state = WorkerClientState.Ready;