feat(sessions): add UnattachedSessionGraceSeconds to reap never-subscribed idle sessions

A client that opens a session and vanishes before it ever streams (e.g.
OtOpcUa's GalaxyDriver on a failed start) pinned a session slot and a live
x86 worker for the full DefaultLeaseSeconds (1800 s), because detach-grace
only applies to sessions that once had a subscriber.

MxGateway:Sessions:UnattachedSessionGraceSeconds (int, default 0 = off,
validated >= 0) makes the existing lease sweep reap a Ready session that
has never attached an event subscriber once it has been idle (no unary
activity) for the window, with the close reason unattached-grace-expired.
Ready-only, idle-from-last-activity and first-subscriber-cancels keep it
from catching slow starts or unary-only clients.

GatewaySession.ExtendLease had no production callers and did not fit the
rule, so it is removed; tests move leases through TouchClientActivity.
This commit is contained in:
Joseph Doherty
2026-10-22 06:31:39 -04:00
parent 2b165cd3a4
commit 285ef64160
9 changed files with 258 additions and 35 deletions
@@ -21,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
@@ -363,12 +364,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
@@ -379,7 +382,9 @@ public sealed class SessionManager : ISessionManager
? FaultedReason
: session.IsDetachGraceExpired(now)
? DetachGraceExpiredReason
: null;
: session.IsUnattachedGraceExpired(now)
? UnattachedGraceExpiredReason
: null;
if (reason is null)
{
continue;
@@ -708,7 +713,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(