docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
This commit is contained in:
@@ -19,7 +19,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// Reason prefix set when the effective mode can need the subtag watch-list
|
||||
/// (<c>Auto</c> or <c>ForceSubtag</c>) but the list resolved to zero targets, so the
|
||||
/// subtag provider covers no alarms (GWC-37). In <c>Auto</c> this is a warning about the
|
||||
/// subtag provider covers no alarms. In <c>Auto</c> this is a warning about the
|
||||
/// standby, not about the currently-serving alarm manager, which is why the dashboard
|
||||
/// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover.
|
||||
/// </summary>
|
||||
@@ -29,7 +29,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so
|
||||
/// the watch-list carries only what configuration contributed and may be incomplete
|
||||
/// (GWC-37). Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
|
||||
/// . Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
|
||||
/// usable config-only list is a completeness warning, not a total loss of coverage.
|
||||
/// </summary>
|
||||
public const string SubtagDiscoveryFailed =
|
||||
@@ -37,7 +37,7 @@ public static class AlarmProviderReasons
|
||||
|
||||
/// <summary>
|
||||
/// Reason prefix set when a re-resolution at the failover edge produced a different
|
||||
/// watch-list than the one sent at <c>SubscribeAlarms</c> (GWC-37). The worker keeps the
|
||||
/// watch-list than the one sent at <c>SubscribeAlarms</c>. The worker keeps the
|
||||
/// list it was given for the life of the subscription, so the difference is reported
|
||||
/// rather than applied; a monitor restart re-resolves and re-sends.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
/// <summary>
|
||||
/// The outcome of one subtag watch-list resolution: the composed targets plus enough
|
||||
/// provenance for the caller to tell an empty list that means "nothing was configured"
|
||||
/// from one that means "discovery was configured and silently produced nothing"
|
||||
/// (GWC-37). <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// from one that means "discovery was configured and silently produced nothing".
|
||||
/// <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// on a discovery failure, so without this the two are indistinguishable at the call site
|
||||
/// and a Galaxy Repository outage at startup can only be found in a log line.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
|
||||
|
||||
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
|
||||
|
||||
// GWC-37: the caller cannot grade an empty watch-list without knowing whether discovery
|
||||
// The caller cannot grade an empty watch-list without knowing whether discovery
|
||||
// was asked to contribute and whether it failed while doing so, because the failure is
|
||||
// deliberately swallowed below.
|
||||
string? discoveryFailureMessage = null;
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// every path that writes _alarms must null this under _sync, or readers keep a stale set.
|
||||
private ActiveAlarmSnapshot[]? _currentAlarmsProjection;
|
||||
|
||||
// NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
|
||||
// Dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
|
||||
// the most recent reconcile pass, keyed by reference with the instance's original raise
|
||||
// timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate
|
||||
// of the repair and is suppressed. One generation deep: each reconcile pass replaces the map,
|
||||
@@ -57,7 +57,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
private string _providerReason = string.Empty;
|
||||
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
|
||||
|
||||
// GWC-37 subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
|
||||
// Subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
|
||||
// using can actually cover anything, and why not. Independent of _providerDegraded (which
|
||||
// means "the subtag provider is the one serving"): in Auto mode the alarm manager can be
|
||||
// serving perfectly while the standby behind it covers zero alarms, and that is invisible
|
||||
@@ -258,7 +258,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
try
|
||||
{
|
||||
// Attach the internal distributor subscriber BEFORE subscribing (GWC-26). The pump
|
||||
// Attach the internal distributor subscriber BEFORE subscribing. The pump
|
||||
// has been running since MarkReady started the dashboard mirror, and the distributor
|
||||
// only fans to subscribers registered at the time of the fan-out, so a subscriber
|
||||
// taken after SubscribeAlarms + the first reconcile would silently lose every
|
||||
@@ -351,7 +351,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
|
||||
|
||||
// GWC-37: grade the coverage before the command goes out, so a Galaxy Repository outage at
|
||||
// Grade the coverage before the command goes out, so a Galaxy Repository outage at
|
||||
// startup is a standing status on the feed instead of one log line an operator reads weeks
|
||||
// later — when the failover that needed the watch-list finds it empty. Broadcast because
|
||||
// feed subscribers outlive the monitor's worker session and may already be attached.
|
||||
@@ -507,7 +507,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
}
|
||||
}
|
||||
|
||||
// NEXT-03: best-effort dedup of the reconcile/live race. A reconcile that already synthesized
|
||||
// Best-effort dedup of the reconcile/live race. A reconcile that already synthesized
|
||||
// this transition as a feed repair left the cache carrying the worker's transition timestamp
|
||||
// and resulting state — both derived from the same worker-side value the live transition
|
||||
// carries — so an exact (timestamp, state) match means this live transition's outcome has
|
||||
@@ -521,7 +521,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
&& existing.CurrentState == incoming.CurrentState;
|
||||
}
|
||||
|
||||
// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry before the buffered
|
||||
// The Clear leg of the dedup. A reconcile Clear repair removes the cache entry before the buffered
|
||||
// live Clear drains, so there is no cached state to compare against; the tombstone recorded by
|
||||
// ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The
|
||||
// match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer
|
||||
@@ -803,7 +803,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
|
||||
//
|
||||
// GWC-37: degraded is the OR of "the subtag provider is the one serving" and "the subtag
|
||||
// Degraded is the OR of "the subtag provider is the one serving" and "the subtag
|
||||
// watch-list cannot cover anything". The second is what makes an empty watch-list visible in
|
||||
// Auto mode, where the alarm manager is serving happily and the standby behind it is empty.
|
||||
// The reasons are concatenated when both apply, which is the worst case worth naming loudly:
|
||||
@@ -857,7 +857,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||
// a synthetic transition for any alarm the live stream missed.
|
||||
//
|
||||
// Repair scope (GWC-26): presence deltas (Clear/Raise) plus acked-state deltas. These are
|
||||
// Repair scope: presence deltas (Clear/Raise) plus acked-state deltas. These are
|
||||
// ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt
|
||||
// from the worker's own authoritative snapshot to repair what the live feed missed. They are
|
||||
// not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not
|
||||
@@ -867,7 +867,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// reads the worker's current state while the corresponding live transition may still be
|
||||
// buffered in the alarm lease's channel; both would then broadcast, and the two are
|
||||
// indistinguishable on the feed, since nothing serializes a reconcile against the in-flight
|
||||
// live stream. ApplyTransition narrows that window with a best-effort dedup (NEXT-03): a live
|
||||
// live stream. ApplyTransition narrows that window with a best-effort dedup: a live
|
||||
// transition whose worker timestamp and resulting state the cache already carries — or whose
|
||||
// Clear matches a tombstone recorded below — was already broadcast as a repair and is
|
||||
// suppressed. The dedup fires only on a positive marker match, so the contract stays
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
|
||||
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
|
||||
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
|
||||
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
|
||||
/// rooted path on the running host for the same reason the auth DB path must be: a
|
||||
/// non-rooted value silently resolves against the launch working directory.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
|
||||
|
||||
@@ -18,7 +18,7 @@ internal static class GatewayConfigPathRules
|
||||
/// against the launch working directory, so the store moves with the CWD and can leak into the
|
||||
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
|
||||
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
|
||||
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
|
||||
/// blessed and then written as a junk-named relative file. Reject
|
||||
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
|
||||
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
|
||||
/// </summary>
|
||||
|
||||
@@ -113,7 +113,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
|
||||
builder);
|
||||
|
||||
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
|
||||
// The two-layer limiter knobs accept 0 as "disable this layer": a zero aggregate
|
||||
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
|
||||
// Negatives express no intent.
|
||||
AddIfNegative(
|
||||
@@ -341,7 +341,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
|
||||
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
|
||||
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
|
||||
// sized checked(2 * EventChannelCapacity). Cap it at int.MaxValue/2 so that doubling
|
||||
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
|
||||
// upper-bound pattern.
|
||||
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
|
||||
@@ -607,7 +607,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
|
||||
// message is not doubled up with the individual range errors.
|
||||
//
|
||||
// IPC-33: the coupling also has an upper half. The worker budgets a DrainEvents reply against the
|
||||
// The coupling also has an upper half. The worker budgets a DrainEvents reply against the
|
||||
// negotiated frame maximum less two reserves, and that same MxCommandReply is then returned
|
||||
// verbatim through Invoke, where the gateway's gRPC MaxSendMessageSize (= MaxGrpcMessageBytes)
|
||||
// applies. Letting the pipe maximum run arbitrarily far above the gRPC cap lets the worker pack a
|
||||
@@ -671,7 +671,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
|
||||
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
|
||||
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
|
||||
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
|
||||
// fails fast instead of being blessed and written as a junk-named relative file.
|
||||
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
|
||||
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class WorkerOptions
|
||||
/// still fits one worker frame, and at most <em>twice</em> that reserve above it so a
|
||||
/// <c>DrainEvents</c> reply budgeted against this maximum (frame max less two reserves) still
|
||||
/// fits the gRPC send cap it is returned through — otherwise the worker can pack a reply the
|
||||
/// pipe carries and the gRPC layer refuses, losing the already-drained events (IPC-33). The
|
||||
/// pipe carries and the gRPC layer refuses, losing the already-drained events. The
|
||||
/// gateway conveys this value to the worker in the handshake
|
||||
/// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>
|
||||
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
|
||||
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
|
||||
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40).
|
||||
/// is the point — there is no configuration value that can turn this into a bypass.
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedDashboardRoleHandler
|
||||
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// into this one by accident.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
|
||||
/// Introduced to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
|
||||
/// which is gateway-central and session-less and therefore carries no per-session provenance the
|
||||
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
|
||||
/// tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
|
||||
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
|
||||
/// state, severity, category, area, acknowledging operator — away from the read-only environment
|
||||
/// bypasses, which exist for local development and unauthenticated deployments rather than as a
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
|
||||
/// <summary>
|
||||
/// Badge label shown when the alarm manager is serving normally but the subtag standby
|
||||
/// behind it covers nothing — an empty or undiscoverable watch-list (GWC-37). The feed is
|
||||
/// behind it covers nothing — an empty or undiscoverable watch-list. The feed is
|
||||
/// healthy today; what is degraded is the fallback that would take over tomorrow, so the
|
||||
/// badge names the standby rather than claiming the feed has failed over.
|
||||
/// </summary>
|
||||
@@ -77,7 +77,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
&& status.Mode == AlarmProviderMode.Subtag
|
||||
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
|
||||
|
||||
// GWC-37: degraded WITHOUT subtag mode means the alarm manager is still serving and the
|
||||
// Degraded WITHOUT subtag mode means the alarm manager is still serving and the
|
||||
// standby is the unsound half. "Subtag monitoring (degraded)" would misreport which
|
||||
// provider is live, so this state gets its own label; the amber badge is shared because
|
||||
// the operator action — fix discovery — is equally overdue in both.
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class DashboardAuthenticationDefaults
|
||||
|
||||
/// <summary>
|
||||
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
|
||||
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
|
||||
/// <c>/alarms</c> page that reads the same central monitor in process. Accepts the
|
||||
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
|
||||
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
|
||||
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
|
||||
@@ -49,10 +49,10 @@ public static class DashboardAuthenticationDefaults
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
|
||||
/// <summary>
|
||||
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
|
||||
/// Claim carrying one dashboard event-visibility tag the caller is granted. Stamped
|
||||
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
|
||||
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>. <see cref="IDashboardSessionAcl"/> reads
|
||||
/// this claim only as a FALLBACK, for a principal that carries no
|
||||
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
|
||||
/// payload carries tags but not groups); a cookie principal is re-resolved against current
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
|
||||
/// <param name="options">
|
||||
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
|
||||
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
|
||||
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
|
||||
/// </param>
|
||||
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||
public sealed class DashboardAuthenticator(
|
||||
@@ -142,7 +142,7 @@ public sealed class DashboardAuthenticator(
|
||||
claims.AddRange(groupNames.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
|
||||
// Dashboard event-visibility tags. Visibility only — never a data-access grant —
|
||||
// and never logged: only the decision, never the tag values, reaches diagnostics.
|
||||
claims.AddRange(DashboardGroupTagMapping
|
||||
.MapGroupsToTags(groupNames, groupToTag)
|
||||
|
||||
@@ -107,7 +107,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
|
||||
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
|
||||
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
|
||||
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is
|
||||
// real authenticated dashboard role — see AlarmsHub for why the alarm feed is
|
||||
// gated harder than the session-scoped ones.
|
||||
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||
|
||||
@@ -177,7 +177,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
||||
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
|
||||
// Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
|
||||
// and an external hub reader), but a requirement type DashboardAuthorizationHandler
|
||||
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
|
||||
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// (ordinal-ignore-case).</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call
|
||||
/// <strong>Where the grant comes from.</strong> The grant is re-resolved on every call
|
||||
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
|
||||
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
|
||||
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
|
||||
@@ -120,7 +120,7 @@ public sealed class DashboardSessionAcl(
|
||||
|
||||
if (groups is not null)
|
||||
{
|
||||
// SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an
|
||||
// Current options, not the login-time snapshot. MapGroupsToTags returns an
|
||||
// OrdinalIgnoreCase set, so probing it with the session's tags carries the
|
||||
// case-insensitive comparison in the same direction the claim scan below does.
|
||||
IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags(
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class HubTokenService
|
||||
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
||||
// bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
|
||||
// the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
|
||||
// the same way: the token carries the tags resolved from the caller's LDAP groups at
|
||||
// mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
|
||||
// connections within one lifetime — the natural place the deferred "tokens gain session
|
||||
// binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
|
||||
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing
|
||||
/// session provenance, so the per-session tag ACL that scopes <c>EventsHub</c> has nothing
|
||||
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
|
||||
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate:
|
||||
/// subscription expression's alarm metadata. What is tightened instead is the entry gate:
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
|
||||
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
|
||||
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
||||
/// those audiences is subject to this dashboard-display flag.
|
||||
/// <para>
|
||||
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
|
||||
/// The broadcast stays deliberately unfiltered. Audience is decided once, at hub
|
||||
/// admission — <see cref="AlarmsHub"/> carries
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
|
||||
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
|
||||
|
||||
@@ -39,8 +39,8 @@ public sealed class EventsHub(
|
||||
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
|
||||
/// only checks that the caller carries one of the dashboard roles, which by
|
||||
/// itself would let any Viewer subscribe to any session id they name. The
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s.
|
||||
/// The admin bypass is evaluated first, so an
|
||||
/// Administrator joins any session id they name; every check below it
|
||||
/// applies to non-Admin callers only. For those: a Viewer sees a session
|
||||
/// only when its tags intersect their granted tags, an untagged session
|
||||
@@ -95,16 +95,12 @@ public sealed class EventsHub(
|
||||
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases every session subscription the dropped connection held. A browser
|
||||
/// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
|
||||
/// this the session would look watched forever and the mirror would keep
|
||||
/// cloning and sending events to an empty group.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that terminated the connection, if any.</param>
|
||||
/// <returns>A task representing the disconnect handling.</returns>
|
||||
/// <inheritdoc />
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
// Release every session subscription the dropped connection held. A browser tab that
|
||||
// closes never calls UnsubscribeSession, so without this the session would look watched
|
||||
// forever and the mirror would keep cloning and sending events to an empty group.
|
||||
viewerRegistry.ReleaseConnection(Context.ConnectionId);
|
||||
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a dashboard principal may observe one session's mirrored
|
||||
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
|
||||
/// event stream. Consulted at every subscribe seam: the
|
||||
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
|
||||
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
|
||||
/// session-details page.
|
||||
|
||||
@@ -154,8 +154,8 @@ public static class GatewayApplication
|
||||
// having pushed the ambient identity before the library's galaxy service runs. That
|
||||
// ordering is still required, but it is no longer trusted silently: with
|
||||
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy
|
||||
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy.
|
||||
// Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||
|
||||
@@ -164,7 +164,7 @@ public static class GatewayApplication
|
||||
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
|
||||
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
|
||||
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
|
||||
// byte-identical to the removed appsettings literal on Windows (SEC-33).
|
||||
// byte-identical to the removed appsettings literal on Windows.
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
|
||||
{
|
||||
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
||||
@@ -175,7 +175,7 @@ public static class GatewayApplication
|
||||
|
||||
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
||||
|
||||
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
|
||||
// Validate that persistence has a valid, host-rooted snapshot path.
|
||||
builder.Services.AddSingleton<
|
||||
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
||||
Configuration.GalaxyRepositoryOptionsValidator>();
|
||||
@@ -224,7 +224,7 @@ public static class GatewayApplication
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
|
||||
/// together, and the mechanism is the one SEC-33 already used for
|
||||
/// together, and the mechanism is the same mechanism already used for
|
||||
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
|
||||
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
|
||||
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
|
||||
|
||||
@@ -116,7 +116,7 @@ public sealed class EventStreamService(
|
||||
// its `yield return` is what feeds the gRPC writer.
|
||||
ChannelReader<MxEvent> reader = subscriber.Reader;
|
||||
|
||||
// GWC-15: register this subscriber's channel as a live backlog source instead of
|
||||
// Register this subscriber's channel as a live backlog source instead of
|
||||
// reconciling the queue-depth gauge on every event. The gauge previously read the
|
||||
// bounded channel's Count (which takes the channel's internal lock) and adjusted the
|
||||
// metric under its own lock on every streamed event. Now the metric reads Count only
|
||||
@@ -197,7 +197,7 @@ public sealed class EventStreamService(
|
||||
}
|
||||
|
||||
// The queue-depth gauge is maintained lazily via the backlog registration
|
||||
// above (GWC-15): the metric reads this subscriber's channel Count only when
|
||||
// above: the metric reads this subscriber's channel Count only when
|
||||
// scraped, so there is no per-event gauge bookkeeping on this hot path.
|
||||
yield return mxEvent;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
// The session's owner id and its dashboard-visibility tags both come from the resolved
|
||||
// API key identity, never from the request: the key is the tenant principal, so a
|
||||
// client cannot label its own session with another tenant's tag (SEC-25).
|
||||
// client cannot label its own session with another tenant's tag.
|
||||
ApiKeyIdentity? owner = identityAccessor.Current;
|
||||
GatewaySession session = await sessionManager
|
||||
.OpenSessionAsync(
|
||||
@@ -130,7 +130,7 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
// Map from the command alone: cloning the whole request only to overwrite its command with
|
||||
// commandToInvoke deep-cloned the (potentially large) original payload for nothing, since
|
||||
// MapCommand reads nothing but the command (GWC-29). The one clone that matters still
|
||||
// MapCommand reads nothing but the command. The one clone that matters still
|
||||
// happens inside MapCommand, which is what keeps the worker-bound graph unaliased from
|
||||
// commandToInvoke — the caller still reads it below via TrackCommandReply.
|
||||
WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);
|
||||
@@ -167,7 +167,7 @@ public sealed class MxAccessGatewayService(
|
||||
.WithCancellation(context.CancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// GWC-06: measure send latency with the allocation-free timestamp API rather
|
||||
// Measure send latency with the allocation-free timestamp API rather
|
||||
// than allocating a Stopwatch object per event per subscriber on the highest-
|
||||
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
|
||||
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
|
||||
@@ -185,15 +185,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)
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class MxAccessGrpcMapper
|
||||
/// <summary>
|
||||
/// Maps a gRPC MX command to a worker command. Callers that already hold the command — including
|
||||
/// the constraint pipeline, whose rewritten command is not the one on the request — use this
|
||||
/// overload rather than cloning a whole request to carry a single field (GWC-29); nothing outside
|
||||
/// overload rather than cloning a whole request to carry a single field; nothing outside
|
||||
/// the command is read here.
|
||||
/// </summary>
|
||||
/// <param name="command">Command payload.</param>
|
||||
@@ -71,7 +71,7 @@ public sealed class MxAccessGrpcMapper
|
||||
};
|
||||
}
|
||||
|
||||
// GWC-07 / IPC-05: ownership transfer, not a deep clone — the same rule MapEvent follows,
|
||||
// ownership transfer, not a deep clone — the same rule MapEvent follows,
|
||||
// applied to the other (and larger, on bulk reads) hot-path message. The enclosing
|
||||
// WorkerCommandReply is parsed fresh from a single pipe frame in WorkerClient's read loop
|
||||
// and is single-consumer by construction: CompleteCommand's TryRemove hands it to exactly
|
||||
@@ -93,10 +93,10 @@ public sealed class MxAccessGrpcMapper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||
|
||||
// GWC-07 / IPC-05: ownership transfer, not a deep clone. The enclosing WorkerEvent is
|
||||
// ownership transfer, not a deep clone. The enclosing WorkerEvent is
|
||||
// parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded
|
||||
// immediately after this mapping — the SessionEventDistributor pump is its single
|
||||
// consumer (GWC-01 claims the worker event channel as single-reader), so nothing else
|
||||
// consumer (it claims the worker event channel as single-reader), so nothing else
|
||||
// aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the
|
||||
// outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to
|
||||
// every subscriber and retains it in the replay ring, but that sharing is READ-ONLY
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _retryAttemptsByArea = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// GWC-15: live backlog sources for the gRPC event-stream queue-depth gauge. Each active
|
||||
// live backlog sources for the gRPC event-stream queue-depth gauge. Each active
|
||||
// StreamEvents subscriber registers a delegate returning its channel's current backlog; the
|
||||
// gauge sums them ONLY when scraped or projected (GetGrpcEventStreamQueueDepth), so nothing is
|
||||
// read on the per-event streaming hot path. Concurrent because subscribers register/unregister
|
||||
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
|
||||
private long _nextEventStreamBacklogSourceId;
|
||||
|
||||
// PERF-02: the same pull model for the worker event queue depth. It replaces a pushed scalar that
|
||||
// the same pull model for the worker event queue depth. It replaces a pushed scalar that
|
||||
// every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide
|
||||
// lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one
|
||||
// arbitrary session's backlog instead of the gateway's. Each client registers a source returning
|
||||
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// <param name="method">Name of the command method.</param>
|
||||
public void CommandStarted(string method)
|
||||
{
|
||||
// PERF-02: the three command counters run two-to-three times per gRPC call, so they use
|
||||
// the three command counters run two-to-three times per gRPC call, so they use
|
||||
// Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a
|
||||
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
|
||||
Interlocked.Increment(ref _commandsStarted);
|
||||
@@ -281,7 +281,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// Registers a live depth source for the worker event queue-depth gauge and returns a handle
|
||||
/// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own
|
||||
/// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of
|
||||
/// the last value any one session happened to push (PERF-02).
|
||||
/// the last value any one session happened to push.
|
||||
/// </summary>
|
||||
/// <param name="depth">
|
||||
/// Returns this worker client's current undelivered event count. Invoked only at collection
|
||||
@@ -303,7 +303,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// returns a handle that removes it when disposed. The gauge sums every registered
|
||||
/// source's current value on demand (when scraped or via <see cref="GetSnapshot"/>),
|
||||
/// rather than tracking a pushed running total, so the streaming hot path does no
|
||||
/// per-event gauge bookkeeping (GWC-15).
|
||||
/// per-event gauge bookkeeping.
|
||||
/// </summary>
|
||||
/// <param name="backlog">
|
||||
/// Returns this subscriber's current channel backlog. Invoked only at collection time;
|
||||
@@ -351,7 +351,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
// Tagged by stage only. Neither the key id nor the peer address may become a tag: key ids
|
||||
// are credential material and peer addresses are unbounded cardinality, and /metrics is not
|
||||
// authenticated (open SEC-14), so anything tagged here is world-readable.
|
||||
// authenticated, so anything tagged here is world-readable.
|
||||
_authThrottledCounter.Add(1, new KeyValuePair<string, object?>("stage", stage));
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
// Compute the live queue depths outside _syncRoot: the sources are the subscriber channels'
|
||||
// Count (their own locks) and the worker clients' interlocked counters, neither of which may
|
||||
// run under this lock. GWC-15, PERF-02.
|
||||
// run under this lock.
|
||||
int workerEventQueueDepth = GetWorkerEventQueueDepth();
|
||||
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
|
||||
lock (_syncRoot)
|
||||
@@ -521,7 +521,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Sums the undelivered event backlog across every live worker client (PERF-02).
|
||||
// Sums the undelivered event backlog across every live worker client.
|
||||
private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources);
|
||||
|
||||
// Sums the live backlog across every registered event-stream subscriber.
|
||||
@@ -571,6 +571,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Removes the source from its gauge; subsequent calls are no-ops.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
|
||||
@@ -39,12 +39,7 @@ public sealed class AuditDrainService(
|
||||
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
|
||||
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
|
||||
/// writer switches from synchronous write-through to enqueueing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -64,12 +59,7 @@ public sealed class AuditDrainService(
|
||||
await base.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
|
||||
/// events a bounded window to reach the store.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
writer.DetachDrain();
|
||||
|
||||
@@ -87,11 +87,9 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// One connection, one transaction and one prepared command for the whole batch: the drain
|
||||
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
|
||||
/// than one round-trip per event.
|
||||
/// </remarks>
|
||||
// One connection, one transaction and one prepared command for the whole batch: the drain
|
||||
// pays a single commit for up to AuditDrainService.MaxBatchSize events rather
|
||||
// than one round-trip per event.
|
||||
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(auditEvents);
|
||||
@@ -148,17 +146,15 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored
|
||||
/// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
|
||||
/// ISO-8601 — which <see cref="AuditEvent.OccurredAtUtc"/> guarantees for rows written through
|
||||
/// this store, but not for rows that entered the table any other way (a repair script, an
|
||||
/// older schema, a future producer). On a mixed-format column a text comparison silently
|
||||
/// deletes live audit: <c>2026-05-17T09:00:00-05:00</c> is two hours AFTER a
|
||||
/// <c>2026-05-17T12:00:00+00:00</c> cutoff yet sorts before it. Comparing instants is correct
|
||||
/// regardless of how the text got there, and anything <c>datetime()</c> cannot parse yields
|
||||
/// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
/// </remarks>
|
||||
// The comparison goes through SQLite's datetime() rather than comparing the stored
|
||||
// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
|
||||
// ISO-8601 — which AuditEvent.OccurredAtUtc guarantees for rows written through
|
||||
// this store, but not for rows that entered the table any other way (a repair script, an
|
||||
// older schema, a future producer). On a mixed-format column a text comparison silently
|
||||
// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
|
||||
// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
|
||||
// regardless of how the text got there, and anything datetime() cannot parse yields
|
||||
// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record ApiKeyConstraints(
|
||||
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
|
||||
/// Gets the dashboard event-visibility tags granted to this key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -44,7 +44,7 @@ public interface IApiKeyCacheInvalidator
|
||||
/// evicting so an in-flight verification that started under the old generation discards its own
|
||||
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
|
||||
/// take effect immediately" contract true even against a verify that was already in the inner library
|
||||
/// when the revoke landed (SEC-34 window 3).
|
||||
/// when the revoke landed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
|
||||
@@ -141,7 +141,7 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
}
|
||||
|
||||
// Caches a successful verification unless the key was invalidated while the inner verify was in
|
||||
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
|
||||
// flight (detected via the generation snapshot). The entry lifetime stays the
|
||||
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
|
||||
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
|
||||
private void TryCacheSuccess(
|
||||
@@ -202,11 +202,14 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
? generation
|
||||
: 0;
|
||||
|
||||
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
|
||||
// the same split the authorization interceptor does. Returns null for a header this cache cannot
|
||||
// attribute to a key id (in which case the generation race-guard is simply not applied).
|
||||
//
|
||||
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_'
|
||||
/// <summary>
|
||||
/// Parses the key id out of a <c>Bearer mxgw_<keyId>_<secret></c> header without any store access,
|
||||
/// using the same split the authorization interceptor does.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">The raw authorization header value, or <see langword="null"/>.</param>
|
||||
/// <returns>The full key id, or <see langword="null"/> for a header this cache cannot attribute to a key id (the generation race-guard is then not applied).</returns>
|
||||
// Implementation note:
|
||||
// Correctness of the generation guard rests on parts[1] being the FULL key id: the '_'
|
||||
// split would truncate a key id that itself contained '_', silently disarming the guard for that
|
||||
// key. This is safe because '_' is the token's field delimiter and both — and the only — key
|
||||
// creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// source-rotating sprayer that never trips any single partition.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An over-limit state is a valve, not a wall (SEC-31): at most one request per
|
||||
/// An over-limit state is a valve, not a wall: at most one request per
|
||||
/// <see cref="SecurityOptions.ApiKeyFailureProbeIntervalSeconds"/> is admitted through to the real
|
||||
/// verifier, and a successful verification resets both layers. The reset path therefore stays
|
||||
/// reachable while throttled — the earlier design keyed solely on the attacker-supplied key id and
|
||||
@@ -29,8 +29,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>).
|
||||
/// Because only a validly shaped token mints a key-id partition and each transport peer may mint at
|
||||
/// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that
|
||||
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush
|
||||
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer
|
||||
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush.
|
||||
/// A validly shaped presented key id still feeds its cross-peer aggregate when its peer
|
||||
/// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray
|
||||
/// protection; the aggregate map remains bounded by the same capped eviction policy:
|
||||
/// eviction prefers fully expired windows, never removes an over-limit partition below a transient
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
||||
/// <remarks>
|
||||
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
|
||||
/// Distinguishes two states the earlier code collapsed into one: "authenticated, no
|
||||
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
|
||||
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
|
||||
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
|
||||
|
||||
+7
-2
@@ -140,12 +140,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
// Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the
|
||||
// secret may itself contain underscores, which does not affect the key id). The shape is checked
|
||||
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
|
||||
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check
|
||||
// partition each and flush the limiter's bounded map. Anything that fails the check
|
||||
// falls back to the sender's transport-peer partition.
|
||||
//
|
||||
// Internal rather than private so the parse rules can be pinned directly by test; the shape
|
||||
// check is a security boundary (SEC-32) and is worth asserting without routing every case
|
||||
// check is a security boundary and is worth asserting without routing every case
|
||||
// through a full RPC.
|
||||
/// <summary>
|
||||
/// Extracts the API key id from a bearer authorization header.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">The raw <c>authorization</c> header value, or <see langword="null"/>.</param>
|
||||
/// <returns>The key id when the header has the <c>mxgw_<keyId>_<secret></c> shape; otherwise <see langword="null"/>.</returns>
|
||||
internal static string? TryResolveKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class GatewaySession
|
||||
private bool _everHadEventSubscriber;
|
||||
private SessionEventDistributor? _eventDistributor;
|
||||
private bool _eventDistributorStarted;
|
||||
// GWC-34: set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
|
||||
// Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
|
||||
// "one distributor per session, never reset" lifecycle symmetric at both ends. Without it
|
||||
// EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
|
||||
// distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
|
||||
@@ -163,7 +163,7 @@ public sealed class GatewaySession
|
||||
/// is unit-testable.
|
||||
/// </param>
|
||||
/// <param name="ownerDashboardTags">
|
||||
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into
|
||||
/// Dashboard event-visibility tags inherited from the owning API key. Copied into
|
||||
/// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged.
|
||||
/// The tags come from the owner key, never from the client's wire request, so a client
|
||||
/// cannot label its own session with another tenant's tag.
|
||||
@@ -265,8 +265,8 @@ public sealed class GatewaySession
|
||||
public string? OwnerKeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key
|
||||
/// (SEC-25). An empty set means untagged.
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key.
|
||||
/// An empty set means untagged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Immutable for the session's life — assigned once at construction from the owner key's
|
||||
@@ -541,7 +541,7 @@ public sealed class GatewaySession
|
||||
// concurrent callers (e.g. MarkReady's dashboard mirror and a racing first
|
||||
// AttachEventSubscriber) agree on a single distributor and a single start.
|
||||
//
|
||||
// GWC-34: after DisposeAsync the null distributor means "gone", not "not built yet", so this
|
||||
// After DisposeAsync the null distributor means "gone", not "not built yet", so this
|
||||
// throws instead of constructing a replacement nothing will ever pump. The attach paths already
|
||||
// roll back and surface the exception, which is the same SessionNotReady the readiness gates
|
||||
// raise — a caller that loses the race to teardown now fails immediately instead of hanging.
|
||||
@@ -610,7 +610,7 @@ public sealed class GatewaySession
|
||||
/// </exception>
|
||||
public IEventSubscriberLease AttachInternalEventSubscriber()
|
||||
{
|
||||
// Readiness gate, mirroring AttachEventSubscriber (GWC-27). It must run BEFORE
|
||||
// Readiness gate, mirroring AttachEventSubscriber. It must run BEFORE
|
||||
// EnsureDistributorCreated: a premature attach would construct the distributor and start
|
||||
// its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady,
|
||||
// PumpAsync would complete every subscriber with that error and latch _completed, and
|
||||
@@ -694,7 +694,7 @@ public sealed class GatewaySession
|
||||
// These three calls are OUTSIDE _syncRoot to avoid holding it across
|
||||
// EnsureDistributorCreated's own lock and StartAsync's Task.Run.
|
||||
//
|
||||
// GWC-34: the state check above can be overtaken by a close+dispose before we get here, and
|
||||
// The state check above can be overtaken by a close+dispose before we get here, and
|
||||
// EnsureDistributorCreated now says so instead of building an orphan distributor. The
|
||||
// dashboard mirror is a best-effort observability attachment on a session that is already
|
||||
// gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started
|
||||
@@ -1918,7 +1918,7 @@ public sealed class GatewaySession
|
||||
{
|
||||
distributor = _eventDistributor;
|
||||
_eventDistributor = null;
|
||||
// GWC-34: latch the null-out so EnsureDistributorCreated cannot read it as
|
||||
// Latch the null-out so EnsureDistributorCreated cannot read it as
|
||||
// "not created yet" and hand a racing attach a distributor that will never pump.
|
||||
_eventDistributorDisposed = true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public interface ISessionManager
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
|
||||
/// tags onto it (SEC-25).
|
||||
/// tags onto it.
|
||||
/// </summary>
|
||||
/// <param name="request">Request payload.</param>
|
||||
/// <param name="clientIdentity">Client identity string.</param>
|
||||
|
||||
@@ -520,7 +520,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
|
||||
{
|
||||
gap = _anyEventSeen && afterSequence < _highestSequenceSeen;
|
||||
|
||||
// GWC-25: nothing is retained, but a gapped client still needs a usable resume
|
||||
// Nothing is retained, but a gapped client still needs a usable resume
|
||||
// anchor. The documented client formula is
|
||||
// after_worker_sequence = oldest_available_sequence - 1, so reporting 0 here made
|
||||
// an unsigned client compute ulong.MaxValue: the follow-up resume then replayed
|
||||
|
||||
@@ -532,7 +532,7 @@ public sealed class SessionManager : ISessionManager
|
||||
// The short prefix and bare guid keep the pipe's Unix-domain-socket path
|
||||
// (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on
|
||||
// macOS, whose default per-user TMPDIR is ~49 chars; the gateway PID keeps
|
||||
// the name collision-free across gateway restarts (NEXT-01).
|
||||
// the name collision-free across gateway restarts.
|
||||
string pipeName = $"mxgw-{Environment.ProcessId}-{sessionUid}";
|
||||
string nonce = CreateNonce();
|
||||
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
// Staging hand-off between the read loop and the dedicated event writer. The read loop writes
|
||||
// here with a non-blocking TryWrite so a full consumer channel (_events) can never stall the read
|
||||
// loop behind an event — replies and heartbeats keep flowing. Bounded at 2 × EventChannelCapacity
|
||||
// (GWC-24): an unbounded staging channel let a consumer that drains slower than the worker
|
||||
// because an unbounded staging channel let a consumer that drains slower than the worker
|
||||
// produces grow gateway memory without limit and without a fault, because each individual timed
|
||||
// write into _events still completed inside EventChannelFullModeTimeout. A rejected TryWrite here
|
||||
// is the sustained-slow-drain signal and faults the client immediately (ProtocolViolation), which
|
||||
@@ -41,7 +41,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
private readonly SemaphoreSlim _pendingCommandSlots;
|
||||
private readonly CancellationTokenSource _stopCts = new();
|
||||
|
||||
// PERF-02: this client's contribution to the gateway-wide worker queue-depth gauge. Registered
|
||||
// this client's contribution to the gateway-wide worker queue-depth gauge. Registered
|
||||
// once here and read only when the gauge is scraped, so staging and consuming an event cost an
|
||||
// Interlocked on _eventQueueDepth and nothing else — previously each of those two hot-path steps
|
||||
// called into GatewayMetrics and took its process-wide lock. Null when metrics are disabled.
|
||||
@@ -233,13 +233,13 @@ public sealed class WorkerClient : IWorkerClient
|
||||
// size the caller controls; checking here keeps a MessageTooLarge in the write loop a
|
||||
// genuine desync signal.
|
||||
//
|
||||
// PERF-07: this size cannot be handed to WorkerFrameWriter to spare its own
|
||||
// this size cannot be handed to WorkerFrameWriter to spare its own
|
||||
// CalculateSize. WriteLoopAsync stamps envelope.Sequence immediately before the write
|
||||
// (GWC-28), and a non-zero varint field grows the encoding — so the number computed here
|
||||
// and a non-zero varint field grows the encoding — so the number computed here
|
||||
// is a lower bound on the frame the writer actually emits, never the frame length. Passing
|
||||
// it as a knownSize would under-length the prefix and desync the worker's framing.
|
||||
//
|
||||
// IPC-39: being a lower bound is conservative for the knownSize question but the wrong
|
||||
// being a lower bound is conservative for the knownSize question but the wrong
|
||||
// direction for this pre-check, which must not admit an envelope the writer will then
|
||||
// reject with a session-fatal MessageTooLarge. Compare against the frame maximum less the
|
||||
// MaxSequenceEncodingBytes the stamp can add, so every envelope this check accepts still
|
||||
@@ -257,7 +257,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
|
||||
await EnqueueAsync(commandEnvelope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// PERF-07: one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
|
||||
// one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
|
||||
// Task.WaitAsync arms a TimerQueueTimer on the shared timer queue and cancels it when the
|
||||
// reply lands, so the steady-state cost of a command that replies in time is a single
|
||||
// continuation — the old shape allocated a linked CancellationTokenSource, its
|
||||
@@ -354,7 +354,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped (PERF-02).
|
||||
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped.
|
||||
Interlocked.Decrement(ref _eventQueueDepth);
|
||||
yield return workerEvent;
|
||||
}
|
||||
@@ -420,7 +420,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// GWC-32: disposal is the third channel-completing path, and it must enter a terminal state
|
||||
// disposal is the third channel-completing path, and it must enter a terminal state
|
||||
// like the other two (MarkClosed, SetFaulted) BEFORE the channels are completed. Otherwise a
|
||||
// client disposed while still Ready keeps reporting IsTerminalState() == false, and a read
|
||||
// loop already inside DispatchEnvelope (the _stopCts.Cancel below only interrupts the NEXT
|
||||
@@ -490,7 +490,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// Worst-case number of bytes <see cref="WriteLoopAsync"/>'s sequence stamp adds to an
|
||||
/// envelope that was sized before it: one tag byte plus a ten-byte varint, the maximum
|
||||
/// encoding of a 64-bit protobuf field. The <c>CommandTooLarge</c> pre-check subtracts this
|
||||
/// from the negotiated frame maximum (IPC-39) because it measures the envelope while
|
||||
/// from the negotiated frame maximum because it measures the envelope while
|
||||
/// <c>Sequence</c> is still 0 — an unset field costs nothing to encode, so the measured size
|
||||
/// is a lower bound on the frame the writer emits, and without the allowance an envelope in
|
||||
/// the last eleven bytes of the range would pass the pre-check and then fault the session on
|
||||
@@ -505,12 +505,12 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
|
||||
{
|
||||
// GWC-28: stamp the sequence at the point of writing, not when the envelope is built.
|
||||
// stamp the sequence at the point of writing, not when the envelope is built.
|
||||
// Stamping at construction let two concurrent InvokeAsync callers take 1 and 2 and then
|
||||
// enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's
|
||||
// "monotonic per sender" contract. This loop is the channel's single consumer
|
||||
// (SingleReader = true), so wire order and stamp order are the same thing here and
|
||||
// _nextSequence needs no interlocking. Mirrors the worker's WRK-04 fix.
|
||||
// _nextSequence needs no interlocking. Mirrors the worker's equivalent fix.
|
||||
envelope.Sequence = unchecked(++_nextSequence);
|
||||
await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -678,11 +678,11 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// faults regardless of how backed up the event path is. It returns <c>false</c> in two cases:
|
||||
/// the staging channel has been completed by one of the three completing paths —
|
||||
/// <see cref="MarkClosed"/>, <see cref="SetFaulted"/> or <see cref="DisposeAsync"/>, each of
|
||||
/// which enters a terminal state before completing the channels (GWC-32), so the event is
|
||||
/// which enters a terminal state before completing the channels, so the event is
|
||||
/// safely dropped because the client is already terminal — or the channel is full at its
|
||||
/// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/> bound. The latter means the
|
||||
/// consumer has been draining slower than the worker produces for the whole time it took the
|
||||
/// worker to emit that many further events, so the client is faulted immediately (GWC-24) —
|
||||
/// worker to emit that many further events, so the client is faulted immediately —
|
||||
/// <see cref="SetFaulted"/> is non-blocking, so the read loop still never awaits here. The
|
||||
/// complementary full-stall case (a consumer that stops entirely) is caught earlier by the
|
||||
/// timed write in <see cref="EnqueueWorkerEventAsync"/>.
|
||||
@@ -753,7 +753,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// the actionable fix (attach <c>StreamEvents</c> or raise
|
||||
/// <c>MxGateway:Events:QueueCapacity</c>). This is the full-stall half of
|
||||
/// the backpressure policy; a consumer that merely drains too slowly is
|
||||
/// caught by the staging bound in <see cref="StageWorkerEvent"/> (GWC-24).
|
||||
/// caught by the staging bound in <see cref="StageWorkerEvent"/>.
|
||||
/// Queue depth is not counted here — the event was already counted when it
|
||||
/// was staged, so moving it between the two channels changes nothing.
|
||||
/// </summary>
|
||||
@@ -851,7 +851,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// <summary>
|
||||
/// Fails the pending command for a caller whose own token fired — a client deadline shorter
|
||||
/// than the gateway's command timeout, or a disconnect that cancelled the RPC's token — and
|
||||
/// forwards a <c>WorkerCancel</c> for the correlation the caller has abandoned (IPC-34).
|
||||
/// forwards a <c>WorkerCancel</c> for the correlation the caller has abandoned.
|
||||
/// <para>
|
||||
/// Caller cancellation is the common abandonment path, not the rare one, and it leaves the
|
||||
/// worker in exactly the state the timeout arm's cancel exists for: the correlation is still
|
||||
@@ -982,7 +982,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// IPC-35: a fault frame during the handshake is the worker reporting a structured startup
|
||||
// a fault frame during the handshake is the worker reporting a structured startup
|
||||
// failure — most often MXAccess COM creation (not installed, not registered, access denied)
|
||||
// — not a protocol problem. Flattening it to ProtocolViolation discarded the category and
|
||||
// diagnostic exactly where they matter most: first session open on a freshly deployed host,
|
||||
@@ -1303,7 +1303,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
string correlationId,
|
||||
WorkerCommand command)
|
||||
{
|
||||
// IPC-05: no second clone. MxAccessGrpcMapper.MapCommand already deep-cloned the command
|
||||
// no second clone. MxAccessGrpcMapper.MapCommand already deep-cloned the command
|
||||
// out of the caller-owned gRPC MxCommandRequest, so this WorkerCommand is a fresh graph
|
||||
// owned by the invoke pipeline and referenced by no other consumer. The envelope is built
|
||||
// and owned entirely inside WorkerClient and the command is not touched again after this
|
||||
@@ -1340,7 +1340,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
// Sequence is deliberately left unset here: WriteLoopAsync stamps it immediately before the
|
||||
// frame goes out, so the numbers are monotonic in wire order however the callers interleave
|
||||
// between construction and enqueue (GWC-28, mirroring the worker's WRK-04 fix).
|
||||
// between construction and enqueue (mirroring the worker's fix).
|
||||
WorkerEnvelope envelope = new()
|
||||
{
|
||||
ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class WorkerFrameReader
|
||||
private readonly WorkerFrameProtocolOptions _options;
|
||||
private readonly Stream _stream;
|
||||
|
||||
// Reused across frames rather than allocated per read (GWC-30). Safe because ReadAsync is
|
||||
// Reused across frames rather than allocated per read. Safe because ReadAsync is
|
||||
// single-consumer by construction; the prefix is fully overwritten by every read.
|
||||
private readonly byte[] _lengthPrefix = new byte[sizeof(uint)];
|
||||
|
||||
@@ -61,8 +61,8 @@ public sealed class WorkerFrameReader
|
||||
}
|
||||
|
||||
// Rent the payload buffer from the shared pool rather than allocating a fresh byte[] per
|
||||
// frame; large event frames near the cap would otherwise allocate an LOH buffer each time
|
||||
// (IPC-14). ParseFrom copies whatever it needs into the parsed message, so the rented buffer
|
||||
// frame; large event frames near the cap would otherwise allocate an LOH buffer each time.
|
||||
// ParseFrom copies whatever it needs into the parsed message, so the rented buffer
|
||||
// can be returned as soon as parsing completes without the envelope aliasing it. The rented
|
||||
// buffer may be larger than requested, so the read and parse are bounded to length.
|
||||
int length = checked((int)payloadLength);
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class WorkerFrameWriter
|
||||
// Serialize once into a single pooled buffer that carries the 4-byte little-endian length
|
||||
// prefix followed by the payload, then issue one stream write. This avoids a second
|
||||
// serialization pass (ToByteArray re-runs CalculateSize), a separate prefix array and its
|
||||
// own write, and any per-frame heap allocation (GWC-08, IPC-13). The rented buffer may be
|
||||
// own write, and any per-frame heap allocation. The rented buffer may be
|
||||
// larger than requested, so only the first frameLength bytes are ever written.
|
||||
int frameLength = sizeof(uint) + payloadLength;
|
||||
byte[] frame = ArrayPool<byte>.Shared.Rent(frameLength);
|
||||
|
||||
Reference in New Issue
Block a user