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

Sweep of 203 source files resolving CommentChecker findings: add
<summary>/<param>/<returns>/<inheritdoc> where missing, and remove
resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN,
Task N) from code comments. Comment/doc-only — no logic changes.
Server+Tests build clean under TreatWarningsAsErrors.
This commit is contained in:
Joseph Doherty
2026-07-07 14:09:49 -04:00
parent 8914472706
commit fca978de07
203 changed files with 1834 additions and 1383 deletions
@@ -10,14 +10,6 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// and composes the per-attribute subtag item addresses from the configured
/// subtag names.
/// </summary>
// NOTE: The exact subtag names and the canonical AlarmFullReference shape
// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
// Task 17 live smoke test. The config Subtags block exists precisely so these
// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy
// area discovered via gobject.area_gobject_id (the alarm group the native
// alarmmgr emits), giving exact reference parity with wnwrap. The configured
// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes
// entries, which carry no discovered area.
public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
{
private const string ProviderLiteral = "Galaxy";
@@ -912,6 +912,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param>
/// <returns><see langword="true"/> if the reference matches this subscriber's prefix filter.</returns>
public bool Matches(string reference)
{
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
@@ -46,6 +46,7 @@ public interface IGatewayAlarmService
/// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param>
/// <returns>An asynchronous stream of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix,
CancellationToken cancellationToken);
@@ -57,6 +58,7 @@ public interface IGatewayAlarmService
/// </summary>
/// <param name="request">The acknowledge request.</param>
/// <param name="cancellationToken">Token to cancel the call.</param>
/// <returns>The acknowledge reply, carrying the outcome in its protocol status.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken);
@@ -9,11 +9,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <summary>
/// Validates gateway configuration options.
/// </summary>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param>
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
{
ValidateAuthentication(options.Authentication, builder);
@@ -8,5 +8,6 @@ public interface IGatewayConfigurationProvider
/// <summary>
/// Returns the validated and effective gateway configuration.
/// </summary>
/// <returns>The validated and effective gateway configuration.</returns>
EffectiveGatewayConfiguration GetEffectiveConfiguration();
}
@@ -33,7 +33,7 @@ public sealed class SessionOptions
/// external subscriber, so a session whose only remaining subscriber is the dashboard
/// mirror still enters detach-grace. A value of <c>0</c> disables retention: the
/// session reverts to the original behavior of lingering only until its normal lease
/// expires. The reconnect/replay itself is implemented separately (Task 12); this
/// expires. The reconnect/replay itself is implemented separately; this
/// option controls retention and expiry only.
/// </summary>
/// <remarks>
@@ -38,7 +38,8 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
await ConnectHubAsync().ConfigureAwait(false);
}
/// <inheritdoc />
/// <summary>Disposes the SignalR hub connection created for this page, tolerating disposal-time errors.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_hub is not null)
@@ -6,6 +6,7 @@ public sealed class DashboardApiKeyAuthorization
{
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <returns><see langword="true"/> if the user is authenticated and holds the <see cref="DashboardRoles.Admin"/> role; otherwise <see langword="false"/>.</returns>
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
@@ -20,17 +20,13 @@ public sealed class DashboardApiKeyManagementService(
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable";
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
/// <summary>Creates an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="request">The request payload.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
@@ -82,10 +78,7 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Revokes an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
@@ -120,10 +113,7 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
/// <summary>Rotates an API key secret asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
@@ -170,10 +160,7 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Deletes a revoked API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
@@ -242,15 +229,15 @@ public sealed class DashboardApiKeyManagementService(
/// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
/// directly through the best-effort <see cref="IAuditWriter"/>. This is in
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary>
/// <remarks>
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
/// <c>Actor</c> is the LDAP operator who performed the
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
/// API key id. This fixes an earlier semantic gap where both fields held the keyId.
/// </remarks>
private async Task WriteDashboardAuditAsync(
ClaimsPrincipal user,
@@ -23,6 +23,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a successful authentication result.
/// </summary>
/// <param name="principal">Authenticated principal.</param>
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping <paramref name="principal"/>.</returns>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
@@ -32,6 +33,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a failed authentication result.
/// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
/// <returns>A failed <see cref="DashboardAuthenticationResult"/> carrying <paramref name="failureMessage"/>.</returns>
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
@@ -107,11 +107,11 @@ public sealed class DashboardAuthenticator(
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username),
// Canonical login-username claim (Task 1.5).
// Canonical login-username claim.
new Claim(ZbClaimTypes.Username, username),
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
new Claim(ZbClaimTypes.Name, displayName),
// Canonical display-name claim for cross-app consistency (Task 1.5).
// Canonical display-name claim for cross-app consistency.
new Claim(ZbClaimTypes.DisplayName, displayName),
];
@@ -6,6 +6,7 @@ public static class DashboardConnectionStringDisplay
{
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param>
/// <returns>A connection string with only display-safe fields, or a placeholder if it cannot be parsed.</returns>
public static string GalaxyRepositoryConnectionString(string connectionString)
{
try
@@ -17,7 +17,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
: IGroupRoleMapper<string>
{
/// <inheritdoc />
/// <summary>Maps the supplied dashboard LDAP group memberships to a dashboard role.</summary>
/// <param name="groups">The user's directory group memberships.</param>
/// <param name="ct">A token to request cancellation of the operation.</param>
/// <returns>The dashboard roles granted; scope is unused and always <see langword="null"/>.</returns>
public Task<GroupRoleMapping<string>> MapAsync(
IReadOnlyList<string> groups,
CancellationToken ct)
@@ -16,6 +16,7 @@ internal static class DashboardGroupRoleMapping
/// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
/// <returns>The distinct dashboard roles the groups map to, or an empty list if none match.</returns>
internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
@@ -30,25 +31,6 @@ internal static class DashboardGroupRoleMapping
{
string normalizedGroup = group.Trim();
// Lookup precedence (Server-040): the full literal group string is
// tried first; only if that misses do we fall back to the leading
// RDN value (e.g. "GwAdmin" extracted from
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
// "GwAdmin" and "gwadmin" both match.
//
// Review C1: with the shared ZB.MOM.WW.Auth.Ldap provider, groups
// arrive here already stripped to short RDN names (the library calls
// FirstRdnValue before returning them). So through the live login path
// the full-string branch only ever sees short names and the RDN
// fallback is effectively a no-op — they collapse to the same key.
// The fallback is retained because this mapping is also reachable
// directly via the IGroupRoleMapper<string> seam (DashboardGroupRoleMapper),
// where a caller could still pass a full DN. CONSEQUENCE: configuring a
// full-DN GroupToRole *key* (e.g. "ou=GwAdmin,ou=groups,...") is
// UNSUPPORTED with the shared library — the incoming group is a short
// name, so it will never equal a full-DN key. Keep GroupToRole keys as
// short group names.
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
@@ -61,6 +43,7 @@ internal static class DashboardGroupRoleMapping
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param>
/// <returns>The value of the first RDN component, or the input unchanged if it contains no '=' separator.</returns>
internal static string ExtractFirstRdnValue(string distinguishedName)
{
int equalsIndex = distinguishedName.IndexOf('=');
@@ -193,7 +193,8 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
}
}
/// <inheritdoc />
/// <summary>Closes the underlying gateway session, if one is open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
@@ -8,7 +8,7 @@ public static class DashboardRoles
{
/// <summary>
/// Read-write access: API-key CRUD, settings, any state-changing UI.
/// Canonical role value (Task 1.7); formerly <c>"Admin"</c> — pure value
/// Canonical role value; formerly <c>"Admin"</c> — pure value
/// rename, the operations this role authorizes are unchanged.
/// </summary>
public const string Admin = "Administrator";
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
/// from the <c>MxGateway:Ldap</c> section. Also read to select the dashboard
/// authentication scheme via the <c>MxGateway:Dashboard:DisableLogin</c> dev flag.
/// </param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddGatewayDashboard(
this IServiceCollection services,
IConfiguration configuration)
@@ -6,6 +6,7 @@ public sealed record DashboardSessionAdminResult(
{
/// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param>
/// <returns>A successful <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Success(string message)
{
return new DashboardSessionAdminResult(true, message);
@@ -13,6 +14,7 @@ public sealed record DashboardSessionAdminResult(
/// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param>
/// <returns>A failed <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Fail(string message)
{
return new DashboardSessionAdminResult(false, message);
@@ -93,10 +93,6 @@ public sealed class DashboardSessionAdminService(
}
catch (Exception exception)
{
// Server-050: any non-SessionManagerException (e.g. an IOException or
// InvalidOperationException from the session DisposeAsync / pipe teardown
// path) used to propagate raw into Blazor's error boundary. Convert it to
// a friendly failure so the Razor pages see only DashboardSessionAdminResult.
_logger.LogWarning(
exception,
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
@@ -162,12 +158,6 @@ public sealed class DashboardSessionAdminService(
}
catch (Exception exception)
{
// Server-050: any non-SessionManagerException (e.g. an IOException from
// worker pipe teardown surfacing through session.DisposeAsync, or an
// InvalidOperationException from a corrupted worker handle) used to
// propagate raw into Blazor's error boundary. Convert it to a friendly
// failure so the page renders the ResultMessage rather than the circuit
// error page.
_logger.LogWarning(
exception,
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
@@ -35,7 +35,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
// sequence means the breakdown is unchanged and can be reused — keeping the ~1s snapshot tick
// O(1) for Galaxy. The summary's cheap volatile fields (status, timestamps, last error, counts)
// are NOT memoized: the library mutates them in place at the same sequence on steady-state ticks
// and on refresh failure, so they are copied fresh on every snapshot (see Server-059).
// and on refresh failure, so they are copied fresh on every snapshot.
private GalaxyBreakdownCache? _galaxyBreakdownCache;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
@@ -72,10 +72,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
}
/// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
/// <inheritdoc />
public DashboardSnapshot GetSnapshot()
{
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -133,11 +130,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private sealed record GalaxyBreakdownCache(long Sequence, GalaxyObjectBreakdown Breakdown);
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// protector; no separate signing keys are configured.
/// </summary>
/// <remarks>
/// Server-043: this service is registered as a singleton in
/// This service is registered as a singleton in
/// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and
/// is shared by two consumer scopes: <c>DashboardHubConnectionFactory</c>
/// (scoped, per-circuit; calls <see cref="Issue"/> from the cookie-authenticated
@@ -40,6 +40,7 @@ public sealed class HubTokenService
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -52,6 +53,7 @@ public sealed class HubTokenService
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
/// <param name="token">The token string to validate.</param>
/// <returns>The reconstructed <see cref="ClaimsPrincipal"/>, or <see langword="null"/> when the token is missing, invalid, or expired.</returns>
public ClaimsPrincipal? Validate(string? token)
{
if (string.IsNullOrEmpty(token))
@@ -67,11 +69,6 @@ public sealed class HubTokenService
return null;
}
// Server-039: reject a token whose payload carries no caller
// identity. A null/empty Name AND NameIdentifier would otherwise
// produce a principal that satisfies IsAuthenticated and IsInRole
// checks without any associated user, because the AuthenticationType
// (the HubToken scheme) is non-empty.
if (string.IsNullOrEmpty(payload.Name) && string.IsNullOrEmpty(payload.NameIdentifier))
{
return null;
@@ -14,9 +14,7 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MX event to publish.</param>
/// <inheritdoc />
public void Publish(string sessionId, MxEvent mxEvent)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// gateway process; clients listen via the hub.
/// </summary>
/// <remarks>
/// Server-042: <see cref="ExecuteAsync"/> wraps the snapshot subscription in
/// <see cref="ExecuteAsync"/> wraps the snapshot subscription in
/// a reconnect loop with a configurable retry delay (5s by default,
/// mirroring <see cref="AlarmsHubPublisher"/>). A transient failure inside
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> — e.g. a
@@ -27,7 +27,7 @@ public sealed class EventsHub : Hub
/// client.
/// </summary>
/// <remarks>
/// Server-038: in v1 the hub-level <see cref="AuthorizeAttribute"/>
/// In v1 the hub-level <see cref="AuthorizeAttribute"/>
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
/// the dashboard roles (Admin or Viewer); both roles may subscribe to
/// any session id they choose. This is acceptable today because (a) the
@@ -43,6 +43,7 @@ public sealed class EventsHub : Hub
/// dedicated authorization policy applied to the hub method itself.
/// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns>
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
/// <param name="username">Username to authenticate.</param>
/// <param name="password">Password to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The authentication result.</returns>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
string? password,
@@ -12,11 +12,13 @@ public interface IDashboardBrowseService
{
/// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The root-level browse nodes and the cache sequence they were projected from.</returns>
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The child browse nodes for the requested parent and the cache sequence they were projected from.</returns>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
@@ -8,11 +8,13 @@ public interface IDashboardSnapshotService
/// <summary>
/// Gets the current dashboard snapshot.
/// </summary>
/// <returns>The current dashboard snapshot.</returns>
DashboardSnapshot GetSnapshot();
/// <summary>
/// Watches for changes to the dashboard state as an async enumerable.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of dashboard snapshots.</returns>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
}
@@ -12,9 +12,15 @@ public sealed class AuthStoreHealthCheck : IHealthCheck
{
private readonly AuthSqliteConnectionFactory _connectionFactory;
/// <summary>Initializes a new instance of the <see cref="AuthStoreHealthCheck"/> class.</summary>
/// <param name="connectionFactory">Factory used to open connections to the SQLite auth store.</param>
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
/// <summary>Verifies the SQLite auth store is reachable by executing a trivial query.</summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Healthy when the store responds; otherwise Unhealthy with the underlying exception.</returns>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
@@ -19,6 +19,7 @@ public static class GatewayLogRedactor
/// Determines whether a command method bears credentials.
/// </summary>
/// <param name="commandMethod">The command method name to check.</param>
/// <returns><see langword="true"/> if the command method carries credentials; otherwise <see langword="false"/>.</returns>
public static bool IsCredentialBearingCommand(string? commandMethod)
{
return commandMethod is not null
@@ -29,6 +30,7 @@ public static class GatewayLogRedactor
/// Redacts the API key secret portion of a Bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
/// <returns>The header with the secret portion redacted, or the original value when it is null, blank, or not a Bearer header.</returns>
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -62,6 +64,7 @@ public static class GatewayLogRedactor
/// Redacts the client identity if it contains an API key.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
/// <returns>The redacted client identity, or the original value when it contains no API key.</returns>
public static string? RedactClientIdentity(string? clientIdentity)
{
if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -80,6 +83,7 @@ public static class GatewayLogRedactor
/// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
/// <returns>The redacted placeholder, the original value, or <see langword="null"/> when <paramref name="value"/> is null.</returns>
public static object? RedactCommandValue(
string? commandMethod,
object? value,
@@ -8,6 +8,7 @@ public sealed record GatewayLogScope(
string? ClientIdentity = null)
{
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
/// <returns>A dictionary containing only the scope's present fields, with sensitive values redacted.</returns>
public IReadOnlyDictionary<string, object?> ToDictionary()
{
Dictionary<string, object?> values = [];
@@ -19,6 +19,7 @@ public static class GatewayRequestLoggingMiddlewareExtensions
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param>
/// <returns>The same <see cref="IApplicationBuilder"/>, for chaining.</returns>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
@@ -13,13 +13,10 @@ public sealed class EventStreamService(
IOptions<GatewayOptions> options,
GatewayMetrics metrics) : IEventStreamService
{
/// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// <para>
/// Task 4 rewired this from a per-RPC channel that drained the session directly
/// to reading the subscriber's lease channel fed by the session's single
/// This reads the subscriber's lease channel fed by the session's single
/// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of
/// the worker event stream and the worker→public mapping (mirroring the former
/// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that
@@ -27,33 +24,28 @@ public sealed class EventStreamService(
/// and the backpressure/overflow policy.
/// </para>
/// <para>
/// Task 6 moved the dashboard mirror OFF this per-RPC loop. The dashboard is now a
/// The dashboard mirror runs OFF this per-RPC loop. The dashboard is a
/// first-class internal subscriber on the session's
/// <see cref="SessionEventDistributor"/> (see <c>GatewaySession.StartDashboardMirror</c>),
/// so it receives session events even when no gRPC client is streaming. This loop no
/// longer mirrors to the dashboard. One deliberate consequence: the dashboard now sees
/// so it receives session events even when no gRPC client is streaming. This loop
/// does not mirror to the dashboard. One deliberate consequence: the dashboard sees
/// RAW session events, not the per-gRPC-subscriber <c>AfterWorkerSequence</c>-filtered
/// view this loop applies — the dashboard is a separate LDAP-authenticated monitoring
/// view that should see the session's full event activity (per-session dashboard ACL is
/// the separate Task 18).
/// view that should see the session's full event activity.
/// </para>
/// <para>
/// Overflow handling (Task 5): the distributor's per-subscriber channel is bounded
/// Overflow handling: the distributor's per-subscriber channel is bounded
/// and the pump writes non-blocking. When this subscriber's channel is full the pump
/// applies the per-subscriber backpressure policy and completes this subscriber's
/// channel with a <see cref="SessionManagerException"/>
/// (<see cref="SessionManagerErrorCode.EventQueueOverflow"/>). That terminal fault
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and — like the
/// pre-epic per-RPC overflow — it propagates to the gRPC client unchanged. The
/// overflow metric, and (in the legacy single-subscriber FailFast case) the session
/// fault + fault metric, are recorded by the distributor's overflow handler so the
/// session, the pump, and other subscribers are isolated from this subscriber's
/// slowness.
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and it propagates to
/// the gRPC client unchanged. The overflow metric, and (in the legacy
/// single-subscriber FailFast case) the session fault + fault metric, are recorded by
/// the distributor's overflow handler so the session, the pump, and other subscribers
/// are isolated from this subscriber's slowness.
/// </para>
/// </remarks>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -72,7 +64,7 @@ public sealed class EventStreamService(
// the session's own SessionEventStreaming.AllowMultipleEventSubscribers field — the
// same source the distributor uses — so the two cannot diverge.
//
// Reconnect/resume (Task 12): when AfterWorkerSequence > 0 the client is resuming, so
// Reconnect/resume: when AfterWorkerSequence > 0 the client is resuming, so
// attach via the replay variant that atomically snapshots the replay ring AND registers
// the live subscriber under one lock. That single critical section is the crux of the
// no-gap/no-duplicate handoff: every replayed event has sequence <= LiveResumeSequence
@@ -81,7 +73,7 @@ public sealed class EventStreamService(
// channel is dropped exactly once, while no newer event is skipped. See
// SessionEventDistributor.RegisterWithReplay for the full argument.
//
// AfterWorkerSequence == 0 (fresh stream, not a resume) keeps the pre-Task-12 behavior:
// AfterWorkerSequence == 0 (fresh stream, not a resume) keeps the original behavior:
// a plain attach, no replay, no sentinel, and the live filter watermark stays 0.
ulong afterWorkerSequence = request.AfterWorkerSequence;
IEventSubscriberLease subscriber;
@@ -157,7 +149,7 @@ public sealed class EventStreamService(
{
// The distributor pump completes every subscriber channel with the source
// fault when the worker event stream terminates abnormally; that surfaces
// here. Mirror the pre-Task-4 ProduceEventsAsync behavior: fault the
// here. Mirror the original ProduceEventsAsync behavior: fault the
// session and record the metric, then propagate the terminal fault to the
// gRPC client.
session.MarkFaulted(workerException.Message);
@@ -175,7 +167,7 @@ public sealed class EventStreamService(
// Queue-depth gauge tracks events the pump has fanned into this subscriber's
// channel but the client has not yet consumed — the same "buffered, not yet
// delivered" quantity the pre-Task-4 per-RPC channel reported. The bounded
// delivered" quantity the original per-RPC channel reported. The bounded
// subscriber channel supports counting, so reconcile the gauge to the current
// backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
@@ -12,6 +12,7 @@ public interface IEventStreamService
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The events emitted for the requested session.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
@@ -164,15 +164,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
/// session-less: the gateway routes it through the always-on
/// <see cref="IGatewayAlarmService"/> monitor session. An
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
/// </remarks>
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request,
ServerCallContext context)
@@ -195,14 +186,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public StreamAlarms RPC — the session-less central
/// alarm feed. The stream opens with one <c>active_alarm</c> per
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
/// a <c>transition</c> for every subsequent change. Served by the
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
/// number of clients fan out from the single monitor.
/// </remarks>
public override async Task StreamAlarms(
StreamAlarmsRequest request,
IServerStreamWriter<AlarmFeedMessage> responseStream,
@@ -226,12 +209,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Snapshot of the active-alarm cache maintained by the gateway's
/// always-on alarm monitor. Streams one <see cref="ActiveAlarmSnapshot"/>
/// per currently-active alarm and completes — no transitions are
/// emitted. Use <c>StreamAlarms</c> for a live transition feed.
/// </remarks>
public override async Task QueryActiveAlarms(
QueryActiveAlarmsRequest request,
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
@@ -23,6 +23,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a gRPC MX command request to a worker command.
/// </summary>
/// <param name="request">Request payload.</param>
/// <returns>The mapped <see cref="WorkerCommand"/> ready for worker dispatch.</returns>
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -39,6 +40,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker command reply to a gRPC MX command reply.
/// </summary>
/// <param name="reply">Worker command reply.</param>
/// <returns>The mapped <see cref="MxCommandReply"/>, or a protocol-violation reply if the worker reply carried no public payload.</returns>
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
@@ -58,6 +60,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker event to a gRPC MX event.
/// </summary>
/// <param name="workerEvent">Worker event to map.</param>
/// <returns>The mapped <see cref="MxEvent"/>, or an unspecified-family event if the worker event carried no public payload.</returns>
public MxEvent MapEvent(WorkerEvent workerEvent)
{
ArgumentNullException.ThrowIfNull(workerEvent);
@@ -73,6 +76,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an OK protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Ok"/>.</returns>
public static ProtocolStatus Ok(string message = "OK")
{
return new ProtocolStatus
@@ -86,6 +90,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an InvalidRequest protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
public static ProtocolStatus InvalidRequest(string message)
{
return new ProtocolStatus
@@ -99,6 +104,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotFound protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
public static ProtocolStatus SessionNotFound(string message)
{
return new ProtocolStatus
@@ -112,6 +118,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotReady protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
public static ProtocolStatus SessionNotReady(string message)
{
return new ProtocolStatus
@@ -125,6 +132,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a WorkerUnavailable protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
public static ProtocolStatus WorkerUnavailable(string message)
{
return new ProtocolStatus
@@ -138,6 +146,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Timeout protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
public static ProtocolStatus Timeout(string message)
{
return new ProtocolStatus
@@ -151,6 +160,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Canceled protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
public static ProtocolStatus Canceled(string message)
{
return new ProtocolStatus
@@ -164,6 +174,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a ProtocolViolation protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
public static ProtocolStatus ProtocolViolation(string message)
{
return new ProtocolStatus
@@ -89,7 +89,7 @@ public sealed class GatewayMetrics : IDisposable
/// (via <c>InternalsVisibleTo</c>) so a <see cref="MeterListener"/> can filter measurements by
/// <see cref="object.ReferenceEquals"/> against this specific instance rather than by the
/// process-shared <see cref="MeterName"/>, which would cross-talk between parallel tests that
/// each build their own <see cref="GatewayMetrics"/> (Tests-027).
/// each build their own <see cref="GatewayMetrics"/>.
/// </summary>
internal Meter Meter => _meter;
@@ -413,6 +413,7 @@ public sealed class GatewayMetrics : IDisposable
};
/// <summary>Sets the current alarm provider-mode gauge without recording a switch (e.g. startup baseline).</summary>
/// <param name="mode">The alarm provider mode value to record.</param>
public void SetAlarmProviderMode(int mode)
{
lock (_syncRoot) { _alarmProviderMode = mode; }
@@ -421,6 +422,7 @@ public sealed class GatewayMetrics : IDisposable
/// <summary>
/// Returns a snapshot of all current metric values.
/// </summary>
/// <returns>The current metrics snapshot.</returns>
public GatewayMetricsSnapshot GetSnapshot()
{
lock (_syncRoot)
@@ -19,7 +19,13 @@ public sealed class CanonicalAuditWriter(
SqliteCanonicalAuditStore store,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
{
/// <inheritdoc />
/// <summary>
/// Persists a canonical audit event to the underlying <see cref="SqliteCanonicalAuditStore"/>.
/// Any failure is caught, logged, and swallowed rather than propagated to the caller.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
/// <param name="cancellationToken">Token to cancel the underlying store write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(auditEvent);
@@ -43,7 +43,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
/// <summary>The library's keyless schema-init event type.</summary>
private const string InitDbEventType = "init-db";
/// <inheritdoc />
/// <summary>Canonicalizes a library-emitted API-key audit entry onto <see cref="AuditEvent"/> and writes it via <see cref="IAuditWriter"/>.</summary>
/// <param name="entry">The library's API-key audit entry to forward.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(entry);
@@ -71,7 +74,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
}
/// <inheritdoc />
/// <summary>Reads back the most recent entries from the canonical audit store and maps each one to an <see cref="ApiKeyAuditEntry"/>.</summary>
/// <param name="limit">The maximum number of entries to return.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>Up to <paramref name="limit"/> recent API-key audit entries.</returns>
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
@@ -43,6 +43,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
/// <param name="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvent);
@@ -79,6 +80,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
/// <param name="limit">Maximum number of events to return.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The most recent audit events, up to <paramref name="limit"/>, newest first.</returns>
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
{
if (limit <= 0)
@@ -26,6 +26,7 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
/// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The process exit code for the executed command.</returns>
public async Task<int> RunAsync(
ApiKeyAdminCommand command,
TextWriter output,
@@ -6,6 +6,7 @@ public sealed record ApiKeyAdminParseResult(
string? Error)
{
/// <summary>Returns a result indicating the input was not an API key command.</summary>
/// <returns>A result with <see cref="IsApiKeyCommand"/> set to <see langword="false"/>.</returns>
public static ApiKeyAdminParseResult NotApiKeyCommand()
{
return new ApiKeyAdminParseResult(false, null, null);
@@ -13,6 +14,7 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param>
/// <returns>A successful <see cref="ApiKeyAdminParseResult"/> wrapping <paramref name="command"/>.</returns>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{
return new ApiKeyAdminParseResult(true, command, null);
@@ -20,6 +22,7 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param>
/// <returns>A failed <see cref="ApiKeyAdminParseResult"/> carrying <paramref name="error"/>.</returns>
public static ApiKeyAdminParseResult Fail(string error)
{
return new ApiKeyAdminParseResult(true, null, error);
@@ -12,6 +12,7 @@ public static class ApiKeyConstraintSerializer
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param>
/// <returns>The serialized JSON, or <see langword="null"/> when the constraints are empty.</returns>
public static string? Serialize(ApiKeyConstraints constraints)
{
ArgumentNullException.ThrowIfNull(constraints);
@@ -20,6 +21,7 @@ public static class ApiKeyConstraintSerializer
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized constraints, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null/whitespace.</returns>
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -66,10 +66,6 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// Canonical audit (Task 2.3 — DEEP adopt ZB.MOM.WW.Audit). All MxGateway audit flows as a
// canonical AuditEvent through the library IAuditWriter, persisted in a NEW gateway-owned
// audit_event table that lives in the SAME SQLite DB file as the api-key stores (it reuses
// the library's AuthSqliteConnectionFactory, registered by AddZbApiKeyAuth above).
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
@@ -16,10 +16,7 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache,
IAuditWriter auditWriter) : IConstraintEnforcer
{
/// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="tagAddress">Tag address to validate.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
@@ -34,12 +31,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
}
/// <summary>Checks read constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -61,12 +53,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
/// <summary>Checks write constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -115,18 +102,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null);
}
/// <summary>Records a constraint denial audit entry.</summary>
/// <param name="identity">The API key identity that was denied.</param>
/// <param name="commandKind">The command type (e.g., read, write).</param>
/// <param name="target">The target being accessed (tag address or handle).</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="correlationId">
/// The per-request client correlation id, if any. Persisted as the audit record's typed
/// <c>CorrelationId</c> when it parses as a GUID; a non-GUID value leaves that column null.
/// The raw string is always preserved in <c>DetailsJson["clientCorrelationId"]</c> so a
/// non-GUID id (e.g. from Rust/Python/Java clients) is never silently lost.
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -135,8 +111,8 @@ public sealed class ConstraintEnforcer(
string? correlationId,
CancellationToken cancellationToken)
{
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter:
// structured Target ("<commandKind>:<target>") and a richer DetailsJson
// envelope carrying constraint/message/commandKind/target.
AuditEvent auditEvent = new()
{
@@ -8,7 +8,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
: IGalaxyBrowseScopeProvider
{
/// <inheritdoc />
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
{
// Invariant: the caller identity is the ambient one pushed by
@@ -6,12 +6,10 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
/// <summary>Gets the current request identity.</summary>
/// <inheritdoc />
public ApiKeyIdentity? Current => currentIdentity.Value;
/// <summary>Sets the current identity and returns a scope that restores the previous identity.</summary>
/// <param name="identity">The identity to push.</param>
/// <returns>Disposable scope.</returns>
/// <inheritdoc />
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
@@ -13,6 +13,7 @@ public static class GrpcAuthorizationServiceCollectionExtensions
/// Registers gRPC authorization middleware and scope resolver.
/// </summary>
/// <param name="services">Service collection to register dependencies into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
@@ -9,6 +9,7 @@ public interface IConstraintEnforcer
/// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
@@ -20,6 +21,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -33,6 +35,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -50,6 +53,7 @@ public interface IConstraintEnforcer
/// <c>CorrelationId</c> when it parses as a GUID; otherwise left null.
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -10,5 +10,6 @@ public interface IGatewayRequestIdentityAccessor
/// <summary>Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state.</summary>
/// <param name="identity">API key identity to push.</param>
/// <returns>A disposable handle that restores the previous identity when disposed.</returns>
IDisposable Push(ApiKeyIdentity identity);
}
@@ -16,6 +16,8 @@ public static class KestrelTlsInspector
/// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a
/// generated fallback certificate.
/// </summary>
/// <param name="configuration">The application configuration to inspect for Kestrel certificate settings.</param>
/// <returns><see langword="true"/> if the gateway must supply a generated fallback certificate; otherwise <see langword="false"/>.</returns>
public static bool RequiresGeneratedCertificate(IConfiguration configuration)
{
// A Kestrel default certificate applies to every endpoint that lacks its own.
@@ -20,6 +20,10 @@ public sealed class SelfSignedCertificateProvider
private readonly ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Initializes a new instance of the <see cref="SelfSignedCertificateProvider"/> class.</summary>
/// <param name="options">TLS options controlling certificate generation and persistence.</param>
/// <param name="logger">Logger for certificate generation and load diagnostics.</param>
/// <param name="timeProvider">Time provider used for certificate validity and expiration checks.</param>
public SelfSignedCertificateProvider(
TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger,
@@ -31,6 +35,7 @@ public sealed class SelfSignedCertificateProvider
}
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
/// <returns>The generated self-signed certificate.</returns>
public X509Certificate2 GenerateCertificate()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
@@ -89,6 +94,7 @@ public sealed class SelfSignedCertificateProvider
/// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary>
/// <returns>The loaded or newly generated and persisted certificate.</returns>
public X509Certificate2 LoadOrCreate()
{
string path = _options.SelfSignedCertPath;
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>
/// The result of a reconnect/resume attach
/// (<see cref="GatewaySession.AttachEventSubscriberWithReplay"/>, Task 12): the live
/// (<see cref="GatewaySession.AttachEventSubscriberWithReplay"/>): the live
/// subscriber lease plus the replay batch and resume watermarks snapshotted atomically
/// with the registration, so the replay→live handoff has no gap and no duplicate.
/// </summary>
@@ -28,8 +28,7 @@ public sealed class GatewaySession
// True once at least one external subscriber attached SUCCESSFULLY. Detach-grace's
// "last subscriber dropped" stamp (see DetachEventSubscriber) is gated on this so a
// 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
// (Server-055).
// attach catch path — does not push a never-subscribed session into the grace window.
private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
@@ -115,7 +114,7 @@ public sealed class GatewaySession
/// </param>
/// <param name="detachGrace">
/// Retention window kept after the last external (gRPC) event subscriber drops, so a
/// client can reconnect (Task 12). When the window is positive and the active external
/// client can reconnect. When the window is positive and the active external
/// subscriber count falls to zero, the session stays <see cref="SessionState.Ready"/>
/// and records a detached timestamp; the lease monitor closes it once the window
/// elapses with no subscriber having re-attached. <see cref="TimeSpan.Zero"/> (the
@@ -389,7 +388,7 @@ public sealed class GatewaySession
/// session by walking it back to <see cref="SessionState.Ready"/> or any earlier
/// state. Both close-related writes (<c>Closing</c> and <c>Closed</c>) go through
/// <c>_syncRoot</c> just like every other state read/write, closing the split-lock
/// race called out in Server-015.
/// race.
/// </remarks>
public void TransitionTo(SessionState nextState)
{
@@ -420,13 +419,13 @@ public sealed class GatewaySession
/// Transitions the session to the Ready state.
/// </summary>
/// <remarks>
/// On becoming Ready the session starts its internal dashboard mirror (Task 6) when a
/// On becoming Ready the session starts its internal dashboard mirror when a
/// dashboard broadcaster was supplied. The mirror registers an internal subscriber on
/// the distributor and starts the pump <em>before</em> any gRPC client attaches, so the
/// dashboard EventsHub receives session events even with no gRPC subscriber streaming —
/// fixing the "dark feed" where the dashboard only saw events while a gRPC client was
/// actively streaming. Registering the internal subscriber BEFORE
/// <see cref="SessionEventDistributor.StartAsync"/> also avoids the Task 4 hazard where
/// <see cref="SessionEventDistributor.StartAsync"/> also avoids the hazard where
/// starting the pump at Ready with zero subscribers drained a fast-completing worker
/// stream into nothing and left a later subscriber hanging: there is now always a
/// subscriber (the dashboard one) registered before the pump starts.
@@ -444,7 +443,7 @@ public sealed class GatewaySession
// single drain to first-attach preserves that "events start flowing on subscribe"
// behavior and avoids draining a fast-completing source into the void before any
// subscriber exists. The source factory mirrors the mapping/ordering/start that
// EventStreamService.ProduceEventsAsync used before Task 4: it drains the worker event
// EventStreamService.ProduceEventsAsync previously used: it drains the worker event
// stream in source order and maps each WorkerEvent to the public MxEvent with the same
// mapper, with no skip/filter — per-RPC filtering (e.g. AfterWorkerSequence) stays at the
// subscriber boundary in EventStreamService. Returns a registered lease atomically with
@@ -462,7 +461,7 @@ public sealed class GatewaySession
return lease;
}
// Reconnect/resume variant of StartDistributorAndRegister (Task 12). Snapshots the replay
// Reconnect/resume variant of StartDistributorAndRegister. Snapshots the replay
// ring for events newer than afterSequence AND registers the live subscriber atomically
// under the distributor's replay lock, so the replay→live handoff has no gap and no
// duplicate (see SessionEventDistributor.RegisterWithReplay). The pump is started after
@@ -542,7 +541,7 @@ public sealed class GatewaySession
// once when the session becomes Ready (idempotent). The internal subscriber is registered
// BEFORE the pump starts (see StartDistributorAndRegister / EnsureDistributorCreated), so
// a subscriber is always present at pump start — the dashboard receives events with no
// gRPC subscriber attached, and the Task 4 "zero-subscriber drain into the void" hang
// gRPC subscriber attached, and the "zero-subscriber drain into the void" hang
// cannot occur. No-op when no dashboard broadcaster was supplied (unit tests).
//
// Race-safety (Issue 1): _dashboardMirrorLease and _dashboardMirrorTask are published
@@ -605,14 +604,14 @@ public sealed class GatewaySession
}
// Reads the internal dashboard subscriber's channel and publishes each RAW fanned event
// to the dashboard broadcaster. The dashboard is a first-class distributor subscriber
// (Task 6), so it sees the session's full raw event activity — NOT the per-gRPC-subscriber
// to the dashboard broadcaster. The dashboard is a first-class distributor subscriber,
// so it sees the session's full raw event activity — NOT the per-gRPC-subscriber
// AfterWorkerSequence filtering that EventStreamService applies at its own boundary. This
// is intentional: the dashboard is a separate LDAP-authenticated monitoring view (per-
// session dashboard ACL is the separate Task 18). Publish is best-effort / never-throw, so
// session dashboard ACL is a separate concern). Publish is best-effort / never-throw, so
// a slow or broken dashboard cannot fault the session or stall the pump; the bounded
// internal subscriber channel (Task 5 per-subscriber isolation) only disconnects THIS
// mirror on overflow, leaving the session and other subscribers untouched.
// internal subscriber channel only disconnects THIS mirror on overflow, leaving the
// session and other subscribers untouched.
private async Task RunDashboardMirrorAsync(
IDashboardEventBroadcaster broadcaster,
IEventSubscriberLease lease,
@@ -757,6 +756,7 @@ public sealed class GatewaySession
/// Determines whether the session lease has expired.
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the lease has expired with no active event subscriber; otherwise <see langword="false"/>.</returns>
public bool IsLeaseExpired(DateTimeOffset now)
{
lock (_syncRoot)
@@ -778,6 +778,7 @@ public sealed class GatewaySession
/// window).
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the detach-grace window has elapsed with no re-attached subscriber; otherwise <see langword="false"/>.</returns>
public bool IsDetachGraceExpired(DateTimeOffset now)
{
lock (_syncRoot)
@@ -813,6 +814,7 @@ public sealed class GatewaySession
/// succeed past it. On distributor-register failure the count is rolled back (see the
/// catch below).
/// </remarks>
/// <returns>A lease that reads the fanned public events for this subscriber.</returns>
public IEventSubscriberLease AttachEventSubscriber(int maxSubscribers)
{
// Derive the mode from the same source the distributor uses so the two can never
@@ -868,7 +870,7 @@ public sealed class GatewaySession
}
/// <summary>
/// Reconnect/resume variant of <see cref="AttachEventSubscriber"/> (Task 12). Attaches
/// Reconnect/resume variant of <see cref="AttachEventSubscriber"/>. Attaches
/// an event subscriber AND atomically snapshots the session replay ring for events newer
/// than <paramref name="afterSequence"/>, so a resuming client can replay what it missed
/// before live delivery resumes — with no gap and no duplicate across the handoff.
@@ -939,7 +941,7 @@ public sealed class GatewaySession
// Records that an external subscriber attached successfully. Gates the detach-grace
// "last subscriber dropped" stamp so a FAILED first attach (which still rolls back via
// DetachEventSubscriber) never pushes a never-subscribed session into grace (Server-055).
// DetachEventSubscriber) never pushes a never-subscribed session into grace.
private void MarkEventSubscriberAttached()
{
lock (_syncRoot)
@@ -953,6 +955,7 @@ public sealed class GatewaySession
/// </summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The worker's reply to the command.</returns>
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
CancellationToken cancellationToken)
@@ -969,7 +972,7 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
}
// Single outbound choke point for the two array-write ergonomics shims (Task 3):
// Single outbound choke point for the two array-write ergonomics shims:
// 1. AddItem/AddItem2 array addresses gain the writable "[]" suffix when Galaxy metadata
// reports them as arrays, so the worker registers a write-capable handle. The mutation
// lands on the same MxCommand instance forwarded to the worker.
@@ -1063,6 +1066,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</param>
/// <returns><see langword="true"/> if a registration was found for the handle pair; otherwise <see langword="false"/>.</returns>
public bool TryGetItemRegistration(
int serverHandle,
int itemHandle,
@@ -1136,6 +1140,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to add.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1161,6 +1166,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1186,6 +1192,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to remove.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1211,6 +1218,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to un-advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1236,6 +1244,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to subscribe to.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1261,6 +1270,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to unsubscribe from.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1284,6 +1294,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
@@ -1307,6 +1318,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
@@ -1330,6 +1342,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -1353,6 +1366,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -1380,6 +1394,7 @@ public sealed class GatewaySession
/// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address read results.</returns>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1437,6 +1452,7 @@ public sealed class GatewaySession
/// <see cref="TransitionTo"/> / <see cref="MarkFaulted"/> and a concurrent
/// <c>TransitionTo(Ready)</c> cannot race past a <c>Closing</c> write.
/// </remarks>
/// <returns>The outcome of the close operation.</returns>
public async Task<SessionCloseResult> CloseAsync(
string reason,
CancellationToken cancellationToken)
@@ -1613,7 +1629,7 @@ public sealed class GatewaySession
/// Mirrors <see cref="CloseAsync"/>'s use of <c>_closeLock</c> so that
/// a Close in flight from one caller and a Kill from another do not
/// race on the "was the session already closed" observation that
/// drives metric increments (Server-045).
/// drives metric increments.
/// </remarks>
/// <param name="reason">Reason for killing the worker.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -1652,6 +1668,7 @@ public sealed class GatewaySession
/// The acquire is best-effort: a non-cancellable wait that swallows
/// <see cref="ObjectDisposedException"/> so double-dispose still completes.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
@@ -1991,7 +2008,7 @@ public sealed class GatewaySession
// session instead of letting it linger only on the (long) lease: stamp the detached
// time so the lease monitor can close it once the grace window elapses. The session
// stays in its current (Ready) state and remains usable, so a reconnecting subscriber
// (Task 12) re-attaches normally. The gateway-owned internal dashboard subscriber is
// re-attaches normally. The gateway-owned internal dashboard subscriber is
// NOT counted in _activeEventSubscriberCount (it registers on the distributor with
// isInternal: true), so a session whose only remaining subscriber is the dashboard
// mirror still enters grace. Only stamp while the session is alive — once
@@ -2001,7 +2018,7 @@ public sealed class GatewaySession
// Only stamp a detach that mirrors a prior SUCCESSFUL attach. The attach catch path
// calls this same method to roll back a reserved slot when the FIRST attach failed
// before any subscriber registered; that never-subscribed session must not enter the
// grace window (Server-055).
// grace window.
if (_everHadEventSubscriber
&& _detachGrace > TimeSpan.Zero
&& _activeEventSubscriberCount == 0
@@ -74,5 +74,6 @@ public interface ISessionManager
/// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
}
@@ -14,12 +14,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// </summary>
/// <param name="isOnlySubscriber">
/// <see langword="true"/> when FailFast is allowed to fault the whole session for this
/// overflow. As of Task 8 this is gated on the SESSION MODE, not a live count: it is
/// overflow. This is gated on the SESSION MODE, not a live count: it is
/// <see langword="true"/> only for an external subscriber in single-subscriber mode
/// (<c>AllowMultipleEventSubscribers == false</c>), where at most one external subscriber
/// can ever exist. In multi-subscriber mode it is always <see langword="false"/>, so
/// FailFast degrades to a per-subscriber disconnect and one slow consumer never faults a
/// session shared by others; gating on the fixed mode also removes the Task 5 race where a
/// session shared by others; gating on the fixed mode also removes the race where a
/// concurrent registration could make a count snapshot falsely report a sole subscriber.
/// Always <see langword="false"/> for internal subscribers (the dashboard mirror) so a
/// slow/broken dashboard can never fault the session.
@@ -38,15 +38,14 @@ public delegate void SubscriberOverflowHandler(bool isOnlySubscriber, bool isInt
/// </summary>
/// <remarks>
/// <para>
/// Introduced by Task 2 of the Session Resilience epic; the bounded replay ring
/// buffer was added by Task 3, it was wired into <c>GatewaySession</c> and
/// <c>EventStreamService</c> by Task 4, and the per-subscriber backpressure-isolation
/// policy (Task 5) is implemented here: a slow subscriber overflows only its own
/// bounded channel and the pump applies the policy to that subscriber alone (see
/// The bounded replay ring buffer is wired into <c>GatewaySession</c> and
/// <c>EventStreamService</c>. The per-subscriber backpressure-isolation policy is
/// implemented here: a slow subscriber overflows only its own bounded channel and the
/// pump applies the policy to that subscriber alone (see
/// <see cref="SubscriberOverflowHandler"/> and <c>OnSubscriberOverflow</c>), leaving
/// the pump, the session, and other subscribers running. Task 8 made the
/// FailFast-faults-session decision mode-gated: it fires only in single-subscriber
/// mode (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to
/// the pump, the session, and other subscribers running. The FailFast-faults-session
/// decision is mode-gated: it fires only in single-subscriber mode
/// (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to
/// a per-subscriber disconnect — see <c>OnSubscriberOverflow</c>. The ring buffer supports capacity
/// eviction (oldest entry dropped when the count exceeds
/// <c>replayBufferCapacity</c>) and age eviction (entries older than
@@ -58,7 +57,7 @@ public delegate void SubscriberOverflowHandler(bool isOnlySubscriber, bool isInt
/// <see cref="Func{T, TResult}"/> producing an
/// <see cref="IAsyncEnumerable{T}"/> of already-mapped public
/// <see cref="MxEvent"/>s, given a <see cref="CancellationToken"/>. This is the
/// cleanest seam for Task 4: it can pass
/// cleanest seam: it can pass
/// <c>ct =&gt; session.ReadEventsAsync(ct).Select(mapper.MapEvent)</c> (or a
/// channel reader's <c>ReadAllAsync</c>), while unit tests pass a plain
/// channel reader's <c>ReadAllAsync</c> with no real session. The pump owns the
@@ -133,7 +132,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <param name="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token.
/// The pump consumes this exactly once. See the type remarks for the seam Task 4
/// The pump consumes this exactly once. See the type remarks for the seam this
/// plugs into.
/// </param>
/// <param name="subscriberQueueCapacity">
@@ -141,10 +140,18 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// queue capacity shape used today.
/// </param>
/// <param name="logger">Logger for pump lifecycle diagnostics.</param>
/// <param name="overflowHandler">
/// Optional per-subscriber backpressure handler invoked when a subscriber's bounded
/// channel is full. See the primary constructor overload for the full contract.
/// </param>
/// <param name="singleSubscriberMode">
/// <see langword="true"/> when the owning session is in single-subscriber mode. See
/// the primary constructor overload for the full contract.
/// </param>
/// <remarks>
/// This overload disables the replay ring buffer (capacity 0). Use the overload
/// taking replay parameters to retain events for reconnect/reattach replay.
/// Kept <c>internal</c> so production wiring (Task 4) cannot accidentally use
/// Kept <c>internal</c> so production wiring cannot accidentally use
/// the no-replay path; tests reach it via <c>InternalsVisibleTo</c>.
/// </remarks>
internal SessionEventDistributor(
@@ -173,7 +180,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <param name="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token.
/// The pump consumes this exactly once. See the type remarks for the seam Task 4
/// The pump consumes this exactly once. See the type remarks for the seam this
/// plugs into.
/// </param>
/// <param name="subscriberQueueCapacity">
@@ -208,9 +215,9 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// overflows reports <c>isOnlySubscriber == true</c> (legacy FailFast faults the
/// session) ONLY in single-subscriber mode. In multi-subscriber mode it is always
/// <see langword="false"/>, so FailFast degrades to a per-subscriber disconnect and a
/// transient registration race can never falsely fault a shared session (Task 8;
/// resolves the Task 5 REVISIT race). Defaults to <see langword="true"/> so existing
/// call sites and unit tests keep legacy single-subscriber FailFast behavior.
/// transient registration race can never falsely fault a shared session. Defaults to
/// <see langword="true"/> so existing call sites and unit tests keep legacy
/// single-subscriber FailFast behavior.
/// </param>
public SessionEventDistributor(
string sessionId,
@@ -259,6 +266,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// Starts the background pump. Idempotent — a second call is a no-op.
/// </summary>
/// <param name="cancellationToken">Token observed only while starting.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -285,7 +293,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// subscribers.
/// </summary>
/// <param name="isInternal">
/// <see langword="true"/> for a gateway-owned internal subscriber (Task 6: the
/// <see langword="true"/> for a gateway-owned internal subscriber (the
/// session's dashboard mirror) that must NOT participate in the single-subscriber
/// overflow accounting. An internal subscriber is excluded from the
/// <c>isOnlySubscriber</c> count, so a lone external gRPC subscriber still reports
@@ -296,6 +304,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// disconnected from the mirror. Defaults to <see langword="false"/> (external
/// subscriber) so every existing call site is unchanged.
/// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
public IEventSubscriberLease Register(bool isInternal = false)
{
Channel<MxEvent> channel = CreateSubscriberChannel();
@@ -355,7 +364,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <summary>
/// Atomically snapshots the replay ring for events newer than
/// <paramref name="afterSequence"/> AND registers a live subscriber, so the
/// replay→live handoff has no gap and no duplicate (Task 12 reconnect/resume).
/// replay→live handoff has no gap and no duplicate (reconnect/resume).
/// </summary>
/// <param name="afterSequence">
/// The last worker sequence the reconnecting client already observed. Replay returns
@@ -389,6 +398,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <see langword="true"/> for a gateway-owned internal subscriber. See
/// <see cref="Register"/>.
/// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
/// <remarks>
/// <para>
/// <b>Why this is atomic and the handoff is correct.</b> The replay snapshot and the
@@ -488,6 +498,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <summary>
/// Stops the pump and completes all subscriber channels. Idempotent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
Task? pumpTask;
@@ -603,22 +614,6 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{
// Decide whether FailFast may fault the whole session for this overflow. This is the
// "isOnlySubscriber" signal the legacy single-subscriber FailFast path keys on.
//
// Task 8 resolution of the Task 5/7 REVISIT race: gate this on the SESSION MODE
// (_singleSubscriberMode), NOT on a live count snapshot. The old
// `CountExternalSubscribers() == 1` snapshot raced once multi-subscriber became real —
// a concurrent second registration/unregistration could make the count read as 1 with
// two subscribers actually present, producing a false FailFast that faults a shared
// session. The mode is fixed for the session's lifetime, so reading it is race-free:
// - single-subscriber mode: at most one external subscriber can ever exist (the
// AttachEventSubscriber guard enforces it), so an overflowing external subscriber
// IS the sole subscriber — preserve the legacy FailFast session-fault behavior.
// - multi-subscriber mode: never fault the shared session; FailFast degrades to a
// per-subscriber disconnect so one slow consumer cannot punish healthy ones.
//
// Task 6: the gateway-owned internal dashboard subscriber is excluded — an internal
// subscriber that overflows is NEVER the "only subscriber", so a slow/broken dashboard
// can only disconnect its own mirror and never fault the session.
bool isOnlySubscriber = !subscriber.IsInternal && _singleSubscriberMode;
_logger.LogDebug(
@@ -816,12 +811,17 @@ public sealed class SessionEventDistributor : IAsyncDisposable
private sealed class Subscriber(long id, Channel<MxEvent> channel, bool isInternal)
{
/// <summary>Gets the subscriber's monotonic id, assigned at registration.</summary>
public long Id { get; } = id;
/// <summary>Gets the subscriber's own bounded event channel, written by the pump.</summary>
public Channel<MxEvent> Channel { get; } = channel;
// True for the gateway-owned internal dashboard subscriber. Excluded from the
// single-subscriber overflow accounting so it cannot fault the session.
/// <summary>
/// Gets a value indicating whether this is the gateway-owned internal dashboard
/// subscriber. Excluded from the single-subscriber overflow accounting so it cannot
/// fault the session.
/// </summary>
public bool IsInternal { get; } = isInternal;
}
@@ -830,8 +830,13 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{
private int _leaseDisposed;
/// <inheritdoc />
public ChannelReader<MxEvent> Reader => subscriber.Channel.Reader;
/// <summary>
/// Unregisters the subscriber from the distributor and completes its channel.
/// Safe to call more than once; only the first call takes effect.
/// </summary>
public void Dispose()
{
// Atomic check-and-set so concurrent Dispose calls unregister at most once.
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <param name="Mapper">
/// Maps worker IPC <c>WorkerEvent</c> frames to public <c>MxEvent</c>s. The distributor
/// pump applies this once per event in worker order, mirroring the mapping
/// <c>EventStreamService.ProduceEventsAsync</c> used before Task 4.
/// <c>EventStreamService.ProduceEventsAsync</c> used previously.
/// </param>
/// <param name="EventOptions">
/// Supplies the distributor's per-subscriber queue capacity and replay ring-buffer
@@ -32,20 +32,20 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// preserving the observability the pre-epic per-RPC overflow path emitted.
/// </param>
/// <param name="DashboardBroadcaster">
/// Sink the session's internal dashboard mirror loop (Task 6) publishes raw session
/// Sink the session's internal dashboard mirror loop publishes raw session
/// <c>MxEvent</c>s to. When non-null the session registers an internal distributor
/// subscriber on becoming Ready and mirrors every fanned event to the dashboard
/// EventsHub group regardless of whether a gRPC client is streaming. When null
/// (unit tests that don't exercise the dashboard mirror) no mirror is started.
/// </param>
/// <param name="AllowMultipleEventSubscribers">
/// The session's effective multi-subscriber mode (Task 8). Carried here so the session
/// The session's effective multi-subscriber mode. Carried here so the session
/// can pass it to its <see cref="SessionEventDistributor"/> at construction — the
/// distributor is created at <c>MarkReady</c> (for the dashboard mirror) before any gRPC
/// subscriber attaches, so the mode cannot be learned from a later
/// <c>AttachEventSubscriber</c> call. The distributor gates its FailFast session-fault
/// decision on this mode (single-subscriber only) instead of a live count snapshot,
/// closing the Task 5 false-FailFast race. Defaults to <see langword="false"/>
/// closing the false-FailFast race. Defaults to <see langword="false"/>
/// (single-subscriber) so existing call sites and unit tests are unchanged.
/// </param>
public sealed record SessionEventStreaming(
@@ -44,7 +44,7 @@ public sealed class SessionManager : ISessionManager
/// <param name="distributorLogger">Logger passed to each session's event distributor pump.</param>
/// <param name="dashboardEventBroadcaster">
/// Dashboard SignalR fan-out sink. Each session registers an internal distributor
/// subscriber (Task 6) that mirrors raw session events to this broadcaster, so the
/// subscriber that mirrors raw session events to this broadcaster, so the
/// dashboard receives events regardless of whether a gRPC client is streaming. Null in
/// unit tests that do not exercise the dashboard mirror.
/// </param>
@@ -79,14 +79,7 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
/// <summary>
/// Opens a new gateway session and connects to the worker.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="clientIdentity">Client authentication identity.</param>
/// <param name="ownerKeyId">API key identifier of the caller creating the session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Opened gateway session.</returns>
/// <inheritdoc />
public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -151,12 +144,7 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Attempts to retrieve a session by ID.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="session">The session if found.</param>
/// <returns>True if session found; otherwise false.</returns>
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -164,13 +152,7 @@ public sealed class SessionManager : ISessionManager
return _registry.TryGet(sessionId, out session);
}
/// <summary>
/// Invokes a worker command on a session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="command">Worker command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
/// <inheritdoc />
public async Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -197,12 +179,7 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Reads events from a session's event stream asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -212,12 +189,7 @@ public sealed class SessionManager : ISessionManager
return session.ReadEventsAsync(cancellationToken);
}
/// <summary>
/// Closes a gateway session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
/// <inheritdoc />
public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -231,16 +203,12 @@ public sealed class SessionManager : ISessionManager
return result;
}
/// <summary>
/// Forcefully terminates a session's worker without attempting graceful shutdown.
/// <inheritdoc />
/// <remarks>
/// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/>
/// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c>
/// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="reason">Reason recorded for the kill.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
/// </remarks>
public async Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
@@ -251,10 +219,6 @@ public sealed class SessionManager : ISessionManager
GatewaySession session = GetRequiredSession(sessionId);
// Serialize concurrent kill/close attempts on this session by routing through the
// per-session close lock (Server-045). Returns whether the session was already in
// Closed state when the lock was acquired so the metric counter is incremented at
// most once across concurrent callers.
bool wasClosed;
try
{
@@ -265,10 +229,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message);
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
// Server-044: 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 Server-006 fix on OpenSessionAsync.
_metrics.SessionRemoved();
await RemoveSessionAsync(session).ConfigureAwait(false);
throw new SessionManagerException(
@@ -291,12 +251,7 @@ public sealed class SessionManager : ISessionManager
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed);
}
/// <summary>
/// Closes all sessions with expired leases asynchronously.
/// </summary>
/// <param name="now">Current time for lease expiration check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Count of sessions closed.</returns>
/// <inheritdoc />
public async Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -341,11 +296,7 @@ public sealed class SessionManager : ISessionManager
return closedCount;
}
/// <summary>
/// Shuts down all active sessions gracefully asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -361,10 +312,6 @@ public sealed class SessionManager : ISessionManager
"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 (Server-046). The
// 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 _))
{
try
@@ -409,11 +356,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message);
if (!wasClosed)
{
// Server-046: account the close as a SessionClosed (decrements the open-session
// gauge AND increments the sessions.closed counter), not just SessionRemoved.
// The session is being removed from the registry below; treating this as a
// half-finished close that only decremented the gauge under-counted the closed
// counter.
_metrics.SessionClosed();
}
@@ -11,6 +11,7 @@ public sealed record SessionOpenRequest(
{
/// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary>
/// <param name="request">Request payload.</param>
/// <returns>The equivalent <see cref="SessionOpenRequest"/>.</returns>
public static SessionOpenRequest FromContract(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -11,20 +11,13 @@ public sealed class SessionRegistry : ISessionRegistry
{
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
/// <summary>
/// Gets the total count of sessions in the registry.
/// </summary>
/// <inheritdoc />
public int Count => _sessions.Count;
/// <summary>
/// Gets the count of non-closed sessions.
/// </summary>
/// <inheritdoc />
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
/// <summary>
/// Adds a session to the registry.
/// </summary>
/// <param name="session">Gateway session to add.</param>
/// <inheritdoc />
public bool TryAdd(GatewaySession session)
{
ArgumentNullException.ThrowIfNull(session);
@@ -32,11 +25,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryAdd(session.SessionId, session);
}
/// <summary>
/// Retrieves a session by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session if found.</param>
/// <inheritdoc />
public bool TryGet(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -44,11 +33,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryGetValue(sessionId, out session);
}
/// <summary>
/// Removes a session from the registry by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The removed session if found.</param>
/// <inheritdoc />
public bool TryRemove(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -56,9 +41,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryRemove(sessionId, out session);
}
/// <summary>
/// Returns a snapshot of all sessions in the registry.
/// </summary>
/// <inheritdoc />
public IReadOnlyCollection<GatewaySession> Snapshot()
{
return _sessions.Values.ToArray();
@@ -8,13 +8,17 @@ public sealed class SessionShutdownHostedService(
ISessionManager sessionManager,
ILogger<SessionShutdownHostedService> logger) : IHostedService
{
/// <inheritdoc />
/// <summary>No-op startup hook; this service only has work to do on shutdown.</summary>
/// <param name="cancellationToken">Token that signals startup should be aborted.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
/// <summary>Shuts down all gateway sessions as the host stops, logging (without throwing) if the host's shutdown timeout cancels the operation first.</summary>
/// <param name="cancellationToken">Token that signals the host's shutdown timeout has elapsed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StopAsync(CancellationToken cancellationToken)
{
try
@@ -39,10 +39,7 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
_options = options.Value;
}
/// <summary>Creates a worker client and launches the worker process.</summary>
/// <param name="session">The gateway session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created worker client.</returns>
/// <inheritdoc />
public async Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -19,12 +19,14 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Initiates the handshake and enters ready state.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken);
/// <summary>Sends a command to the worker and waits for a reply.</summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="timeout">Timeout for waiting for the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The worker's reply to the command.</returns>
Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -32,11 +34,13 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Reads events from the worker as they arrive.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of worker events.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
/// <summary>Gracefully shuts down the worker by closing the connection.</summary>
/// <param name="timeout">Timeout for shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>Terminates the worker process immediately with a diagnostic reason.</summary>
@@ -24,6 +24,7 @@ public interface IWorkerProcess : IDisposable
/// Waits for the process to exit with the specified cancellation token.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask WaitForExitAsync(CancellationToken cancellationToken);
/// <summary>
@@ -8,7 +8,9 @@ public sealed class OrphanWorkerCleanupHostedService(
OrphanWorkerTerminator terminator,
ILogger<OrphanWorkerCleanupHostedService> logger) : IHostedService
{
/// <inheritdoc />
/// <summary>Terminates leftover orphan MXAccess worker processes once, best-effort, before the gateway starts accepting sessions.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
try
@@ -25,6 +27,8 @@ public sealed class OrphanWorkerCleanupHostedService(
return Task.CompletedTask;
}
/// <inheritdoc />
/// <summary>No-op; this service has no ongoing work to stop.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -28,7 +28,7 @@ internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess
process.Kill(entireProcessTree);
}
/// <inheritdoc />
/// <summary>Disposes the wrapped <see cref="Process"/> instance.</summary>
public void Dispose()
{
process.Dispose();
@@ -78,10 +78,10 @@ public sealed class WorkerClient : IWorkerClient
_lastHeartbeatAt = _timeProvider.GetUtcNow();
}
/// <summary>Gets the worker's session ID.</summary>
/// <inheritdoc />
public string SessionId => _connection.SessionId;
/// <summary>Gets the worker process ID.</summary>
/// <inheritdoc />
public int? ProcessId
{
get
@@ -93,7 +93,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Gets the current client state.</summary>
/// <inheritdoc />
public WorkerClientState State
{
get
@@ -105,7 +105,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Gets the timestamp of the last received heartbeat.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt
{
get
@@ -117,8 +117,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Starts the worker client and completes the handshake.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
@@ -141,11 +140,7 @@ public sealed class WorkerClient : IWorkerClient
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
}
/// <summary>Invokes a command on the worker and waits for reply.</summary>
/// <param name="command">The command to invoke.</param>
/// <param name="timeout">Command timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The command reply.</returns>
/// <inheritdoc />
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -228,9 +223,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Reads events from the worker as an async stream.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -242,9 +235,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Shuts down the worker gracefully.</summary>
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ThrowIfDisposed();
@@ -289,8 +280,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Terminates the worker process immediately.</summary>
/// <param name="reason">Reason for termination.</param>
/// <inheritdoc />
public void Kill(string reason)
{
ThrowIfDisposed();
@@ -302,6 +292,7 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>Disposes the worker client and releases resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
@@ -390,13 +381,13 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>
/// Monitors worker heartbeat and detects stale sessions. Mirrors
/// Worker-023 on the worker side: while a command is in flight on the
/// Monitors worker heartbeat and detects stale sessions. Mirrors the
/// worker-side watchdog: while a command is in flight on the
/// gateway↔worker pipe, the heartbeat watchdog is suppressed up to
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker
/// may be busy executing a slow STA command and the heartbeat write may
/// be queued behind a long event-drain burst (Server-031), neither of
/// which indicates the worker is actually hung. Once the oldest pending
/// be queued behind a long event-drain burst, neither of which
/// indicates the worker is actually hung. Once the oldest pending
/// command exceeds the ceiling, the fault fires anyway so a truly stuck
/// COM call doesn't hide the worker forever.
/// </summary>
@@ -419,11 +410,6 @@ public sealed class WorkerClient : IWorkerClient
continue;
}
// Server-031: if a command is in flight and hasn't yet exceeded
// the stuck-command ceiling, the gap is more likely caused by
// pipe-write contention (event drain holding the writer lock)
// or a legitimately slow STA command than by a hung worker.
// Wait for the ceiling before faulting on heartbeat alone.
if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge)
&& oldestCommandAge <= _options.HeartbeatStuckCeiling)
{
@@ -446,7 +432,7 @@ public sealed class WorkerClient : IWorkerClient
/// Returns the age of the oldest pending command on the worker pipe,
/// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against
/// <see cref="PendingCommand.StartTimestamp"/>, or <c>false</c> when no
/// commands are pending. Used by the heartbeat watchdog (Server-031)
/// commands are pending. Used by the heartbeat watchdog
/// to decide whether a heartbeat gap is plausibly the result of
/// pipe-write contention rather than a hung worker.
/// </summary>
@@ -508,12 +494,12 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>
/// Enqueues a worker event for client consumption. Server-032: the
/// channel is configured with <see cref="BoundedChannelFullMode.Wait"/>
/// and a brief consumer hiccup is now tolerated for up to
/// Enqueues a worker event for client consumption. The channel is
/// configured with <see cref="BoundedChannelFullMode.Wait"/>
/// and a brief consumer hiccup is tolerated for up to
/// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
/// (default 5s) before the worker is faulted. Pre-Server-032 the code
/// used <c>TryWrite</c> (non-blocking) which never honored the
/// (default 5s) before the worker is faulted. The channel previously
/// used <c>TryWrite</c> (non-blocking), which never honored the
/// configured <c>FullModeTimeout</c> — the worker faulted on the first
/// missed slot even though the wait-mode channel would have absorbed
/// the burst. The diagnostic now names the capacity, current depth, and
@@ -538,8 +524,6 @@ public sealed class WorkerClient : IWorkerClient
return;
}
// Channel is full; honor the configured wait timeout before declaring
// the consumer dead and faulting the worker (Server-032).
using CancellationTokenSource fullModeCts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout);
@@ -15,7 +15,7 @@ public sealed class WorkerClientOptions
/// <summary>
/// Default ceiling on the in-flight-command heartbeat skip. Mirrors
/// <see cref="ZB.MOM.WW.MxGateway.Worker.Ipc.WorkerPipeSessionOptions.DefaultHeartbeatStuckCeiling"/>
/// on the worker side (Worker-023). When a command has been in flight
/// on the worker side. When a command has been in flight
/// longer than this, the gateway-side heartbeat watchdog fires
/// regardless of pending commands — a truly stuck COM call shouldn't
/// hide the worker forever.
@@ -47,8 +47,7 @@ public sealed class WorkerClientOptions
/// faulting the worker. Honored by <c>EnqueueWorkerEventAsync</c> via
/// <c>WriteAsync</c>; with the channel configured for
/// <c>BoundedChannelFullMode.Wait</c>, a transient backlog only faults
/// after the configured timeout has elapsed (Server-032). Pre-Server-032
/// the field was declared but unused — overflow faulted immediately.
/// after the configured timeout has elapsed.
/// </summary>
public TimeSpan EventChannelFullModeTimeout { get; init; }
@@ -56,12 +55,12 @@ public sealed class WorkerClientOptions
public int MaxPendingCommands { get; init; }
/// <summary>
/// Server-031: ceiling on the in-flight-command heartbeat-skip. When
/// Ceiling on the in-flight-command heartbeat-skip. When
/// a command has been pending on the gateway↔worker pipe for longer
/// than this, the gateway-side <c>HeartbeatLoopAsync</c> fires the
/// <c>HeartbeatExpired</c> fault even if commands are still pending;
/// a truly stuck COM call shouldn't keep the watchdog suppressed
/// indefinitely. Mirrors Worker-023's <c>HeartbeatStuckCeiling</c> on
/// indefinitely. Mirrors <c>HeartbeatStuckCeiling</c> on
/// the worker side.
/// </summary>
public TimeSpan HeartbeatStuckCeiling { get; init; }
@@ -30,6 +30,7 @@ public sealed class WorkerFrameWriter
/// </summary>
/// <param name="envelope">Worker envelope message to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask WriteAsync(
WorkerEnvelope envelope,
CancellationToken cancellationToken = default)
@@ -30,7 +30,7 @@ public sealed class WorkerProcessHandle : IDisposable
/// <summary>Gets the time when the process was launched.</summary>
public DateTimeOffset LaunchedAt { get; }
/// <inheritdoc />
/// <summary>Disposes the underlying worker process.</summary>
public void Dispose()
{
Process.Dispose();
@@ -58,12 +58,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
_logger = logger ?? NullLogger<WorkerProcessLauncher>.Instance;
}
/// <summary>
/// Launches a worker process and waits for startup.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Handle to the launched worker process.</returns>
/// <inheritdoc />
public async Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -2,11 +2,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Workers;
public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe
{
/// <summary>Verifies that the worker process has started and has not exited.</summary>
/// <param name="process">Worker process to verify.</param>
/// <param name="request">Process launch request.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Completed task if process is running.</returns>
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -5,6 +5,7 @@ public static class WorkerServiceCollectionExtensions
{
/// <summary>Registers worker process launcher and factory services.</summary>
/// <param name="services">Service collection to register services.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddWorkerProcessLauncher(this IServiceCollection services)
{
services.AddSingleton<IWorkerProcessFactory, SystemWorkerProcessFactory>();