Rewrite the Pages 'Show:' lists to what each page actually renders (no
per-session subscriber/pending-command/queue counters, no worker memory/CPU,
no per-family command counters, counters not rates). Document the per-session
EventsHub pump, alarm-hub send bound and AlarmFeedReset marker, configuration
sent once per snapshot-hub connection, health-derived gateway status, Browse
search debounce/index, bounded disposal, 30s circuit retention with
connection-gated polling, per-viewer advise-set share, and AlarmsPage memo.
- Search: the oninput setter scanned the whole Galaxy synchronously per
keystroke. It now debounces 150ms, runs on the thread pool with
cancellation (newer keystroke supersedes), and reads a shared singleton
DashboardBrowseSearchIndex of pre-lowercased references keyed on the
Galaxy cache Sequence.
- Disposal: DisposeAsync awaited both loops unbounded and the loops caught
only OCE. Loops now handle faults per iteration (poll faults surface in
_readError, deploy-notifier faults in a retrying warning), mutate the tree
on the dispatcher, drain concurrently with a 5s bound, and the CTSs are
disposed in finally.
- Circuits: DisconnectedCircuitRetentionPeriod is 30s (was the 3 min
default) and a scoped DashboardCircuitConnectionTracker (CircuitHandler)
lets the 2s ReadBulk poll skip ticks while the browser is disconnected.
- Reads pass a per-page viewer id to the live-data per-viewer share.
The 256-tag LRU was global, so two Browse circuits on different areas evicted
each other's whole sets every poll. Tags now record the viewer that read them
last; each active viewer (read within 30s) owns MaxSubscribedTags/active
(floor 32). Eviction takes other viewers' excess over their share first
(inactive viewers' share is zero), then the reader's own untouched tags, and
never strips a neighbour within its share (soft cap). Single-viewer behaviour
is unchanged. IDashboardLiveDataService gains a viewerId ReadAsync overload.
FilteredAlarms() ran the full filter+sort twice per render (Showing card and
table) and Areas() once more, including every search keystroke render. Both
now go through DashboardAlarmView, recomputed only when the alarm generation
(bumped per poll) or the filter criteria change.
SEC-27: DashboardSnapshot.GatewayStatus was hardcoded Healthy. It is now the
aggregate HealthCheckService status, evaluated once per snapshot tick (2s
bound, zero-wait gate) and cached for GetSnapshot; Unknown before the first
evaluation, on failure, or without a health service.
The startup-static EffectiveGatewayConfiguration is JsonIgnore'd on the
snapshot, so /hubs/snapshot ticks carry only volatile state. The hub sends it
once per connection (ConfigurationUpdated) and serves GetConfiguration().
Snapshot broadcasts are time-bounded (1s) so one slow client cannot delay
ticks. StatusBadge maps Unhealthy to Bad.
AlarmsHubPublisher awaited every group send unbounded inside the StreamAsync
loop, so one slow client stalled the drain until the monitor dropped the
subscriber and the resubscribe replayed the full snapshot to everyone. Each
broadcast is now bounded (2s) and abandoned on timeout, and every
(re)subscription is preceded by an AlarmFeedReset frame so clients discard
state before the replayed snapshot.
Publish fired a group SendAsync per event and returned, so a slow remote
viewer accumulated unbounded pending sends plus cloned MxEvents. Hub delivery
now goes through a 256-deep DropOldest channel per hub-watched session with a
single drain task and time-bounded sends. The registry counts hub viewers
separately and raises HubViewersReleased so the pump is torn down with the
last remote viewer; in-process-only sessions start no pump.
WorkerClient.ShutdownAsync marked the client Closing and then queued the
WorkerShutdown envelope on the caller's token alone; the shutdown-timeout CTS
was only created afterwards. The outbound channel is bounded, and a worker that
has stopped reading the pipe parks the write loop in a pipe write it can only
leave through _stopCts while the channel behind it fills with commands and
their cancels. Once Closing, the heartbeat watchdog skips every tick, so nothing
cancelled _stopCts and the enqueue waited for the caller's token - the lease
sweep's stopping token, which fires at host stop and never before - with
GatewaySession.CloseAsync holding its close lock throughout.
Create the linked timeout CTS before the enqueue and run the enqueue, the loop
waits and the process-exit wait on it, so the whole call is bounded by the
shutdown timeout. A stalled queue now surfaces as the existing ShutdownTimeout
fault, which cancels the loops, kills the worker process tree and hands
GatewaySession its kill fallback.
Test: WorkerClientTests.ShutdownAsync_WhenOutboundQueueIsFullBehindStalledPipeWrite_TimesOutAndKillsWorker
stalls the gateway-side stream (a worker that stopped reading), fills the
outbound channel with abandoned commands and their cancels, and asserts that
ShutdownAsync throws ShutdownTimeout inside the test bound and kills the fake
worker. It hung past the bound before the fix.
- eventQueueCapacity option (default 1024, was hard-coded 16)
- overflow retains buffered events and delivers the error after them
- interrupt cancels the gRPC call and restores the interrupt flag
- ping/getSessionState/getWorkerInfo/drainEvents on MxGatewaySession
Address review of the #164-#166 fixes:
- The HRESULT check could not tell interop from programming errors
(a managed ArgumentException already defaults to 0x80070057). Wrap each
MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow
filter that converts an interop failure to a private marker exception,
and catch only that marker, so exceptions from the writer's own code
(argument checks, caching, the pump) propagate.
- A late OnWriteComplete for a dropped handle recreated the cache entry
that DropItem removed. The writer now records completions only for item
handles it holds on its live registration.
- The item-handle-reuse probe now asserts that handles within one
registration strictly increase, so a rerun is a regression check.
Refs #164, #165, #166
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result
(2026-10-20): within one MXAccess registration, item handles increase
monotonically and are never reused across remove/re-add, so the ack
writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle
generation counter is needed. Numbering restarts only on a new
registration, which teardown already isolates. Document the guarantee at
the Dirty re-add and in AlarmProbeFindings.md.
Fixes#166
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Add MxAccessWriteCompletionCache.Remove and RemoveServer and call them
from AlarmAckWriter when it drops an item (dirty re-add, LRU eviction,
failed AddItem/Advise/Write) and when it tears down its registration, so
the cache no longer keeps one entry per dropped handle for the life of
the worker.
Fixes#165
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
Resolve the operator user, password and comment before the interop try
blocks, and match ArgumentException as an MXAccess failure only when it
carries E_INVALIDARG and is not ArgumentNullException or
ArgumentOutOfRangeException. A programming error inside the try blocks
now propagates instead of being reported as an authentication or write
failure.
Fixes#164
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
- AlarmAckWriter: classify AuthenticateUser and AddItem/Advise/Write
failures by HRESULT for any interop exception (ArgumentException for
E_INVALIDARG, UnauthorizedAccessException, COMException); server-gone
HRESULTs at AuthenticateUser tear down the registration and report
WriteRejected.
- Bound the ack item cache with a 256-entry LRU (eviction best-effort
UnAdvise+RemoveItem).
- After an Unconfirmed ack, drop and re-add the item before the next
write so a late OnWriteComplete on the old handle is not credited.
- Pass StaRuntime.PumpPendingMessages to the ack writer through a
pump-aware alarm handler factory so long completion waits refresh the
STA watchdog heartbeat.
- Rust CLI: --operator is required for acknowledge-alarm.
- Docs: full ack status table, credential-oracle trade-off, upgrade note.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT