Compare commits

...

10 Commits

Author SHA1 Message Date
Joseph Doherty b2d8dd70ed chore(plans): Phase B implementation complete; as-built note for Task 17
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m12s
ci / java (push) Successful in 2m26s
ci / portable (push) Successful in 7m56s
2026-08-15 17:35:05 -04:00
Joseph Doherty 58d97ad4e8 perf(worker): memoize event frame size at enqueue; drain stops sizing under the STA's lock 2026-08-15 17:27:47 -04:00
Joseph Doherty b5ea6bb461 fix(alarms): fetch/poll ceilings; truncation-semantics docs; log-format conformance 2026-08-15 17:19:41 -04:00
Joseph Doherty 7c9add3d73 fix(worker): StaWaitHelper regression tests + full-drain contract notes; consolidate wait P/Invoke 2026-08-15 17:17:10 -04:00
Joseph Doherty 896d81e286 docs(worker): record the SemaphoreSlim/STA dispatch invariant + single-waiter contract; harden fake 2026-08-15 17:13:17 -04:00
Joseph Doherty 25cbe5cd3e fix(worker): guard timestamp-format derivation against pathological culture patterns 2026-08-15 17:10:58 -04:00
Joseph Doherty 13583322b5 perf(worker): launcher-configurable event queue capacity 2026-08-15 17:10:54 -04:00
Joseph Doherty f3e1de5f37 fix(alarms): truncation-safe transitions; perf: single-pass alarm parse; configurable poll cadence 2026-08-15 17:04:06 -04:00
Joseph Doherty 94fdc18c3c perf(worker): exact-format timestamp parse and compiled status-field accessors on the event path 2026-08-15 16:59:52 -04:00
Joseph Doherty f4a6cb1db2 perf(worker): signal-driven event drain — removes the 25 ms latency floor and idle wakeups 2026-08-15 16:59:13 -04:00
36 changed files with 3217 additions and 206 deletions
+20
View File
@@ -140,6 +140,26 @@ Two viable A.2 designs given the probe data:
poll period; modest CPU floor because the call is cheap. Matches
the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a
poll loop internally.
As shipped, this is the chosen design, and the cadence is **no
longer fixed at 500 ms**: it is the 500 ms *default* of
`MxGateway:Alarms:PollIntervalMilliseconds` (range 100 ms – 1 h),
which the gateway hands the worker through the
`MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable. The
per-fetch cap is likewise configurable
(`MxGateway:Alarms:MaxAlarmsPerFetch`, default 1024).
One snapshot rule matters when reading the capture below: a fetch
that returns exactly the cap is treated as **truncated**, and the
worker *merges* it into the retained snapshot instead of replacing
it. `GetXmlCurrentAlarms2` caps its reply with no "more available"
flag, so a capped reply is authoritative about presence only —
alarms it had no room to mention are retained rather than allowed
to vanish, because their disappearance is what the gateway's
reconcile pass reads as a clear. Only a sub-cap fetch replaces the
snapshot wholesale and can therefore clear alarms. See
`docs/DesignDecisions.md`, "Alarms — a capped snapshot fetch never
implies a clear".
2. **Hook AVEVA's internal window.** Discover AVEVA's own window
(`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or
`SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's
+70
View File
@@ -135,6 +135,76 @@ alarm state is gateway-wide, not session-scoped — every client wants the same
current set plus updates, and forcing each to own a worker would multiply AVEVA
polling load for no benefit.
### Alarms — a capped snapshot fetch never implies a clear
Decision (2026-08-15): when the worker's `GetXmlCurrentAlarms2` fetch comes back
holding exactly `MxGateway:Alarms:MaxAlarmsPerFetch` records, the worker treats
the snapshot as **truncated** and merges it into the retained snapshot instead
of replacing it. Alarms the capped reply did carry update normally; alarms it
had no room to mention are retained untouched.
The COM API caps its reply at `maxAlmCnt` and exposes no "more available" flag,
so a reply sitting exactly on the cap is indistinguishable from a galaxy that
happens to hold exactly that many active alarms. Both are treated as truncated,
because the two error directions are not symmetric.
Nothing in the worker emits a Clear transition. The clear is an **inference**:
`WnWrapAlarmConsumer.ComputeTransitions` produces no transition for an alarm
that disappears from the snapshot, and `GatewayAlarmMonitor.ApplyReconcile`
later diffs its cache against `SnapshotActiveAlarms()` and broadcasts a Clear
for every cached alarm the worker no longer reports. Before this decision, a
capped fetch shrank that snapshot, so every alarm past the cap was broadcast as
cleared while still standing — a silent, galaxy-wide false clear on exactly the
alarm floods where the cap is reached.
Consequences, and how this sits with the existing failover/reconcile design:
- **The suppression is an eviction guard, not a transition filter.** It lives in
the snapshot update inside `PollOnce`, not in `ComputeTransitions`, which was
never going to emit anything for a disappearance. The reconcile/dedup
machinery (`_clearedByReconcile` tombstones, the NEXT-03 duplicate-Clear
suppression) is untouched: it still sees the same shape of snapshot, only
with the truncated poll's unmentionable alarms still present.
- **It preserves at-least-once, idempotent application.** The failure mode
becomes bounded staleness — a genuinely cleared alarm can linger until the
first sub-cap fetch evicts it, and the reconcile then broadcasts its Clear
late. A late Clear is repaired by the next complete poll; a Clear that never
happened is broadcast to every `StreamAlarms` subscriber and cannot be taken
back. Consumers already apply transitions as "set this alarm to this state",
so a repeated or delayed Clear is absorbed.
- **Under *sustained* truncation, some intermediate history is lost — end state
is not.** For an alarm that stays outside the fetch window, a full
clear→re-raise cycle that begins and ends between two sightings emits **no
transitions at all**: the retained record is identical before and after, so
the diff sees nothing to report. Consumers that render current state are
correct; consumers that *count occurrences* lose an event. Likewise, an
operator acknowledgement of an out-of-window alarm does not reach the feed
until that alarm re-enters a fetch window, at which point the reconcile
repairs the acked state. This is a strictly better failure than the
pre-guard behaviour (which fabricated a Clear for every out-of-window alarm
on every poll), but it is not lossless, and it is another reason a
persistently truncating deployment is a configuration defect to fix rather
than a mode to run in.
- **It does not synthesize anything.** Suppressing an inference is the opposite
of inventing an event; no transition is fabricated on a truncated poll.
- **Failover is unaffected.** `FailoverAlarmConsumer` selects which
`IMxAccessAlarmConsumer` is live; the guard is internal to the wnwrap
consumer's own snapshot bookkeeping and changes neither the failure counting
that triggers failover nor the subtag standby's snapshot, which is built from
a bounded watch-list and has no per-fetch cap to hit.
- **Operators get told, weakly.** A truncated poll logs a rate-limited (once
per minute) `AlarmSnapshotTruncated` warning carrying the cap, the record
counts, and the running truncated-fetch total — identifiers and counts only,
never tag names, values, limits, or comments. Be honest about its reach: it
goes to the worker's console/stderr, which is captured on dev hosts but is
not a metric, not a dashboard tile, and not part of any session-status or
alarm-feed payload, so a production deployment can truncate indefinitely
without anyone noticing. Surfacing truncation as a **structural** degraded
status (a field on the alarm-provider mode/status surface the dashboard and
`StreamAlarms` consumers already read) is filed as a follow-up; until it
lands, the log line is the only signal. A galaxy that truncates persistently
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
## Session-Resilience Epic Scope
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
+3
View File
@@ -115,6 +115,7 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
| `MxGateway:Worker:EventQueueCapacity` | `10000` | Capacity, in events, of the worker's outbound MXAccess event queue. Must be between `1000` and `1000000`. This is burst headroom, not a throttle: the queue has no drop policy, so filling it records a `QueueOverflow` worker fault and faults the session. Raise it for sessions whose subscription set can outrun the drain loop (large advise sets, slow event consumers); the backing queue pre-allocates its slots, so the ceiling keeps a mistyped value from committing the 32-bit worker to an outsized allocation. The gateway conveys the value to the worker via the `MXGATEWAY_EVENT_QUEUE_CAPACITY` environment variable; a missing or unusable value leaves the worker on the 10000 default rather than failing the session. |
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
@@ -417,6 +418,8 @@ behavior.
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. |
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. |
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor.
+18 -2
View File
@@ -295,6 +295,14 @@ cache lock) so a cross-thread producer wakes the waiter immediately; in the live
worker the update arrives on the STA from inside `pumpStep` itself, and the
message wake is what carries it.
`MWMO_INPUTAVAILABLE` makes the drain contract load-bearing: the wait wakes on
input that is merely *present*, including input an earlier `PeekMessage` saw but
did not remove. A `pumpStep` that drains only part of the queue — or a no-op one
— therefore leaves a message that satisfies the wake condition forever, and the
loop spins at 100% CPU until its deadline (deadline and reply shape still hold;
it is a CPU fault, not a correctness one). Every `pumpStep` must drain to empty,
as `StaRuntime.PumpPendingMessages` does.
The wait slice is capped at 50 ms so `pumpStep` runs periodically even when
nothing wakes the wait — a process with no STA message queue (unit tests drive
these caches from ordinary threads, standing in for the STA by updating the
@@ -416,7 +424,11 @@ type on buffered events. `OperationComplete` is only emitted from the native
`MxAccessEventQueue` is the bounded outbound event queue for one worker
session. It assigns the monotonic `WorkerSequence` and `WorkerTimestamp` when an
event is accepted, preserving the order in which MXAccess handlers enqueue
events. The default capacity is `10000`. When the queue reaches capacity it
events. The capacity is `10000` by default and comes from
`MxGateway:Worker:EventQueueCapacity`, which the gateway stamps onto the worker
launch environment as `MXGATEWAY_EVENT_QUEUE_CAPACITY`; a missing, unparseable,
or out-of-range value (outside `1000`–`1000000`) leaves the worker on the
default rather than failing the session. When the queue reaches capacity it
records a `WorkerFaultCategory.QueueOverflow` fault and rejects further events.
The event handler catches conversion and enqueue failures, records the first
fault on the queue, and returns to the STA message pump instead of writing to
@@ -529,7 +541,11 @@ is bounded on **two** axes because no diagnostics command may be session-fatal:
maximum less a 64 KiB envelope/reply-wrapper reserve, and the size decision
happens inside the event queue's lock, so an event is dequeued only once it is
known to fit. An event that does not fit stays at the head of the queue and is
never lost.
never lost. Each event's serialized size is *measured* once at enqueue, outside
that lock, and stored beside it: the drain only compares memoized numbers, so a
large drain never walks messages under the lock the STA needs to enqueue the
next COM callback. The memoized size cannot go stale because an enqueued event
is never mutated again (WRK-11).
Truncation is reported in the reply's existing `DiagnosticMessage`
("N events returned, M remain; repeat DrainEvents for the rest") rather than in a
@@ -438,6 +438,8 @@ Commit: `perf(worker): signal-driven event drain — removes the 25 ms latency f
**Implement:** copy the `WriteCompletionWaitMilliseconds` plumbing end to end under a new name (`MXGW_EVENT_QUEUE_CAPACITY` shaped like the existing variable's naming). Absent/invalid env value → default 10000 (never crash the worker on a bad value; log and default).
> **As-built note (1358332):** shipped as silent default without logging, matching the alarm-resolver precedent — no `ILogger` is reachable from the static resolve site without new plumbing; the silent fallback is disclosed in `GatewayConfiguration.md`. The Bootstrap parser files listed above were correctly NOT touched — the established env-var pattern reads `Environment.GetEnvironmentVariable` at the resolve site.
Note the gateway-side files here don't overlap Phase A tasks — safe after Task 15.
Commit: `perf(worker): launcher-configurable event queue capacity`
@@ -1,31 +1,188 @@
{
"planPath": "docs/plans/2026-08-15-perf-review-remediation.md",
"tasks": [
{"id": 1, "subject": "Task 1: Named-pipe buffer sizes", "status": "completed"},
{"id": 2, "subject": "Task 2: Metrics pull-gauge + Interlocked counters", "status": "completed"},
{"id": 3, "subject": "Task 3: Distributor copy-on-write subscriber snapshot", "status": "completed"},
{"id": 4, "subject": "Task 4: Dashboard event mirror viewer gating", "status": "completed"},
{"id": 5, "subject": "Task 5: Snapshot pipeline idle gating + cached config", "status": "completed"},
{"id": 6, "subject": "Task 6: Reply ownership transfer in MapCommandReply", "status": "completed"},
{"id": 7, "subject": "Task 7: WorkerClient WaitAsync timeout + WorkerCancel", "status": "completed", "blockedBy": [2]},
{"id": 8, "subject": "Task 8: Audit pipeline background writer + retention", "status": "completed"},
{"id": 9, "subject": "Task 9: Parallel session teardown", "status": "completed"},
{"id": 10, "subject": "Task 10: Dashboard live-data subscription cap", "status": "completed"},
{"id": 11, "subject": "Task 11: Alarm monitor cached CurrentAlarms", "status": "completed"},
{"id": 12, "subject": "Task 12: Logging middleware hoist + bearer redaction fix", "status": "completed"},
{"id": 13, "subject": "Task 13: Auth-path span parsing + limiter keys", "status": "completed"},
{"id": 14, "subject": "Task 14: Bulk constraint loops, caches, hygiene", "status": "completed"},
{"id": 15, "subject": "Task 15: Phase A gate — full gateway suite", "status": "in_progress", "blockedBy": [1,2,3,4,5,6,7,8,9,10,11,12,13,14]},
{"id": 16, "subject": "Task 16: Event drain wake signal", "status": "pending", "blockedBy": [15]},
{"id": 17, "subject": "Task 17: Event queue capacity env plumbing", "status": "pending", "blockedBy": [16]},
{"id": 18, "subject": "Task 18: STA message-driven completion waits", "status": "pending", "blockedBy": [15]},
{"id": 19, "subject": "Task 19: Handle registry reverse index + O(1) removals", "status": "pending", "blockedBy": [15]},
{"id": 20, "subject": "Task 20: Event conversion TryParseExact + compiled accessors", "status": "pending", "blockedBy": [15]},
{"id": 21, "subject": "Task 21: Drain size memoized at enqueue", "status": "pending", "blockedBy": [16, 17]},
{"id": 22, "subject": "Task 22: Worker frame writer/reader pooled buffers", "status": "pending", "blockedBy": [15]},
{"id": 23, "subject": "Task 23: Alarm consumer parse + truncation + cadence", "status": "pending", "blockedBy": [15]},
{"id": 24, "subject": "Task 24: Phase B verification on windev (psbridge)", "status": "pending", "blockedBy": [16,17,18,19,20,21,22,23]},
{"id": 25, "subject": "Task 25: Wrap-up docs sweep + follow-ups", "status": "pending", "blockedBy": [24]}
{
"id": 1,
"subject": "Task 1: Named-pipe buffer sizes",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: Metrics pull-gauge + Interlocked counters",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: Distributor copy-on-write subscriber snapshot",
"status": "completed"
},
{
"id": 4,
"subject": "Task 4: Dashboard event mirror viewer gating",
"status": "completed"
},
{
"id": 5,
"subject": "Task 5: Snapshot pipeline idle gating + cached config",
"status": "completed"
},
{
"id": 6,
"subject": "Task 6: Reply ownership transfer in MapCommandReply",
"status": "completed"
},
{
"id": 7,
"subject": "Task 7: WorkerClient WaitAsync timeout + WorkerCancel",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 8,
"subject": "Task 8: Audit pipeline background writer + retention",
"status": "completed"
},
{
"id": 9,
"subject": "Task 9: Parallel session teardown",
"status": "completed"
},
{
"id": 10,
"subject": "Task 10: Dashboard live-data subscription cap",
"status": "completed"
},
{
"id": 11,
"subject": "Task 11: Alarm monitor cached CurrentAlarms",
"status": "completed"
},
{
"id": 12,
"subject": "Task 12: Logging middleware hoist + bearer redaction fix",
"status": "completed"
},
{
"id": 13,
"subject": "Task 13: Auth-path span parsing + limiter keys",
"status": "completed"
},
{
"id": 14,
"subject": "Task 14: Bulk constraint loops, caches, hygiene",
"status": "completed"
},
{
"id": 15,
"subject": "Task 15: Phase A gate \u2014 full gateway suite",
"status": "completed",
"blockedBy": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14
]
},
{
"id": 16,
"subject": "Task 16: Event drain wake signal",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 17,
"subject": "Task 17: Event queue capacity env plumbing",
"status": "completed",
"blockedBy": [
16
]
},
{
"id": 18,
"subject": "Task 18: STA message-driven completion waits",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 19,
"subject": "Task 19: Handle registry reverse index + O(1) removals",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 20,
"subject": "Task 20: Event conversion TryParseExact + compiled accessors",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 21,
"subject": "Task 21: Drain size memoized at enqueue",
"status": "completed",
"blockedBy": [
16,
17
]
},
{
"id": 22,
"subject": "Task 22: Worker frame writer/reader pooled buffers",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 23,
"subject": "Task 23: Alarm consumer parse + truncation + cadence",
"status": "completed",
"blockedBy": [
15
]
},
{
"id": 24,
"subject": "Task 24: Phase B verification on windev (psbridge)",
"status": "in_progress",
"blockedBy": [
16,
17,
18,
19,
20,
21,
22,
23
]
},
{
"id": 25,
"subject": "Task 25: Wrap-up docs sweep + follow-ups",
"status": "pending",
"blockedBy": [
24
]
}
],
"lastUpdated": "2026-08-15T13:30:00Z"
}
"lastUpdated": "2026-08-15T18:05:00Z"
}
@@ -46,6 +46,33 @@ public sealed class AlarmsOptions
/// </summary>
public int ReconcileIntervalSeconds { get; init; } = 30;
/// <summary>
/// Cadence at which the worker's STA polls the AVEVA alarm consumer
/// (<c>GetXmlCurrentAlarms2</c>) for the current active-alarm snapshot.
/// Default 500 ms; must be between 100 ms and 3,600,000 ms (one hour).
/// Every poll is a COM call plus an XML parse on the STA that also
/// serves reads and writes, so driving it below 100 ms starves the
/// command path; above an hour the cadence stops being a cadence and
/// silently disables alarm polling. Conveyed to the worker through the
/// <c>MXGATEWAY_ALARM_POLL_INTERVAL_MS</c> environment variable.
/// </summary>
public int PollIntervalMilliseconds { get; init; } = 500;
/// <summary>
/// Cap the worker passes to <c>GetXmlCurrentAlarms2</c>'s
/// <c>maxAlmCnt</c> argument. Default 1024; must be between 64 and
/// 65,536 — the worker is a 32-bit process that materializes each
/// fetch as one BSTR plus a full XmlDocument, so an unbounded cap
/// faults the STA rather than merely slowing it. A fetch that comes
/// back holding exactly this many records is treated as truncated: the
/// worker keeps the alarms the capped fetch could not mention in its
/// snapshot rather than letting their absence read as a clear. Raise it
/// on galaxies whose steady-state active-alarm count approaches the
/// cap. Conveyed to the worker through the
/// <c>MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH</c> environment variable.
/// </summary>
public int MaxAlarmsPerFetch { get; init; } = 1024;
/// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
@@ -10,6 +10,12 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
// Bounds on the worker's outbound event-queue capacity. The floor keeps enough headroom that a
// normal subscription burst cannot overflow the queue (an overflow faults the whole session);
// the ceiling keeps a mistyped value from committing the x86 worker to an unbounded backlog.
private const int MinimumWorkerEventQueueCapacity = 1000;
private const int MaximumWorkerEventQueueCapacity = 1_000_000;
// Whether the host is running in the Production environment. Drives the production-only
// hard-stops (dashboard login disabled, plaintext LDAP transport) that must abort startup
// rather than merely warn. Non-production hosts keep the permissive dev posture.
@@ -275,6 +281,12 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
}
if (options.EventQueueCapacity is < MinimumWorkerEventQueueCapacity or > MaximumWorkerEventQueueCapacity)
{
builder.Add(
$"MxGateway:Worker:EventQueueCapacity must be between {MinimumWorkerEventQueueCapacity} and {MaximumWorkerEventQueueCapacity}.");
}
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{
builder.Add(
@@ -414,8 +426,38 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
private const int MinimumAlarmPollIntervalMilliseconds = 100;
// One hour. Above this the cadence stops being a cadence: int.MaxValue
// milliseconds is ~24 days, which silently disables alarm polling instead
// of reporting the misconfiguration.
private const int MaximumAlarmPollIntervalMilliseconds = 3_600_000;
private const int MinimumMaxAlarmsPerFetch = 64;
// The worker is a 32-bit process and materializes each fetch as one BSTR
// plus a full XmlDocument over it, so an unbounded cap is an out-of-memory
// fault on the STA rather than a slow poll.
private const int MaximumMaxAlarmsPerFetch = 65_536;
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
{
// Validated regardless of Enabled: both values are stamped onto every
// worker launch environment, so a bad value is a misconfiguration even
// before the central monitor is switched on.
if (options.PollIntervalMilliseconds is < MinimumAlarmPollIntervalMilliseconds
or > MaximumAlarmPollIntervalMilliseconds)
{
builder.Add(
$"MxGateway:Alarms:PollIntervalMilliseconds must be between {MinimumAlarmPollIntervalMilliseconds} and {MaximumAlarmPollIntervalMilliseconds}.");
}
if (options.MaxAlarmsPerFetch is < MinimumMaxAlarmsPerFetch or > MaximumMaxAlarmsPerFetch)
{
builder.Add(
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
}
if (!options.Enabled)
{
return;
@@ -33,6 +33,18 @@ public sealed class WorkerOptions
/// </summary>
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
/// <summary>
/// Capacity of the worker's outbound MXAccess event queue, in events.
/// Default 10,000; must be between 1,000 and 1,000,000. This is
/// headroom, not a throttle: the queue has no drop policy, so a burst
/// that fills it faults the session with a <c>QueueOverflow</c> worker
/// fault. Raise it for sessions whose subscription set can outrun the
/// drain loop (large advise sets, slow event consumers). Conveyed to
/// the worker through the <c>MXGATEWAY_EVENT_QUEUE_CAPACITY</c>
/// environment variable.
/// </summary>
public int EventQueueCapacity { get; init; } = 10000;
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
public int ShutdownTimeoutSeconds { get; init; } = 10;
@@ -29,11 +29,34 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
/// <summary>
/// Conveys MxGateway:Alarms:PollIntervalMilliseconds to the worker: the
/// cadence at which the worker's STA polls the AVEVA alarm consumer.
/// </summary>
public const string WorkerAlarmPollIntervalEnvironmentVariableName =
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
/// <summary>
/// Conveys MxGateway:Alarms:MaxAlarmsPerFetch to the worker: the cap
/// passed to GetXmlCurrentAlarms2, which is also the record count at
/// which the worker treats a fetch as truncated.
/// </summary>
public const string WorkerMaxAlarmsPerFetchEnvironmentVariableName =
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <summary>
/// Conveys MxGateway:Worker:EventQueueCapacity to the worker: the capacity
/// of the outbound MXAccess event queue, whose overflow faults the session.
/// </summary>
public const string WorkerEventQueueCapacityEnvironmentVariableName =
"MXGATEWAY_EVENT_QUEUE_CAPACITY";
private readonly IWorkerProcessFactory _processFactory;
private readonly IWorkerStartupProbe _startupProbe;
private readonly GatewayMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly WorkerOptions _workerOptions;
private readonly AlarmsOptions _alarmsOptions;
private readonly ILogger<WorkerProcessLauncher> _logger;
/// <summary>
@@ -59,6 +82,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
ArgumentNullException.ThrowIfNull(metrics);
_workerOptions = gatewayOptions.Value.Worker;
_alarmsOptions = gatewayOptions.Value.Alarms;
_processFactory = processFactory;
_startupProbe = startupProbe;
_metrics = metrics;
@@ -185,6 +209,12 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerEventQueueCapacityEnvironmentVariableName] =
_workerOptions.EventQueueCapacity.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerAlarmPollIntervalEnvironmentVariableName] =
_alarmsOptions.PollIntervalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerMaxAlarmsPerFetchEnvironmentVariableName] =
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
@@ -86,7 +86,9 @@
"Enabled": true,
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
"DefaultArea": "",
"ReconcileIntervalSeconds": 30
"ReconcileIntervalSeconds": 30,
"PollIntervalMilliseconds": 500,
"MaxAlarmsPerFetch": 1024
}
}
}
@@ -163,6 +163,121 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
{
AlarmsOptions alarms = new();
Assert.Equal(500, alarms.PollIntervalMilliseconds);
Assert.Equal(1024, alarms.MaxAlarmsPerFetch);
ValidateOptionsResult result = new GatewayOptionsValidator()
.Validate(null, CloneWithAlarms(ValidOptions(), alarms));
Assert.True(result.Succeeded);
}
/// <summary>
/// A poll cadence outside the 100 ms – 1 h range must fail validation.
/// Both values are stamped onto every worker launch environment, so
/// they are validated whether or not the central alarm monitor is
/// enabled.
/// </summary>
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[Theory]
[InlineData(99, false)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(99, true)]
// Above the one-hour ceiling the cadence stops being a cadence: int.MaxValue
// milliseconds is ~24 days, which silently disables alarm polling.
[InlineData(3_600_001, false)]
[InlineData(int.MaxValue, false)]
[InlineData(int.MaxValue, true)]
public void Validate_Fails_WhenAlarmPollIntervalOutOfRange(
int pollIntervalMilliseconds,
bool alarmsEnabled)
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = alarmsEnabled,
DefaultArea = "Galaxy",
PollIntervalMilliseconds = pollIntervalMilliseconds,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Alarms:PollIntervalMilliseconds", StringComparison.Ordinal));
}
/// <summary>
/// A per-fetch cap outside the 64 – 65,536 range must fail validation.
/// The cap doubles as the truncation-detection threshold in the worker,
/// so a tiny cap would make almost every fetch read as truncated; and
/// the worker is a 32-bit process that materializes each reply as one
/// BSTR plus a full XmlDocument, so an unbounded cap is an
/// out-of-memory fault on the STA rather than a slow poll.
/// </summary>
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[Theory]
[InlineData(63, false)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(63, true)]
[InlineData(65_537, false)]
[InlineData(int.MaxValue, false)]
[InlineData(int.MaxValue, true)]
public void Validate_Fails_WhenMaxAlarmsPerFetchOutOfRange(
int maxAlarmsPerFetch,
bool alarmsEnabled)
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = alarmsEnabled,
DefaultArea = "Galaxy",
MaxAlarmsPerFetch = maxAlarmsPerFetch,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Alarms:MaxAlarmsPerFetch", StringComparison.Ordinal));
}
/// <summary>Verifies the boundary values themselves are accepted at both ends.</summary>
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
[Theory]
[InlineData(100, 64)] // floors
[InlineData(3_600_000, 65_536)] // ceilings
public void Validate_Succeeds_AtAlarmPollCadenceAndFetchCapBoundaries(
int pollIntervalMilliseconds,
int maxAlarmsPerFetch)
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = true,
DefaultArea = "Galaxy",
PollIntervalMilliseconds = pollIntervalMilliseconds,
MaxAlarmsPerFetch = maxAlarmsPerFetch,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
[Fact]
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
@@ -1002,4 +1117,59 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MaxMessageBytes") && f.Contains("reserve"));
}
/// <summary>
/// Verifies the shipped worker event-queue capacity default passes validation and still matches
/// the worker-side <c>MxAccessEventQueue.DefaultCapacity</c> the environment variable falls back
/// to when the launcher value is unusable.
/// </summary>
[Fact]
public void Validate_Succeeds_WithDefaultEventQueueCapacity()
{
Assert.Equal(10000, new WorkerOptions().EventQueueCapacity);
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies a worker event-queue capacity outside the supported range fails validation. Too
/// small leaves no burst headroom (an overflow faults the whole session); too large commits the
/// 32-bit worker to an outsized pre-allocation.
/// </summary>
/// <param name="eventQueueCapacity">Capacity under test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(999)]
[InlineData(1_000_001)]
public void Validate_Fails_WhenEventQueueCapacityOutOfRange(int eventQueueCapacity)
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
null,
WithWorkerAndProtocol(
new WorkerOptions { EventQueueCapacity = eventQueueCapacity },
new ProtocolOptions()));
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Worker:EventQueueCapacity", StringComparison.Ordinal));
}
/// <summary>Verifies the worker event-queue capacity bounds themselves are accepted.</summary>
/// <param name="eventQueueCapacity">Capacity under test.</param>
[Theory]
[InlineData(1000)]
[InlineData(1_000_000)]
public void Validate_Succeeds_AtEventQueueCapacityBounds(int eventQueueCapacity)
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
null,
WithWorkerAndProtocol(
new WorkerOptions { EventQueueCapacity = eventQueueCapacity },
new ProtocolOptions()));
Assert.True(result.Succeeded);
}
}
@@ -47,12 +47,56 @@ public sealed class WorkerProcessLauncherTests
"1500",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
// The worker sizes its outbound event queue from the launch environment;
// the queue has no drop policy, so this capacity is the session's burst
// headroom rather than a throttle.
Assert.Equal(
"10000",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerEventQueueCapacityEnvironmentVariableName]);
// MxGateway:Alarms defaults reach the worker's alarm poll loop and its
// GetXmlCurrentAlarms2 cap (which is also its truncation threshold)
// through the launch environment, not the command line.
Assert.Equal(
"500",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerAlarmPollIntervalEnvironmentVariableName]);
Assert.Equal(
"1024",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerMaxAlarmsPerFetchEnvironmentVariableName]);
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
Assert.False(pipeReservation.DisposeCalled);
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
}
/// <summary>
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — not the shipped
/// default — is what reaches the worker, so the option is deployable without a worker rebuild.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LaunchAsync_WithConfiguredEventQueueCapacity_ExportsItToTheWorkerEnvironment()
{
using TestDirectory directory = TestDirectory.Create();
string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
FakeWorkerProcessFactory processFactory = new(new FakeWorkerProcess(processId: 1234));
WorkerProcessLauncher launcher = CreateLauncher(
executablePath,
processFactory,
new SucceedingStartupProbe(),
eventQueueCapacity: 65536);
using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest());
Assert.NotNull(processFactory.LastStartInfo);
Assert.Equal(
"65536",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerEventQueueCapacityEnvironmentVariableName]);
}
/// <summary>Verifies that a failed startup probe kills and disposes the worker process.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -205,7 +249,8 @@ public sealed class WorkerProcessLauncherTests
GatewayMetrics? metrics = null,
int startupTimeoutSeconds = 30,
int startupProbeRetryAttempts = 3,
int startupProbeRetryDelayMilliseconds = 250)
int startupProbeRetryDelayMilliseconds = 250,
int eventQueueCapacity = 10000)
{
GatewayOptions options = new()
{
@@ -216,6 +261,7 @@ public sealed class WorkerProcessLauncherTests
StartupTimeoutSeconds = startupTimeoutSeconds,
StartupProbeRetryAttempts = startupProbeRetryAttempts,
StartupProbeRetryDelayMilliseconds = startupProbeRetryDelayMilliseconds,
EventQueueCapacity = eventQueueCapacity,
},
};
@@ -135,6 +135,90 @@ public sealed class MxStatusProxyConverterTests
Assert.Equal("Invalid reference", second.DiagnosticText);
}
/// <summary>
/// Verifies every accessor strategy converts the same status values
/// identically. PERF-20 reads a public field of a publicly visible type
/// through a delegate compiled from an expression tree instead of
/// <c>FieldInfo.GetValue</c>; a field type whose conversion to int is not
/// a lossless widening (<see cref="FakeWideStatusProxy"/>) and a type the
/// compiled delegate may not touch (<see cref="HiddenStatusProxy"/>, not
/// visible outside this assembly) keep the reflection read. All four
/// doubles below carry the same logical status, so all four messages must
/// be equal.
/// </summary>
[Fact]
public void Convert_AcrossAccessorStrategies_ProducesIdenticalMessages()
{
// Struct, widening field types: the compiled accessor unboxes in place.
MxStatusProxy compiledStruct = _converter.Convert(new FakeMxStatusProxy
{
success = 1,
category = 5,
detectedBy = 3,
detail = 21,
});
// Reference type, widening field types: the compiled accessor casts.
MxStatusProxy compiledClass = _converter.Convert(new FakeStatusProxyClass
{
success = 1,
category = 5,
detectedBy = 3,
detail = 21,
});
// Non-widening field types: reflection + Convert.ToInt32 is kept.
MxStatusProxy reflectedWide = _converter.Convert(new FakeWideStatusProxy
{
success = 1L,
category = 5L,
detectedBy = 3L,
detail = 21L,
});
// Type not visible outside the assembly: reflection is kept.
MxStatusProxy reflectedHidden = _converter.Convert(new HiddenStatusProxy
{
success = 1,
category = 5,
detectedBy = 3,
detail = 21,
});
Assert.Equal(compiledStruct, compiledClass);
Assert.Equal(compiledStruct, reflectedWide);
Assert.Equal(compiledStruct, reflectedHidden);
Assert.Equal(MxStatusCategory.OperationalError, compiledStruct.Category);
Assert.Equal(MxStatusSource.RespondingNmx, compiledStruct.DetectedBy);
Assert.Equal("Invalid reference", compiledStruct.DiagnosticText);
}
/// <summary>
/// Verifies the compiled accessor widens a negative 16-bit field the way
/// <c>Convert.ToInt32</c> did — sign-extended, not reinterpreted. The
/// interop MXSTATUS_PROXY declares 16-bit fields, so a sign-extension
/// mistake here would silently change every failing status the gateway
/// reports.
/// </summary>
[Fact]
public void Convert_WithExtremeInt16FieldValues_SignExtendsLikeConvertToInt32()
{
FakeMxStatusProxy status = new()
{
success = short.MinValue,
category = 2,
detectedBy = 0,
detail = short.MaxValue,
};
MxStatusProxy converted = _converter.Convert(status);
Assert.Equal((int)short.MinValue, converted.Success);
Assert.Equal((int)short.MaxValue, converted.Detail);
Assert.Equal(MxStatusCategory.Warning, converted.Category);
Assert.Equal(MxStatusSource.RequestingLmx, converted.DetectedBy);
}
public struct FakeMxStatusProxy
{
public short success;
@@ -146,6 +230,39 @@ public sealed class MxStatusProxyConverterTests
public short detail;
}
public struct FakeWideStatusProxy
{
public long success;
public long category;
public long detectedBy;
public long detail;
}
public sealed class FakeStatusProxyClass
{
public int success;
public int category;
public int detectedBy;
public int detail;
}
private struct HiddenStatusProxy
{
public short success;
public int category;
public int detectedBy;
public short detail;
}
private sealed class MissingFields
{
}
@@ -311,6 +311,61 @@ public sealed class WorkerPipeSessionTests
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
/// <summary>
/// The event drain loop waits on the runtime's wake signal instead of sleeping a fixed tick,
/// so an event enqueued at an idle worker is framed as soon as it is enqueued rather than up
/// to <c>EventDrainInterval</c> later. The fake's wait honours only the signal here, so the
/// event reaching the pipe is proof the enqueue woke the loop — a poll-driven loop would
/// never run again, and the test would fail on its cancellation deadline instead of passing
/// on a fallback tick that happened to fire. The loop is left parked on that wait before the
/// enqueue, which also makes the recorded fallback ceiling assertable.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_EventAfterIdle_DrainLoopWakesOnSignalNotOnPollTick()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
FakeRuntimeSession runtime = new()
{
WaitForEventsOnSignalOnly = true,
};
// A far-off heartbeat interval keeps the drain loop the only thing that can produce a frame
// after the first beat, so nothing else can mask a drain loop that never woke.
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
new WorkerPipeSessionOptions
{
HeartbeatInterval = TimeSpan.FromMinutes(5),
HeartbeatGrace = TimeSpan.FromSeconds(30),
});
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
// Park the drain loop: it drains empty once and then waits. Enqueuing before it parks would
// let the first drain pass find the event, which proves nothing about the wake.
while (runtime.LastWaitForEventsTimeout is null)
{
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellation.Token);
}
runtime.EnqueueEvent(CreateWorkerEvent(sequence: 7));
WorkerEnvelope workerEvent = await ReadUntilAsync(
pipePair.GatewayReader,
WorkerEnvelope.BodyOneofCase.WorkerEvent,
cancellation.Token);
Assert.Equal(7UL, workerEvent.WorkerEvent.Event.WorkerSequence);
// The 25 ms survives as the ceiling the loop passes to every wait, not as a poll period.
Assert.Equal(TimeSpan.FromMilliseconds(25), runtime.LastWaitForEventsTimeout);
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
/// <summary>
/// Verifies that a Ping control command is answered on the worker side
/// (not dispatched to the STA) with an OK reply that echoes the ping
@@ -238,6 +238,146 @@ public sealed class MxAccessEventMapperTests
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
}
/// <summary>
/// Verifies the exact-format fast path PERF-20 added in front of the
/// general parse chain returns exactly what that chain returned. The
/// cases cover the captured MXAccess shape (which the fast path is meant
/// to catch on a US-shaped host), its zero-padded and second-precision
/// siblings, and strings the derived formats cannot match — an ISO
/// round-trip form, a long-date form, and a day-first European form —
/// which must fall through to the unchanged two-stage chain. The
/// expectation is computed with the pre-PERF-20 chain in this test, so it
/// holds on any host culture: the fast path is a shortcut, never a
/// different answer.
/// </summary>
/// <param name="text">Timestamp string to parse.</param>
[Theory]
[InlineData("3/26/2026 1:38:22.907 PM")]
[InlineData("3/26/2026 1:38:22 PM")]
[InlineData("03/26/2026 01:38:22 PM")]
[InlineData("12/31/2026 11:59:59.999 PM")]
[InlineData("2026-03-26T13:38:22.9070000")]
[InlineData("Thursday, March 26, 2026 1:38:22 PM")]
[InlineData("26.03.2026 13:38:22")]
[InlineData("not a timestamp")]
public void TryParseSourceTimestamp_MatchesPreFastPathChain(string text)
{
bool expectedParsed = TryParseWithGeneralChainOnly(text, out DateTime expectedUtc);
bool parsed = MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc);
Assert.Equal(expectedParsed, parsed);
Assert.Equal(expectedUtc, utc);
if (parsed)
{
Assert.Equal(DateTimeKind.Utc, utc.Kind);
}
}
/// <summary>
/// Verifies a timestamp written in the host culture's own patterns —
/// including the millisecond fraction MXAccess appends, which no culture
/// publishes in its long-time pattern — parses as local wall-clock time
/// and comes back as UTC. This is the string shape the derived exact
/// formats are built for, so it exercises the fast path on the worker's
/// own host regardless of which culture that host runs.
/// </summary>
[Fact]
public void TryParseSourceTimestamp_WithCultureShapedMillisecondTimestamp_ReturnsLocalWallClockAsUtc()
{
CultureInfo culture = CultureInfo.CurrentCulture;
string longTime = culture.DateTimeFormat.LongTimePattern;
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
// Every Windows culture publishes seconds in its long-time pattern; the
// guard keeps the test honest rather than green-by-accident if one does not.
Assert.True(secondsIndex >= 0, $"Long-time pattern '{longTime}' has no seconds specifier.");
DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
string text = localWall.ToString(
culture.DateTimeFormat.ShortDatePattern + " " + longTime.Insert(secondsIndex + 2, ".fff"),
culture);
Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
Assert.Equal(DateTimeKind.Utc, utc.Kind);
Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
}
/// <summary>
/// Verifies format derivation survives a culture whose long-time pattern
/// hides an "ss" inside quoted literal text — the case where blindly
/// inserting ".fff" at the first "ss" produces a format that mangles the
/// value or is outright malformed, and a malformed format makes exact
/// parsing throw rather than report failure. Derivation must reject such
/// a candidate at build time, so parsing here stays exception-free and
/// the culture's own published shape still reads as local wall-clock
/// time. Uses a culture no other test touches, because the format cache
/// compares cultures by name.
/// </summary>
[Fact]
public void TryParseSourceTimestamp_WithLiteralSecondsInCulturePattern_ParsesWithoutThrowing()
{
CultureInfo pathological = new("en-ZA")
{
DateTimeFormat =
{
ShortDatePattern = "M/d/yyyy",
LongTimePattern = "'sss' HH:mm:ss",
},
};
CultureInfo original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = pathological;
DateTime localWall = new(2026, 3, 26, 13, 38, 22, DateTimeKind.Unspecified);
// The culture's published shape: "3/26/2026 sss 13:38:22".
string published = localWall.ToString(
pathological.DateTimeFormat.ShortDatePattern + " " + pathological.DateTimeFormat.LongTimePattern,
pathological);
Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(published, out DateTime utc));
Assert.Equal(DateTimeKind.Utc, utc.Kind);
Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
// Shapes this culture cannot describe must still fail quietly rather
// than throw out of the derived formats.
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("not a timestamp", out _));
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("sss sss sss", out _));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
/// <summary>
/// The parse chain exactly as it stood before PERF-20 added the
/// exact-format stage, used as the parity oracle above.
/// </summary>
/// <param name="text">Timestamp string to parse.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
private static bool TryParseWithGeneralChainOnly(string? text, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
return false;
}
private sealed class FakeStatus
{
public int success;
@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -249,6 +253,158 @@ public sealed class MxAccessEventQueueTests
Assert.Equal(3, result.RemainingCount);
}
/// <summary>
/// Brackets the byte budget's per-event charge to the exact serialized size of the event.
/// The queue measures that size once at enqueue rather than during the drain, so this pins
/// the two ends of the charge against the very instance the queue holds: a budget one byte
/// short must refuse the head (and leave it queued, WRK-21), and a budget of exactly the
/// charge must ship it. An undercharge — sizing the event before Enqueue stamps its worker
/// sequence and timestamp, say — passes the first probe and breaks the frame guarantee; an
/// overcharge of even one byte fails the second. The <c>preStamped</c> case covers an event
/// that arrives with those two fields already filled, which Enqueue overwrites.
/// </summary>
/// <param name="preStamped">Whether the event carries a stale sequence/timestamp on arrival.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Drain_ByteBudget_ChargesTheEventsExactSerializedSize(bool preStamped)
{
MxAccessEventQueue queue = new(capacity: 4);
MxEvent mxEvent = CreateEventWithPayload(itemHandle: 7, payloadLength: 300);
if (preStamped)
{
mxEvent.WorkerSequence = ulong.MaxValue;
mxEvent.WorkerTimestamp = Timestamp.FromDateTime(
new DateTime(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc));
}
queue.Enqueue(mxEvent);
// Enqueue takes ownership but never mutates the event afterwards, so the retained reference
// measures exactly what the queue holds — no second drain is needed to learn the cost.
Assert.Equal(1UL, mxEvent.WorkerSequence);
int exactCost = DrainCostOf(new WorkerEvent { Event = mxEvent });
WorkerEventDrainResult refused = queue.Drain(maxEvents: 0, maxTotalBytes: exactCost - 1);
Assert.Empty(refused.Events);
Assert.True(refused.TruncatedBySize);
Assert.Equal(1UL, refused.OversizedHeadSequence);
Assert.Equal(1, queue.Count);
WorkerEventDrainResult drained = queue.Drain(maxEvents: 0, maxTotalBytes: exactCost);
Assert.Single(drained.Events);
Assert.Same(mxEvent, drained.Events[0].Event);
Assert.False(drained.TruncatedBySize);
Assert.Equal(0, queue.Count);
}
/// <summary>
/// Pins the batch boundaries of a byte-budgeted walk over a mixed-size queue. Each batch is
/// checked against the true serialized sizes of the events it returned: no batch may exceed
/// the budget (an undercharge would build a reply past the frame maximum) and no batch may
/// stop early — the next event, at its real size, must not have fit (an overcharge would
/// ship frames smaller than the negotiated maximum allows). Both bounds come from the
/// drained events themselves, so any drift between the size memoized at enqueue and the real
/// one moves a boundary and fails here.
/// </summary>
[Fact]
public void Drain_ByteBudget_MixedSizes_KeepsBatchBoundariesOnTheRealSizes()
{
int[] payloadLengths = { 8, 512, 40, 2_048, 96, 1_200, 16, 700 };
const int eventCount = 240;
// Comfortably above the largest single event's cost, so the walk is never blocked by an
// oversized head and every stop is a genuine budget boundary.
const int budget = 4096;
MxAccessEventQueue queue = new(eventCount);
for (int index = 0; index < eventCount; index++)
{
queue.Enqueue(CreateEventWithPayload(index, payloadLengths[index % payloadLengths.Length]));
}
List<IReadOnlyList<WorkerEvent>> batches = new();
while (true)
{
WorkerEventDrainResult result = queue.Drain(maxEvents: 0, maxTotalBytes: budget);
if (result.Events.Count == 0)
{
Assert.Equal(0UL, result.OversizedHeadSequence);
break;
}
batches.Add(result.Events);
Assert.True(batches.Count <= eventCount, "Drain made no progress.");
}
Assert.Equal(0, queue.Count);
Assert.True(
batches.Count > 5,
$"Expected the byte budget to split the drain, saw {batches.Count} batches.");
ulong expectedSequence = 0;
for (int batchIndex = 0; batchIndex < batches.Count; batchIndex++)
{
IReadOnlyList<WorkerEvent> batch = batches[batchIndex];
int charged = 0;
foreach (WorkerEvent drained in batch)
{
charged += DrainCostOf(drained);
Assert.Equal(++expectedSequence, drained.Event.WorkerSequence);
}
Assert.True(
charged <= budget,
$"Batch {batchIndex} shipped {charged} bytes against a {budget} byte budget.");
if (batchIndex + 1 < batches.Count)
{
int nextCost = DrainCostOf(batches[batchIndex + 1][0]);
Assert.True(
charged + nextCost > budget,
$"Batch {batchIndex} stopped at {charged} bytes although the next event's {nextCost} still fit the {budget} byte budget.");
}
}
Assert.Equal((ulong)eventCount, expectedSequence);
}
/// <summary>
/// The other half of the WRK-21 head guarantee: a head refused for its size is drained
/// unchanged by a later call whose budget fits it. The size the queue charges lives with the
/// event across calls, so a refused attempt must neither consume nor alter it — and the
/// refusal itself is justified by the event's real serialized size.
/// </summary>
[Fact]
public void Drain_ByteBudget_RefusedHead_IsDrainedUnchangedOnceTheBudgetFitsIt()
{
MxAccessEventQueue queue = new(capacity: 8);
queue.Enqueue(CreateEventWithPayload(itemHandle: 0, payloadLength: 4096));
queue.Enqueue(CreateEventWithPayload(itemHandle: 1, payloadLength: 8));
WorkerEventDrainResult refused = queue.Drain(maxEvents: 0, maxTotalBytes: 1024);
Assert.Empty(refused.Events);
Assert.True(refused.TruncatedBySize);
Assert.Equal(1UL, refused.OversizedHeadSequence);
Assert.Equal(2, queue.Count);
WorkerEventDrainResult retried = queue.Drain(maxEvents: 0, maxTotalBytes: 64 * 1024);
Assert.Equal(2, retried.Events.Count);
Assert.Equal(1UL, retried.Events[0].Event.WorkerSequence);
Assert.Equal(2UL, retried.Events[1].Event.WorkerSequence);
Assert.False(retried.TruncatedBySize);
Assert.Equal(0, retried.RemainingCount);
Assert.Equal(0, queue.Count);
Assert.True(
DrainCostOf(retried.Events[0]) > 1024,
"The head was refused although its real cost fits the 1024-byte budget it was refused under.");
Assert.True(
DrainCostOf(retried.Events[0]) + DrainCostOf(retried.Events[1]) <= 64 * 1024,
"The retried batch exceeded the budget it was drained under.");
}
/// <summary>Verifies that Enqueue is rejected after a fault is recorded manually.</summary>
[Fact]
public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException()
@@ -300,6 +456,204 @@ public sealed class MxAccessEventQueueTests
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, queue.Fault?.Category);
}
// Wake-signal timings. The fallback is far longer than the patience deliberately: every wait
// below asserts "the signal completed this", which is only a faithful claim while the fallback
// timeout cannot have completed it within the patience window. The patience itself is generous
// so a loaded CI box cannot fail a test that is not about latency.
private static readonly TimeSpan WakeFallback = TimeSpan.FromSeconds(30);
private static readonly TimeSpan WakePatience = TimeSpan.FromSeconds(5);
/// <summary>
/// Verifies the queue wakes a parked waiter as soon as an event is enqueued, rather than
/// leaving it to time out. This is what removes the drain loop's latency floor: before the
/// signal existed, an event arriving at an idle queue waited out the loop's whole poll tick.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForEventsAsync_EnqueueAfterIdle_CompletesWithoutWaitingTheFallback()
{
MxAccessEventQueue queue = new(capacity: 4);
Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
Assert.False(wait.IsCompleted);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
await wait;
Assert.Equal(1, queue.Count);
}
/// <summary>
/// Verifies a fault recorded while the waiter is parked wakes it too. The drain loop
/// discovers faults by calling <c>DrainFault()</c> at the top of each pass, so without this
/// signal an overflow or conversion fault would not be reported until the loop's fallback
/// tick expired.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForEventsAsync_RecordFaultWhileParked_WakesTheWaiter()
{
MxAccessEventQueue queue = new(capacity: 4);
Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
Assert.False(wait.IsCompleted);
queue.RecordFault(new WorkerFault
{
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
});
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
await wait;
Assert.NotNull(queue.DrainFault());
}
/// <summary>
/// Verifies the one-permit cap loses no wakeups. A burst that lands while nobody is waiting
/// leaves exactly one pending wake — the waiter that consumes it drains the whole burst, so
/// coalescing costs nothing — and that consumed wake is not replayed: the next wait parks
/// until a new enqueue signals it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForEventsAsync_BurstWhileNoWaiter_CoalescesToOneWakeThatLosesNothing()
{
MxAccessEventQueue queue = new(capacity: 16);
for (int itemHandle = 0; itemHandle < 5; itemHandle++)
{
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle));
}
Task firstWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
Assert.Same(firstWait, await Task.WhenAny(firstWait, Task.Delay(WakePatience)));
await firstWait;
// One wake, the whole burst: the waiter re-drains everything queued, which is why capping
// the signal at a single permit cannot drop an event.
Assert.Equal(5, queue.Drain(maxEvents: 0).Count);
Task secondWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None);
Assert.False(secondWait.IsCompleted);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 99));
Assert.Same(secondWait, await Task.WhenAny(secondWait, Task.Delay(WakePatience)));
await secondWait;
}
/// <summary>
/// Verifies the timeout still bounds an unsignalled wait, from both sides: the wait ends
/// without a signal (the fallback survives as a ceiling, so a state change reached by some
/// future path that does not signal is still observed on the next pass rather than never)
/// and it does not end early (the wait really is the timeout, not an already-armed permit
/// completing it instantly). A longer-than-production timeout is used so the lower bound
/// carries a wide margin over timer resolution.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForEventsAsync_WithNoSignal_CompletesAtTheFallbackTimeout()
{
MxAccessEventQueue queue = new(capacity: 4);
TimeSpan fallback = TimeSpan.FromMilliseconds(200);
Stopwatch elapsed = Stopwatch.StartNew();
Task wait = queue.WaitForEventsAsync(fallback, CancellationToken.None);
Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience)));
await wait;
elapsed.Stop();
// Half the timeout: far enough below it to be immune to timer resolution, far enough above
// zero to fail an implementation that returned a completed task instead of waiting.
Assert.True(
elapsed.Elapsed >= TimeSpan.FromMilliseconds(100),
$"Unsignalled wait returned after {elapsed.ElapsedMilliseconds} ms, well inside its {fallback.TotalMilliseconds} ms fallback.");
}
/// <summary>Verifies a cancelled wait unwinds instead of hanging until the fallback expires.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForEventsAsync_WhenCancelled_Throws()
{
MxAccessEventQueue queue = new(capacity: 4);
using CancellationTokenSource cancellation = new();
Task wait = queue.WaitForEventsAsync(WakeFallback, cancellation.Token);
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await wait);
}
/// <summary>
/// The queue capacity comes from the launcher-set environment variable;
/// a missing, unparseable, below-floor, or above-ceiling value must fall
/// back to the 10,000 default rather than throw. A bad environment value
/// must never stop the worker's session from starting.
/// </summary>
/// <param name="environmentValue">Raw environment value under test.</param>
/// <param name="expected">Expected resolved capacity.</param>
[Theory]
[InlineData(null, MxAccessEventQueue.DefaultCapacity)]
[InlineData("", MxAccessEventQueue.DefaultCapacity)]
[InlineData("not-a-number", MxAccessEventQueue.DefaultCapacity)]
[InlineData("0", MxAccessEventQueue.DefaultCapacity)]
[InlineData("-5", MxAccessEventQueue.DefaultCapacity)]
[InlineData("999", MxAccessEventQueue.DefaultCapacity)]
[InlineData("1000001", MxAccessEventQueue.DefaultCapacity)]
[InlineData("1000", 1000)]
[InlineData("1000000", 1000000)]
[InlineData("50000", 50000)]
public void ResolveCapacity_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
string? environmentValue,
int expected)
{
string? original = Environment.GetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName,
environmentValue);
Assert.Equal(expected, MxAccessEventQueue.ResolveCapacity());
}
finally
{
Environment.SetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName,
original);
}
}
/// <summary>
/// A resolved capacity is what the queue is actually built with, so the
/// configured headroom reaches the overflow check rather than only the
/// resolver.
/// </summary>
[Fact]
public void Constructor_WithResolvedCapacity_UsesEnvironmentValue()
{
string? original = Environment.GetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName,
"2500");
MxAccessEventQueue queue = new(MxAccessEventQueue.ResolveCapacity());
Assert.Equal(2500, queue.Capacity);
}
finally
{
Environment.SetEnvironmentVariable(
MxAccessEventQueue.CapacityEnvironmentVariableName,
original);
}
}
// Mirrors MxAccessEventQueue's per-event repeated-field allowance. Kept local rather than made
// public on the queue: the byte-budget tests state their budgets in units of that charge, so a
// change to it should surface here as a failing bound instead of silently moving with the code.
@@ -314,6 +668,19 @@ public sealed class MxAccessEventQueueTests
/// </summary>
/// <param name="payloadLength">Length of the event's raw-status payload string.</param>
/// <returns>The per-event byte cost.</returns>
/// <summary>
/// What the byte budget must charge for an already-stamped event: its true serialized size plus
/// the repeated-field allowance. Measured from the generated <c>CalculateSize()</c> so the
/// budget tests bound the queue's memoized size against the real one rather than against a
/// second copy of the queue's own arithmetic.
/// </summary>
/// <param name="workerEvent">Event as the queue holds it, sequence and timestamp stamped.</param>
/// <returns>The per-event byte cost.</returns>
private static int DrainCostOf(WorkerEvent workerEvent)
{
return workerEvent.CalculateSize() + RepeatedFieldOverheadBytes;
}
private static int MeasureDrainCost(int payloadLength)
{
MxAccessEventQueue probe = new(capacity: 1);
@@ -57,6 +57,51 @@ public sealed class MxAccessStaSessionTests
}
}
/// <summary>
/// The alarm poll cadence comes from the launcher-set environment
/// variable; a missing, unparseable, or out-of-range value must fall
/// back to the 500 ms default rather than throw. The ceiling matters as
/// much as the floor: <see cref="int.MaxValue"/> milliseconds is ~24
/// days, which silently disables alarm polling altogether.
/// </summary>
/// <param name="environmentValue">Raw environment value under test.</param>
/// <param name="expectedMilliseconds">Expected resolved cadence, in milliseconds.</param>
[Theory]
[InlineData(null, 500)]
[InlineData("", 500)]
[InlineData("not-a-number", 500)]
[InlineData("0", 500)]
[InlineData("-1", 500)]
[InlineData("99", 500)]
[InlineData("100", 100)]
[InlineData("250", 250)]
[InlineData("3600000", 3600000)]
[InlineData("3600001", 500)]
[InlineData("2147483647", 500)]
public void ResolveAlarmPollInterval_WithEnvironmentValue_FallsBackToDefaultWhenOutOfRange(
string? environmentValue,
int expectedMilliseconds)
{
string? original = Environment.GetEnvironmentVariable(
MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName,
environmentValue);
Assert.Equal(
TimeSpan.FromMilliseconds(expectedMilliseconds),
MxAccessStaSession.ResolveAlarmPollInterval());
}
finally
{
Environment.SetEnvironmentVariable(
MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName,
original);
}
}
/// <summary>
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
/// </summary>
@@ -179,27 +179,35 @@ public sealed class MxAccessValueCacheTests
}
/// <summary>
/// Verifies the wait is message/signal-driven rather than sleep-polled:
/// with a poll interval far longer than the deadline, a value set from
/// another thread still wakes the wait and is returned well inside the
/// deadline. The sleep-polled loop this replaced would have sat blind for
/// the whole 10 s interval — on the STA that means 10 s with no Windows
/// Verifies the wait slice stays bounded no matter what poll interval the
/// caller asks for: with a 10 s interval and a 3 s deadline, a value set
/// 50 ms in is still returned in time, because every slice is capped at
/// the 50 ms fallback tick. The blind <c>Thread.Sleep</c> this replaced
/// would have slept the full interval — on the STA, 10 s with no Windows
/// messages dispatched, so the OnDataChange being waited for could not
/// have arrived at all — and then reported a timeout.
/// </summary>
/// <remarks>
/// Scope: this proves the cadence bound, not the signal path. The clamp
/// alone would satisfy it, so it stays green if the <c>Set</c>-side
/// signal is deleted. <c>StaWaitHelperTests</c> owns the signal path,
/// where a five-second wait with no clamp in play makes the handle the
/// only thing that can end it early.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public void TryWaitForUpdate_WakesOnCrossThreadSet_DespiteLongPollInterval()
public async Task TryWaitForUpdate_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick()
{
MxAccessValueCache cache = new();
Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
using ManualResetEventSlim waitEntered = new(false);
Task setter = Task.Run(() =>
Task setter = Task.Run(async () =>
{
// Handshake so the value cannot land before the wait starts —
// otherwise the first check would satisfy it and prove nothing.
waitEntered.Wait(TimeSpan.FromSeconds(5));
Thread.Sleep(50);
await Task.Delay(50, CancellationToken.None);
cache.Set(7, 21, BuildEvent(7, 21, intValue: 8080, quality: 192, sourceTimestamp));
});
@@ -213,13 +221,13 @@ public sealed class MxAccessValueCacheTests
out MxAccessValueCache.CachedValue value,
pollIntervalMs: 10_000);
elapsed.Stop();
setter.Wait(TimeSpan.FromSeconds(5));
await setter;
Assert.True(found);
Assert.Equal(8080, value.Value.Int32Value);
Assert.True(
elapsed.Elapsed < TimeSpan.FromSeconds(2),
$"The wait should have woken on the cached value, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
$"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
}
/// <summary>
@@ -113,25 +113,33 @@ public sealed class MxAccessWriteCompletionCacheTests
}
/// <summary>
/// Verifies the wait is message/signal-driven rather than sleep-polled:
/// with a poll interval far longer than the deadline, a completion
/// recorded from another thread still wakes the wait and is returned well
/// inside the deadline. The sleep-polled loop this replaced would have sat
/// blind for the whole 10 s interval — on the STA that means 10 s with no
/// Verifies the wait slice stays bounded no matter what poll interval the
/// caller asks for: with a 10 s interval and a 3 s deadline, a completion
/// recorded 50 ms in is still returned in time, because every slice is
/// capped at the 50 ms fallback tick. The blind <c>Thread.Sleep</c> this
/// replaced would have slept the full interval — on the STA, 10 s with no
/// Windows messages dispatched — and then reported a timeout.
/// </summary>
/// <remarks>
/// Scope: this proves the cadence bound, not the signal path. The clamp
/// alone would satisfy it, so it stays green if the <c>Record</c>-side
/// <c>Set()</c> is deleted. <c>StaWaitHelperTests</c> owns the signal
/// path, where a five-second wait with no clamp in play makes the handle
/// the only thing that can end it early.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public void TryWaitForCompletion_WakesOnCrossThreadRecord_DespiteLongPollInterval()
public async Task TryWaitForCompletion_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick()
{
MxAccessWriteCompletionCache cache = new();
using ManualResetEventSlim waitEntered = new(false);
Task recorder = Task.Run(() =>
Task recorder = Task.Run(async () =>
{
// Handshake so the completion cannot land before the wait starts —
// otherwise the first check would satisfy it and prove nothing.
waitEntered.Wait(TimeSpan.FromSeconds(5));
Thread.Sleep(50);
await Task.Delay(50, CancellationToken.None);
cache.Record(7, 21, BuildStatuses(detail: 8080));
});
@@ -145,13 +153,13 @@ public sealed class MxAccessWriteCompletionCacheTests
out RepeatedField<MxStatusProxy> statuses,
pollIntervalMs: 10_000);
elapsed.Stop();
recorder.Wait(TimeSpan.FromSeconds(5));
await recorder;
Assert.True(found);
Assert.Equal(8080, Assert.Single(statuses).Detail);
Assert.True(
elapsed.Elapsed < TimeSpan.FromSeconds(2),
$"The wait should have woken on the recorded completion, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
$"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
}
/// <summary>Verifies that Record stores an independent clone of the caller's rows.</summary>
@@ -317,6 +317,377 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
}
// -------------------------------------------------------------------------
// Truncation cliff (Task 23). GetXmlCurrentAlarms2 caps its reply at
// maxAlmCnt with no "more available" flag. Before the guard, a capped fetch
// shrank the retained snapshot, and every alarm past the cap vanished from
// SnapshotActiveAlarms — which the gateway's reconcile pass reads as a
// Clear and broadcasts to every StreamAlarms subscriber.
// -------------------------------------------------------------------------
/// <summary>
/// A fetch that comes back holding exactly the cap must be treated as
/// truncated; anything below the cap must not.
/// </summary>
/// <param name="fetchedRecordCount">Records the reply carried.</param>
/// <param name="maxAlarmsPerFetch">The cap passed to the fetch.</param>
/// <param name="expected">Whether the reply must read as truncated.</param>
[Theory]
[InlineData(1024, 1024, true)]
[InlineData(1023, 1024, false)]
[InlineData(0, 1024, false)]
[InlineData(64, 64, true)]
public void IsTruncatedFetch_AtOrAboveCap_IsTruncated(
int fetchedRecordCount,
int maxAlarmsPerFetch,
bool expected)
{
Assert.Equal(
expected,
WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch));
}
/// <summary>
/// THE truncation-correctness test. A 1024-record fetch against a
/// 1024 cap that does not mention a known-active alarm must NOT evict
/// that alarm from the retained snapshot: the snapshot is what
/// <see cref="WnWrapAlarmConsumer.SnapshotActiveAlarms"/> returns and
/// what the gateway's reconcile diffs its cache against, so an
/// eviction here is a Clear broadcast for an alarm that never cleared.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch()
{
const int Cap = 1024;
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
string xml = BuildAlarmXml(Cap);
Dictionary<Guid, MxAlarmSnapshotRecord> next =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap, fetchedRecordCount);
Assert.Equal(Cap, next.Count);
Assert.False(next.ContainsKey(missingGuid));
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
// The diff itself never emits a Clear for a disappearance — the Clear
// is the eviction, one level up. Pin both halves.
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
Assert.DoesNotContain(transitions, t => t.Record.AlarmGuid == missingGuid);
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
// NO Clear: the alarm the capped fetch had no room to mention survives,
// so the gateway's reconcile still sees it active.
Assert.True(snapshot.ContainsKey(missingGuid));
Assert.Equal(MxAlarmStateKind.UnackAlm, snapshot[missingGuid].State);
Assert.Equal(Cap + 1, snapshot.Count);
}
/// <summary>
/// The control case: the same missing alarm against a sub-cap fetch
/// must still be evicted, because a complete fetch IS authoritative
/// about absence. Without this, the truncation guard would have
/// silently disabled clears altogether.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchBelowCap_EvictsAlarmMissingFromFetch()
{
const int Cap = 1024;
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
string xml = BuildAlarmXml(Cap - 1);
Dictionary<Guid, MxAlarmSnapshotRecord> next =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap - 1, fetchedRecordCount);
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: false);
// Clear as today: the alarm drops out of the snapshot, and the
// gateway's reconcile turns that absence into a Clear.
Assert.False(snapshot.ContainsKey(missingGuid));
Assert.Equal(Cap - 1, snapshot.Count);
}
/// <summary>
/// A truncated fetch must still apply the alarms it DID carry — the
/// guard suppresses eviction, not the update. An alarm whose state
/// changed inside a capped reply still transitions.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchTruncated_StillAppliesPresentAlarms()
{
Guid presentGuid = new Guid("22222222-2222-2222-2222-222222222222");
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm),
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(presentGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
Assert.Equal(MxAlarmStateKind.AckAlm, snapshot[presentGuid].State);
Assert.True(snapshot.ContainsKey(missingGuid));
}
/// <summary>
/// Truncation is detected from the raw ALARM-element count, not the
/// parsed dictionary size. A record dropped for a malformed GUID still
/// consumed a slot in the capped reply, so counting only survivors
/// would let a truncated fetch pass as complete and re-open the cliff.
/// </summary>
[Fact]
public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid()
{
const int Cap = 64;
string xml = BuildAlarmXml(Cap).Replace(
"<GUID>00000000000000000000000000000001</GUID>",
"<GUID>not-a-guid</GUID>");
Dictionary<Guid, MxAlarmSnapshotRecord> records =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap, fetchedRecordCount);
Assert.Equal(Cap - 1, records.Count);
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
}
/// <summary>
/// The per-fetch cap comes from the launcher-set environment variable;
/// a missing, unparseable, or below-floor value must fall back to the
/// 1024 default rather than throw. A bad environment value must never
/// stop the alarm consumer from starting.
/// </summary>
/// <param name="environmentValue">Raw environment value under test.</param>
/// <param name="expected">Expected resolved cap.</param>
[Theory]
[InlineData(null, 1024)]
[InlineData("", 1024)]
[InlineData("not-a-number", 1024)]
[InlineData("0", 1024)]
[InlineData("-5", 1024)]
[InlineData("63", 1024)]
[InlineData("64", 64)]
[InlineData("4096", 4096)]
[InlineData("65536", 65536)]
// Above the ceiling: the x86 worker materializes the whole reply as one
// BSTR plus an XmlDocument, so an unbounded cap is an OOM on the STA.
[InlineData("65537", 1024)]
[InlineData("2147483647", 1024)]
public void ResolveMaxAlarmsPerFetch_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
string? environmentValue,
int expected)
{
string? original = Environment.GetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
environmentValue);
Assert.Equal(expected, WnWrapAlarmConsumer.ResolveMaxAlarmsPerFetch());
}
finally
{
Environment.SetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
original);
}
}
/// <summary>
/// A galaxy parked above the cap truncates on every poll, so the
/// warning must be throttled: two truncated polls inside one interval
/// produce exactly one line, and the next one only after the full
/// interval has elapsed.
/// </summary>
[Fact]
public void ShouldWarnTruncation_ThrottlesConsecutiveTruncatedPollsToOneWarningPerInterval()
{
// Seeded so the very first truncated poll always warns.
const long NeverWarned = -60_000;
// Poll 1 at t=0: warns, and records t=0 as the last warning.
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(0, NeverWarned));
// Poll 2 half a second later (the default cadence): suppressed.
Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(500, 0));
// Still suppressed just shy of the interval...
Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(59_999, 0));
// ...and allowed again exactly on it.
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(60_000, 0));
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(120_000, 60_000));
}
/// <summary>
/// Builds a well-formed ALARM_RECORDS payload with
/// <paramref name="count"/> distinct alarms. GUIDs are the dashless
/// 32-char hex form wnwrap actually emits, numbered from 1 so a test
/// can name one (e.g. ...0001) to corrupt.
/// </summary>
/// <param name="count">Number of ALARM elements to emit.</param>
/// <returns>The XML payload.</returns>
private static string BuildAlarmXml(int count)
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"")
.Append(count)
.Append("\">");
for (int index = 1; index <= count; index++)
{
sb.Append("<ALARM><GUID>")
.Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture))
.Append("</GUID>")
.Append("<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>")
.Append("<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>")
.Append("<PROVIDER_NODE>TEST-NODE</PROVIDER_NODE>")
.Append("<PROVIDER_NAME>Galaxy</PROVIDER_NAME>")
.Append("<GROUP>TestArea</GROUP>")
.Append("<TAGNAME>TestMachine_")
.Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture))
.Append(".TestAlarm</TAGNAME>")
.Append("<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>")
.Append("<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>")
.Append("<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>")
.Append("<ALARM_COMMENT>Test alarm</ALARM_COMMENT></ALARM>");
}
sb.Append("</ALARM_RECORDS>");
return sb.ToString();
}
// -------------------------------------------------------------------------
// Single-pass parse parity (Task 23). The per-field SelectSingleNode calls
// were replaced by one walk over ChildNodes; these pin the semantics that
// walk has to reproduce.
// -------------------------------------------------------------------------
/// <summary>
/// An absent child element and a present-but-empty one must both yield
/// <see cref="string.Empty"/>, exactly as
/// <c>SelectSingleNode(name)?.InnerText ?? string.Empty</c> did.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<TAGNAME></TAGNAME>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal(string.Empty, record.TagName); // present but empty
Assert.Equal(string.Empty, record.ProviderNode); // absent
Assert.Equal(string.Empty, record.Value); // absent
Assert.Equal(string.Empty, record.AlarmComment); // absent
Assert.Equal(0, record.Priority); // absent → ParseInt("") → 0
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
}
/// <summary>
/// Duplicate child elements must resolve to the FIRST occurrence —
/// <c>SelectSingleNode</c> returned the first match, so a last-wins
/// walk would silently change which value a malformed payload yields.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<TAGNAME>First</TAGNAME><TAGNAME>Second</TAGNAME>" +
"<PRIORITY>100</PRIORITY><PRIORITY>900</PRIORITY>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal("First", record.TagName);
Assert.Equal(100, record.Priority);
}
/// <summary>
/// Field names are matched ordinally and case-sensitively, as the
/// XPath node test was. A lowercase element must not populate the
/// field its uppercase counterpart owns.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<tagname>ShouldBeIgnored</tagname>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal(string.Empty, record.TagName);
}
/// <summary>
/// Non-element children (whitespace text, comments, CDATA) must be
/// skipped rather than matched by name, and a nested element must
/// contribute its InnerText exactly as <c>InnerText</c> always did.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">\n" +
" <ALARM>\n" +
" <!-- a comment -->\n" +
" <GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>\n" +
" <TAGNAME>TestMachine.TestAlarm</TAGNAME>\n" +
" <STATE>UNACK_ALM</STATE>\n" +
" </ALARM>\n" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(1, fetchedRecordCount);
MxAlarmSnapshotRecord record = Assert.Single(records).Value;
Assert.Equal("TestMachine.TestAlarm", record.TagName);
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
}
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
{
return new MxAlarmSnapshotRecord
@@ -0,0 +1,235 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;
/// <summary>
/// Tests for <see cref="StaWaitHelper"/>, the message-aware wait behind the
/// write-completion and ReadBulk value waits.
/// </summary>
/// <remarks>
/// <para>
/// These exist because the cache-level tests cannot guard the signal
/// path. Every cache wait clamps its slice to
/// <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>, so a wait
/// woken by the handle and a wait that merely timed out and re-checked
/// are milliseconds apart — no wall-clock assertion up there can tell
/// them apart, and deleting the caches' <c>Set()</c> calls would leave
/// them green. Calling the helper directly with a five-second timeout
/// removes the clamp from the picture: only the handle can end that wait
/// early, and the test asserts the handle was the thing consumed.
/// </para>
/// <para>
/// Boundary: the <c>WAIT_FAILED</c> branch is not exercised, for the same
/// reason <see cref="StaMessagePumpTests"/> gives — forcing
/// <c>MsgWaitForMultipleObjectsEx</c> to fail means handing it a
/// deliberately invalid native handle, which is unsafe to construct in a
/// managed test.
/// </para>
/// </remarks>
public sealed class StaWaitHelperTests
{
/// <summary>Verifies that a null wait handle is rejected.</summary>
[Fact]
public void WaitForSignalOrMessages_NullSignal_ThrowsArgumentNullException()
{
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(
() => StaWaitHelper.WaitForSignalOrMessages(null!, 10));
Assert.Equal("signal", exception.ParamName);
}
/// <summary>
/// Verifies the wait ends on a cross-thread signal rather than on its
/// timeout: the handle is set ~50 ms into a five-second wait, and the
/// wait must return two orders of magnitude before that timeout. The
/// post-condition that the handle was consumed is what makes this a
/// discriminator — an auto-reset event that our wait did not take would
/// still be signalled afterwards, which would mean something other than
/// the signal ended the wait.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WaitForSignalOrMessages_SignalSetDuringWait_ReturnsLongBeforeTheTimeout()
{
using AutoResetEvent signal = new(initialState: false);
// Drain anything already queued for this thread so the wait cannot be
// woken by stale input instead of by the signal.
new StaMessagePump().PumpPendingMessages();
Task setter = Task.Run(async () =>
{
await Task.Delay(50, CancellationToken.None);
signal.Set();
});
Stopwatch elapsed = Stopwatch.StartNew();
StaWaitHelper.WaitForSignalOrMessages(signal, 5000);
elapsed.Stop();
await setter;
Assert.True(
elapsed.Elapsed < TimeSpan.FromMilliseconds(500),
$"Wait took {elapsed.ElapsedMilliseconds} ms of its 5000 ms timeout; the signal should have ended it.");
Assert.False(
signal.WaitOne(0),
"The wait did not consume the signal, so something other than the signal ended it.");
}
/// <summary>
/// Verifies an already-signalled handle ends the wait immediately, which
/// is the case that keeps a completion recorded between the caller's
/// check and its wait from being missed.
/// </summary>
[Fact]
public void WaitForSignalOrMessages_PreSignalledHandle_ReturnsImmediately()
{
using AutoResetEvent signal = new(initialState: true);
Stopwatch elapsed = Stopwatch.StartNew();
StaWaitHelper.WaitForSignalOrMessages(signal, 30_000);
elapsed.Stop();
Assert.True(
elapsed.Elapsed < TimeSpan.FromSeconds(5),
$"Wait took {elapsed.ElapsedMilliseconds} ms; a pre-signalled handle must return at once.");
Assert.False(signal.WaitOne(0), "The wait should have consumed the signal.");
}
/// <summary>
/// Verifies the wait actually blocks for its timeout when nothing signals
/// it. The lower bound is the point: a wait that returned instantly would
/// turn every caller's poll loop into a spin, which is exactly the
/// failure mode the queue must be drained to avoid.
/// </summary>
[Fact]
public void WaitForSignalOrMessages_NeverSignalled_BlocksUntilTheTimeout()
{
using AutoResetEvent signal = new(initialState: false);
// The wait wakes on input that is merely present, so drain first —
// otherwise a stale message would end the wait early and the lower
// bound below would be measuring the wrong thing.
new StaMessagePump().PumpPendingMessages();
Stopwatch elapsed = Stopwatch.StartNew();
StaWaitHelper.WaitForSignalOrMessages(signal, 200);
elapsed.Stop();
// Loose lower bound: the OS may return slightly early on a coarse timer
// tick, so this proves "it blocked", not "it blocked for exactly 200 ms".
Assert.True(
elapsed.Elapsed >= TimeSpan.FromMilliseconds(100),
$"Wait returned after {elapsed.ElapsedMilliseconds} ms; a 200 ms wait must not return instantly.");
Assert.True(
elapsed.Elapsed < TimeSpan.FromSeconds(5),
$"Wait took {elapsed.ElapsedMilliseconds} ms; a 200 ms timeout must end it.");
}
/// <summary>
/// Verifies a non-positive timeout returns without entering the wait at
/// all — proven by the signal still being set afterwards, since a wait
/// that ran would have consumed it.
/// </summary>
/// <param name="timeoutMilliseconds">The non-positive timeout under test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void WaitForSignalOrMessages_NonPositiveTimeout_DoesNotEnterTheWait(int timeoutMilliseconds)
{
using AutoResetEvent signal = new(initialState: true);
Stopwatch elapsed = Stopwatch.StartNew();
StaWaitHelper.WaitForSignalOrMessages(signal, timeoutMilliseconds);
elapsed.Stop();
Assert.True(
elapsed.Elapsed < TimeSpan.FromSeconds(2),
$"Wait took {elapsed.ElapsedMilliseconds} ms; a non-positive timeout must not block.");
Assert.True(signal.WaitOne(0), "The signal must be untouched when the wait is skipped.");
}
/// <summary>
/// Boundary table for the slice math. Every caller's wait length comes
/// from here, so each clause is pinned: a poll interval below one clamps
/// up to 1 ms, an expired deadline yields 0 (the caller returns without
/// waiting), a deadline nearer than the tick rounds up so the wait never
/// ends before the deadline, and any interval above the fallback ceiling
/// clamps down to it.
/// </summary>
/// <param name="remainingMilliseconds">Time left before the caller's deadline.</param>
/// <param name="pollIntervalMilliseconds">The caller's requested pump cadence.</param>
/// <param name="expected">The expected wait slice in milliseconds.</param>
[Theory]
// pollInterval < 1 clamps up to 1 ms — never a zero-length spin.
[InlineData(1000d, 0, 1)]
[InlineData(1000d, -5, 1)]
// remaining <= 0 yields 0; the caller has already returned on its deadline.
[InlineData(0d, 5, 0)]
[InlineData(-10d, 5, 0)]
// remaining < tick shortens the wait to the deadline.
[InlineData(3d, 5, 3)]
[InlineData(1d, 50, 1)]
// remaining == tick is not "less than", so the tick stands.
[InlineData(5d, 5, 5)]
// The caller's interval wins while it is under the ceiling.
[InlineData(1000d, 5, 5)]
[InlineData(1000d, 49, 49)]
// Anything at or above the ceiling clamps to it — this is what bounds the
// unconditional pumpStep cadence regardless of what a caller asks for.
[InlineData(1000d, 50, 50)]
[InlineData(1000d, 10_000, 50)]
[InlineData(50d, 10_000, 50)]
public void ClampWaitMilliseconds_BoundaryTable(
double remainingMilliseconds,
int pollIntervalMilliseconds,
int expected)
{
int actual = StaWaitHelper.ClampWaitMilliseconds(
TimeSpan.FromMilliseconds(remainingMilliseconds),
pollIntervalMilliseconds);
Assert.Equal(expected, actual);
}
/// <summary>
/// Verifies a sub-millisecond remainder rounds up rather than down: a
/// zero-length wait would spin, and truncating would end the wait before
/// the caller's deadline. Built from ticks because
/// <see cref="TimeSpan.FromMilliseconds(double)"/> rounds its argument to
/// whole milliseconds and so cannot express these values at all.
/// </summary>
[Fact]
public void ClampWaitMilliseconds_SubMillisecondRemaining_RoundsUp()
{
// 10_000 ticks == 1 ms.
Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(4_000), 5));
Assert.Equal(3, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(25_000), 5));
Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(1), 5));
}
/// <summary>
/// Verifies the clamp never exceeds the time actually left, which is what
/// keeps a wait from overrunning its caller's deadline.
/// </summary>
[Fact]
public void ClampWaitMilliseconds_NeverExceedsRemainingOrTheCeiling()
{
for (int remaining = 1; remaining <= 200; remaining++)
{
int slice = StaWaitHelper.ClampWaitMilliseconds(
TimeSpan.FromMilliseconds(remaining),
pollIntervalMilliseconds: 10_000);
Assert.True(slice >= 1, $"Slice for {remaining} ms remaining was {slice}; a positive deadline must wait.");
Assert.True(slice <= remaining, $"Slice {slice} overran the {remaining} ms left before the deadline.");
Assert.True(
slice <= StaWaitHelper.MaxFallbackTickMilliseconds,
$"Slice {slice} exceeded the {StaWaitHelper.MaxFallbackTickMilliseconds} ms fallback ceiling.");
}
}
}
@@ -24,6 +24,13 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
private readonly object gate = new();
private readonly Queue<WorkerEvent> events = new();
private readonly List<string> cancelledCorrelationIds = new();
// Mirrors MxAccessEventQueue's coalesced wake signal so the drain loop under test is driven the
// same way it is in production: EnqueueEvent(s) releases one permit, WaitForEventsAsync consumes
// it. Never disposed — the drain loop can still be parked on it while Dispose runs, and a
// disposed SemaphoreSlim would turn that shutdown into an ObjectDisposedException.
private readonly SemaphoreSlim eventSignal = new(0, 1);
private TimeSpan? lastWaitForEventsTimeout;
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
DateTimeOffset.UtcNow,
pendingCommandCount: 0,
@@ -263,6 +270,63 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
}
}
/// <summary>
/// When set, <see cref="WaitForEventsAsync"/> honours only the wake signal and cancellation,
/// never the fallback timeout. A drain loop that ships an event while this is set can only
/// have been woken by the enqueue signal, which is what makes "the drain is signal-driven,
/// not poll-driven" assertable without racing the 25 ms fallback tick.
/// </summary>
public bool WaitForEventsOnSignalOnly { get; set; }
/// <summary>
/// The <c>timeout</c> argument of the most recent <see cref="WaitForEventsAsync"/> call, so
/// a test can assert the drain loop still passes its fallback ceiling.
/// </summary>
public TimeSpan? LastWaitForEventsTimeout
{
get
{
lock (gate)
{
return lastWaitForEventsTimeout;
}
}
}
/// <inheritdoc />
public Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
lock (gate)
{
lastWaitForEventsTimeout = timeout;
}
if (BackingQueue is not null)
{
if (WaitForEventsOnSignalOnly)
{
// The two are mutually exclusive: a real backing queue owns its own signal and
// always honours the timeout, so silently letting it win would leave a
// signal-only test passing on a fallback tick — exactly the false green the
// option exists to rule out.
throw new InvalidOperationException(
"FakeRuntimeSession cannot combine BackingQueue with WaitForEventsOnSignalOnly: "
+ "the backing queue honours the fallback timeout, which defeats the signal-only wait.");
}
// Tests that drive a real queue enqueue into it directly, so the real queue owns the
// wake signal too.
return BackingQueue.WaitForEventsAsync(timeout, cancellationToken);
}
if (WaitForEventsOnSignalOnly)
{
return eventSignal.WaitAsync(cancellationToken);
}
return eventSignal.WaitAsync(timeout, cancellationToken);
}
/// <inheritdoc />
public WorkerFault? DrainFault()
{
@@ -370,6 +434,8 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
{
events.Enqueue(workerEvent);
}
SignalWake();
}
/// <summary>
@@ -387,6 +453,26 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
events.Enqueue(workerEvent);
}
}
SignalWake();
}
// Coalesced wake, released outside the gate exactly as MxAccessEventQueue does.
private void SignalWake()
{
if (eventSignal.CurrentCount > 0)
{
return;
}
try
{
eventSignal.Release();
}
catch (SemaphoreFullException)
{
// A concurrent enqueue already published the pending wake this call wanted.
}
}
/// <inheritdoc />
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -11,13 +12,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
public sealed class MxStatusProxyConverter
{
/// <summary>
/// Per-type cache of the four resolved <see cref="FieldInfo"/> objects a
/// status conversion needs. The status type is stable (the interop
/// <c>MXSTATUS_PROXY</c> struct in production; a fixed test double in
/// tests), so the expensive <see cref="Type.GetField(string, BindingFlags)"/>
/// metadata scan is resolved once per type and reused. Keyed by
/// <see cref="Type"/> so a plain-CLR test double and the real interop
/// struct each get their own entry, keeping the converter interop-agnostic.
/// Per-type cache of the four field accessors a status conversion needs.
/// The status type is stable (the interop <c>MXSTATUS_PROXY</c> struct in
/// production; a fixed test double in tests), so the expensive
/// <see cref="Type.GetField(string, BindingFlags)"/> metadata scan — and,
/// for a visible type, the one-time expression compile that replaces
/// <see cref="FieldInfo.GetValue(object)"/> on the event path — happens
/// once per type and is reused. Keyed by <see cref="Type"/> so a
/// plain-CLR test double and the real interop struct each get their own
/// entry, keeping the converter interop-agnostic.
/// </summary>
private static readonly ConcurrentDictionary<Type, StatusFields> FieldCache = new();
@@ -31,12 +34,11 @@ public sealed class MxStatusProxyConverter
throw new ArgumentNullException(nameof(status));
}
Type statusType = status.GetType();
StatusFields fields = GetFields(statusType);
int success = ReadInt32Field(status, statusType, fields.Success);
int rawCategory = ReadInt32Field(status, statusType, fields.Category);
int rawDetectedBy = ReadInt32Field(status, statusType, fields.DetectedBy);
int detail = ReadInt32Field(status, statusType, fields.Detail);
StatusFields fields = GetFields(status.GetType());
int success = fields.Success(status);
int rawCategory = fields.Category(status);
int rawDetectedBy = fields.DetectedBy(status);
int detail = fields.Detail(status);
return new MxStatusProxy
{
@@ -109,26 +111,102 @@ public sealed class MxStatusProxyConverter
}
/// <summary>
/// Resolves (and caches) the four <see cref="FieldInfo"/> objects for the
/// given status type. The first resolution for a type performs the
/// reflection scan; every subsequent conversion of that type reuses the
/// cached entry. A type missing a required field throws the same
/// <see cref="MxStatusConversionException"/> the per-field lookup used to
/// throw — and, because <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>
/// Resolves (and caches) the four field accessors for the given status
/// type. The first resolution for a type performs the reflection scan and
/// the expression compile; every subsequent conversion of that type
/// reuses the cached delegates. A type missing a required field throws the
/// same <see cref="MxStatusConversionException"/> the per-field lookup used
/// to throw — and, because <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>
/// does not store a value when the factory throws, a bad type keeps
/// failing identically on every call rather than being cached.
/// </summary>
/// <param name="statusType">Runtime type of the status object being converted.</param>
/// <returns>The resolved field set for <paramref name="statusType"/>.</returns>
/// <returns>The resolved field accessors for <paramref name="statusType"/>.</returns>
private static StatusFields GetFields(Type statusType)
{
return FieldCache.GetOrAdd(
statusType,
type => new StatusFields(
ResolveField(type, "success"),
ResolveField(type, "category"),
ResolveField(type, "detectedBy"),
ResolveField(type, "detail")));
BuildAccessor(type, "success"),
BuildAccessor(type, "category"),
BuildAccessor(type, "detectedBy"),
BuildAccessor(type, "detail")));
}
/// <summary>
/// Builds the accessor for one status field. Every OnDataChange carries a
/// status array, so this read is on the hot event path: a public field on
/// a publicly visible type is read through a delegate compiled once from
/// an expression tree, which drops the boxed <see cref="object"/> that
/// <see cref="FieldInfo.GetValue(object)"/> allocates per field per status
/// entry, plus the <see cref="IConvertible"/> dispatch behind
/// <see cref="System.Convert.ToInt32(object, IFormatProvider)"/>.
/// <para>
/// The value is required to be identical, so the compiled path is
/// taken only for field types whose conversion to <see cref="int"/>
/// is a lossless widening (see <see cref="IsWideningToInt32"/>) —
/// the interop <c>MXSTATUS_PROXY</c> declares its four fields as
/// 16/32-bit integers. Field types where the CLR conversion and
/// <c>Convert.ToInt32</c> can disagree (bool, char, floating point,
/// unsigned 32/64-bit, or a reference type that could be null) keep
/// the reflection read verbatim, as does a non-visible type, whose
/// members a compiled delegate is not permitted to touch.
/// </para>
/// </summary>
/// <param name="valueType">Runtime type of the status object.</param>
/// <param name="fieldName">Name of the status field to read.</param>
/// <returns>A delegate reading the field as an <see cref="int"/>.</returns>
private static Func<object, int> BuildAccessor(
Type valueType,
string fieldName)
{
FieldInfo field = ResolveField(valueType, fieldName);
if (valueType.IsVisible && IsWideningToInt32(field.FieldType))
{
try
{
ParameterExpression parameter = Expression.Parameter(typeof(object), "status");
// Unbox (not Convert) for a struct: it addresses the boxed
// instance in place rather than copying it out before the load.
Expression instance = valueType.IsValueType
? Expression.Unbox(parameter, valueType)
: Expression.Convert(parameter, valueType);
Expression widened = Expression.Convert(Expression.Field(instance, field), typeof(int));
return Expression.Lambda<Func<object, int>>(widened, parameter).Compile();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Compiling the accessor is an optimization, never a requirement:
// if the runtime refuses it the reflection read below still
// produces the same value.
}
}
return status => ReadInt32Field(status, valueType, field);
}
/// <summary>
/// Reports whether reading a field of this type as an <see cref="int"/>
/// via the CLR's own conversion is lossless and total, and therefore
/// indistinguishable from <see cref="System.Convert.ToInt32(object, IFormatProvider)"/>.
/// Enums answer with their underlying type code, which is the intent.
/// </summary>
/// <param name="fieldType">Declared type of the status field.</param>
/// <returns><see langword="true"/> when the compiled accessor is safe to use.</returns>
private static bool IsWideningToInt32(Type fieldType)
{
switch (Type.GetTypeCode(fieldType))
{
case TypeCode.SByte:
case TypeCode.Byte:
case TypeCode.Int16:
case TypeCode.UInt16:
case TypeCode.Int32:
return true;
default:
return false;
}
}
private static FieldInfo ResolveField(
@@ -179,17 +257,17 @@ public sealed class MxStatusProxyConverter
}
/// <summary>
/// The four resolved status fields cached per type. Plain readonly struct
/// (not a record) so it compiles under the worker's net48 target, which
/// lacks <c>IsExternalInit</c>.
/// The four resolved status-field accessors cached per type. Plain
/// readonly struct (not a record) so it compiles under the worker's net48
/// target, which lacks <c>IsExternalInit</c>.
/// </summary>
private readonly struct StatusFields
{
public StatusFields(
FieldInfo success,
FieldInfo category,
FieldInfo detectedBy,
FieldInfo detail)
Func<object, int> success,
Func<object, int> category,
Func<object, int> detectedBy,
Func<object, int> detail)
{
Success = success;
Category = category;
@@ -197,12 +275,12 @@ public sealed class MxStatusProxyConverter
Detail = detail;
}
public FieldInfo Success { get; }
public Func<object, int> Success { get; }
public FieldInfo Category { get; }
public Func<object, int> Category { get; }
public FieldInfo DetectedBy { get; }
public Func<object, int> DetectedBy { get; }
public FieldInfo Detail { get; }
public Func<object, int> Detail { get; }
}
}
@@ -15,6 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
public sealed class WorkerPipeSession
{
// Fallback ceiling for the event drain loop's wait — not a poll period. MxAccessEventQueue
// signals on enqueue (and on a recorded fault), so the loop wakes as soon as there is something
// to ship instead of paying up to this interval of latency on every burst from idle, and an
// idle worker parks instead of waking 40x/s. The interval survives only as the bound on how
// long the loop may sleep unsignalled, which keeps its DrainFault() poll on a known cadence.
private static readonly TimeSpan EventDrainInterval = TimeSpan.FromMilliseconds(25);
private static readonly TimeSpan BackgroundTaskStopTimeout = TimeSpan.FromSeconds(1);
private const uint EventDrainBatchSize = 128;
@@ -367,7 +372,15 @@ public sealed class WorkerPipeSession
IReadOnlyList<WorkerEvent> events = runtimeSession.DrainEvents(EventDrainBatchSize);
if (events.Count == 0)
{
await Task.Delay(EventDrainInterval, cancellationToken).ConfigureAwait(false);
// Wait on the queue's wake signal rather than sleeping a fixed tick: an event
// enqueued by the STA completes this immediately, so the first event of a burst is
// framed at signal latency instead of waiting out EventDrainInterval, and a session
// with no traffic stops waking at all. The wait's outcome is intentionally ignored —
// whether a signal or the fallback ended it, the next pass re-checks DrainFault()
// and re-drains, which is also why one coalesced wake for many enqueues is safe.
await runtimeSession
.WaitForEventsAsync(EventDrainInterval, cancellationToken)
.ConfigureAwait(false);
continue;
}
@@ -57,6 +57,28 @@ public interface IWorkerRuntimeSession : IDisposable
/// <returns>The drained events and the truncation facts describing what stayed queued.</returns>
WorkerEventDrainResult DrainEvents(uint maxEvents, int maxTotalBytes);
/// <summary>
/// Waits until the outbound event queue has something for the caller to look at (an
/// enqueued event or a recorded fault), the fallback timeout elapses, or the token is
/// cancelled. Lets a drain loop be signal-driven instead of polling.
/// </summary>
/// <remarks>
/// Declared on the interface because the pipe session only ever sees an
/// <see cref="IWorkerRuntimeSession"/>, never the queue behind it; .NET Framework 4.8 has
/// no default interface members, so every implementation supplies it. The wait's outcome is
/// not surfaced: the caller re-drains and re-checks <see cref="DrainFault"/> after every
/// wait, because the signal is coalesced and the timeout is a ceiling, not a poll period.
/// <para>
/// Precondition: <b>at most one waiter</b>. The implementation's wake signal carries a
/// single permit, which is sufficient only because a session has exactly one event
/// drain loop; a second concurrent waiter would degrade to the fallback timeout.
/// </para>
/// </remarks>
/// <param name="timeout">Maximum time to wait before the wait completes unsignalled.</param>
/// <param name="cancellationToken">Token cancelling the wait.</param>
/// <returns>A task that completes when the queue is signalled or the timeout elapses.</returns>
Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>
/// Drains a pending fault from the queue, if any.
/// </summary>
@@ -100,6 +100,20 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = new MxStatusProxyConverter();
this.alarmCommandHandler = alarmCommandHandler;
// The no-op fallback is a TEST affordance only — never pass null on a
// real STA. The cache wait loops this pump step feeds
// (MxAccessValueCache.TryWaitForUpdate,
// MxAccessWriteCompletionCache.TryWaitForCompletion) block in
// StaWaitHelper.WaitForSignalOrMessages, whose contract is that the pump
// step drains the message queue to empty on every call: the wait wakes on
// input that is merely present, so a pump step that removes nothing makes
// every wait return instantly and turns the loop into a busy spin at 100%
// CPU until its deadline (deadline and reply shape still hold — it is a
// CPU fault, not a correctness one). It is harmless here only because the
// fakes that pass null pre-populate the caches and run on threads with no
// windows and no pending input, so there is never a message left behind
// for the wait to keep waking on.
this.pumpStep = pumpStep ?? (static () => { });
this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;
}
@@ -1,4 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -9,6 +11,24 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
public sealed class MxAccessEventMapper
{
/// <summary>
/// Per-culture cache of the explicit timestamp formats
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
/// derived from the culture's own patterns and the worker host's culture
/// is fixed in practice, so the derivation runs once. The cache assumes
/// <see cref="DateTimeFormatInfo"/> patterns are not customized or
/// mutated at runtime — <see cref="CultureInfo"/> compares by name, so a
/// culture carrying custom patterns is served the formats derived for its
/// stock namesake. That direction is safe: a stale format simply stops
/// matching, and the unchanged general chain below still parses the
/// string.
/// </summary>
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
/// <summary>Cached factory delegate so the dictionary lookup allocates nothing on the event path.</summary>
private static readonly Func<CultureInfo, string[]> TimestampFormatFactory = BuildTimestampFormats;
private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter;
@@ -353,6 +373,23 @@ public sealed class MxAccessEventMapper
/// wall-clock UTC). The parsed value is therefore taken as local time
/// and converted to UTC. Tries the worker host's culture first
/// (MXAccess formats with the host locale), then the invariant culture.
/// <para>
/// PERF: every OnDataChange carries one of these strings, and the
/// general <see cref="DateTime.TryParse(string, IFormatProvider, DateTimeStyles, out DateTime)"/>
/// has to walk the culture's whole pattern set to recognize it. The
/// shapes MXAccess actually emits are tried first with
/// <see cref="DateTime.TryParseExact(string, string[], IFormatProvider, DateTimeStyles, out DateTime)"/>.
/// The formats are <em>derived from the current culture's own</em>
/// short-date and long-time patterns (see
/// <see cref="BuildTimestampFormats"/>) rather than hard-coded to the
/// US shapes seen in captures: a hard-coded <c>M/d/yyyy</c> would
/// reorder month and day on a day-first host relative to what the
/// general parse below returns. Deriving them means any string the
/// exact parse accepts is one the culture-aware general parse would
/// have accepted with the same value, so the fast path is a shortcut
/// and never a different answer; anything it does not match falls
/// through to the unchanged two-stage chain.
/// </para>
/// </summary>
/// <param name="text">The MXAccess timestamp string.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
@@ -366,7 +403,14 @@ public sealed class MxAccessEventMapper
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
CultureInfo culture = CultureInfo.CurrentCulture;
// Empty when the culture's patterns yielded no format that survived
// validation; that culture just gets no fast path.
string[] formats = TimestampFormatCache.GetOrAdd(culture, TimestampFormatFactory);
DateTime parsed;
if ((formats.Length > 0 && DateTime.TryParseExact(text, formats, culture, styles, out parsed))
|| DateTime.TryParse(text, culture, styles, out parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
@@ -376,6 +420,90 @@ public sealed class MxAccessEventMapper
return false;
}
/// <summary>
/// Builds the explicit timestamp formats for a culture: the culture's
/// short date plus its long time, and — first, because it is the shape
/// MXAccess actually fires — the same pattern with the millisecond
/// fraction MXAccess appends to the seconds ("3/26/2026 1:38:22.907 PM"
/// under en-US). No culture publishes a long-time pattern with
/// milliseconds, so the fractional sibling is derived by inserting
/// <c>.fff</c> after the seconds specifier; a pattern without a
/// two-digit seconds specifier, or one that already carries a fraction,
/// gets only the published pattern.
/// <para>
/// Both candidates are validated by round-tripping a probe instant
/// before they are handed to the event path, because the derivation
/// reads patterns it does not control: the first <c>ss</c> in a
/// pattern could in principle sit inside quoted literal text, and
/// inserting into it would produce a format that either mangles the
/// value or is outright malformed — and a malformed format makes
/// <see cref="DateTime.ParseExact(string, string, IFormatProvider)"/>
/// and its siblings throw <see cref="FormatException"/> rather than
/// report failure. Validation happens here, once per culture, so the
/// event path never carries that risk; a candidate that does not
/// round-trip is dropped, and a culture that loses both simply gets
/// no fast path.
/// </para>
/// </summary>
/// <param name="culture">Culture whose patterns the formats are derived from.</param>
/// <returns>The validated formats to try, most likely first; empty when none survived.</returns>
private static string[] BuildTimestampFormats(CultureInfo culture)
{
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
string shortDate = formatInfo.ShortDatePattern;
string longTime = formatInfo.LongTimePattern;
List<string> formats = new(2);
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
{
string withMilliseconds = shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff");
if (RoundTripsProbe(withMilliseconds, culture, new DateTime(2026, 3, 26, 13, 38, 22, 907)))
{
formats.Add(withMilliseconds);
}
}
string published = shortDate + " " + longTime;
if (RoundTripsProbe(published, culture, new DateTime(2026, 3, 26, 13, 38, 22)))
{
formats.Add(published);
}
return formats.ToArray();
}
/// <summary>
/// Reports whether a derived format both formats and re-parses a probe
/// instant back to itself under the given culture. A format that throws,
/// fails to parse its own output, or loses part of the instant is
/// rejected — the fast path may only ever be a shortcut.
/// </summary>
/// <param name="format">Candidate format to validate.</param>
/// <param name="culture">Culture the format was derived from.</param>
/// <param name="probe">Instant to round-trip; carries the precision the format must preserve.</param>
/// <returns><see langword="true"/> when the format is safe to use on the event path.</returns>
private static bool RoundTripsProbe(
string format,
CultureInfo culture,
DateTime probe)
{
try
{
string probeText = probe.ToString(format, culture);
return DateTime.TryParseExact(probeText, format, culture, DateTimeStyles.None, out DateTime roundTripped)
&& roundTripped == probe;
}
catch (FormatException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
private MxArray ConvertBufferedArray(
object? value,
MxDataType expectedElementDataType)
@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -18,6 +22,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// MxAccessAlarmEventSink, AlarmCommandHandler) build a new event per
/// Enqueue via the mapper and satisfy this; the value cache stores its own
/// independent snapshot (see <see cref="MxAccessValueCache.Set"/>).
/// <para>
/// The byte-budgeted <see cref="Drain(uint, int)"/> relies on the same invariant: each
/// event's serialized size is measured once at enqueue and stored beside it, which is
/// only sound because an enqueued event is never mutated again.
/// </para>
/// </remarks>
public sealed class MxAccessEventQueue
{
@@ -26,6 +35,31 @@ public sealed class MxAccessEventQueue
/// </summary>
public const int DefaultCapacity = 10000;
/// <summary>
/// Environment variable the gateway's <c>WorkerProcessLauncher</c> sets
/// from <c>MxGateway:Worker:EventQueueCapacity</c>. A missing,
/// unparseable, or out-of-range value falls back to
/// <see cref="DefaultCapacity"/> — a bad environment value must never
/// keep the session from starting.
/// </summary>
internal const string CapacityEnvironmentVariableName = "MXGATEWAY_EVENT_QUEUE_CAPACITY";
/// <summary>
/// Floor on the resolved capacity. Mirrors the gateway-side
/// <c>MxGateway:Worker:EventQueueCapacity</c> minimum so a value that
/// slipped past startup validation still leaves the headroom the queue
/// needs: it has no drop policy, so an overflow faults the session.
/// </summary>
internal const int MinimumCapacity = 1000;
/// <summary>
/// Ceiling on the resolved capacity, mirroring the gateway-side maximum.
/// The backing queue pre-allocates its slots, so an absurd environment
/// value would otherwise cost the 32-bit worker that allocation at
/// startup.
/// </summary>
internal const int MaximumCapacity = 1_000_000;
// Extra per-event slack added to WorkerEvent.CalculateSize() when charging the byte budget in
// Drain(maxEvents, maxTotalBytes). CalculateSize() already accounts for the event's own tag and
// length-delimiter (the WorkerEvent wrapper serializes MxEvent as field 1, and the reply packs
@@ -35,9 +69,35 @@ public sealed class MxAccessEventQueue
// edge can never push the packed reply past the frame maximum.
private const int RepeatedFieldOverheadBytes = 8;
// Every field the enqueue-time size reconstruction charges by hand — WorkerEvent.event (field
// 1), MxEvent.worker_sequence (9) and MxEvent.worker_timestamp (10) — has a field number below
// 16, so protobuf encodes each of their tags in a single byte. Renumbering any of them above 15
// would widen its tag and make this estimate one byte light per field, which the
// RepeatedFieldOverheadBytes slack absorbs — so the frame stays safe — but the constant should
// be revisited with the .proto.
private const int SingleByteFieldTagBytes = 1;
private readonly int capacity;
private readonly Queue<WorkerEvent> events;
private readonly Queue<QueuedEvent> events;
private readonly object syncRoot = new();
// Wake signal for the worker's event drain loop (see WorkerPipeSession.RunEventDrainLoopAsync).
// Capped at one permit, so it is a level ("there is something to look at"), never a count: a
// burst of enqueues coalesces into one wake and the waiter re-drains everything that arrived.
// Never released while holding syncRoot — the STA thread enqueues here, so its path must stay a
// lock acquire plus a non-blocking Release. Deliberately not disposed: the queue outlives its
// waiter, no wait handle is ever materialized (nothing touches AvailableWaitHandle), and making
// the queue IDisposable would only add a lifecycle race between the STA's Enqueue and the
// drain loop's wait. WorkerFrameWriter's _writeLock is held the same way.
//
// It must STAY a SemaphoreSlim: on .NET Framework 4.8, Release() hands an async waiter to the
// thread pool (the waiter is a TaskNode : IThreadPoolWorkItem) rather than completing it
// inline, so the STA thread's Enqueue never runs the drain loop's continuation. Swapping this
// for a TaskCompletionSource — a refactor that looks cosmetic — would complete the waiter
// inline on the caller and put the drain loop's pipe write on the apartment thread, which is a
// real STA/COM reentrancy hazard (and stalls the message pump behind the write).
private readonly SemaphoreSlim eventSignal = new SemaphoreSlim(initialCount: 0, maxCount: 1);
private ulong lastEventSequence;
private WorkerFault? fault;
private bool faultDrained;
@@ -64,7 +124,29 @@ public sealed class MxAccessEventQueue
}
this.capacity = capacity;
events = new Queue<WorkerEvent>(capacity);
events = new Queue<QueuedEvent>(capacity);
}
/// <summary>
/// Resolves the queue capacity from the launcher-provided environment
/// variable. A missing, unparseable, or out-of-range value falls back to
/// <see cref="DefaultCapacity"/> rather than throwing: running on the
/// default capacity is always safe, and a worker that refuses to start
/// over a mistyped environment variable is not.
/// </summary>
/// <returns>The capacity to construct the outbound event queue with.</returns>
internal static int ResolveCapacity()
{
string? value = Environment.GetEnvironmentVariable(CapacityEnvironmentVariableName);
return int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int capacity)
&& capacity >= MinimumCapacity
&& capacity <= MaximumCapacity
? capacity
: DefaultCapacity;
}
/// <summary>
@@ -142,33 +224,98 @@ public sealed class MxAccessEventQueue
throw new ArgumentNullException(nameof(mxEvent));
}
lock (syncRoot)
// Size the event here, outside syncRoot, and carry the result into the queue. The
// byte-budgeted Drain used to call CalculateSize() on every candidate while holding the
// lock, so a large gateway-pulled drain kept the STA's next Enqueue blocked for the whole
// sizing walk — exactly when a burst of COM callbacks needs the lock. Measuring on the
// enqueuing thread is safe on both counts: that thread owns this event exclusively at this
// point, and WRK-11 (see the class remarks) makes the hand-off final — nothing mutates an
// event after Enqueue, so a memoized size can never go stale.
//
// Enqueue itself stamps the worker sequence and worker timestamp, and those must stay under
// the lock (the sequence has to be assigned in queue order), so they are excluded from this
// measurement and charged back by CalculateWorkerEventSize with O(1) arithmetic instead of a
// second walk of the message. Clearing them first is observationally a no-op — both are
// overwritten unconditionally below — and it makes this measurement exactly "the event minus
// the two stamped fields" even if a caller pre-filled them.
//
// CalculateSize() on a valid protobuf message does not throw, so moving it ahead of the
// fault and overflow checks introduces no new failure before them; an event those checks
// reject simply discards the size computed here.
mxEvent.WorkerSequence = 0UL;
mxEvent.WorkerTimestamp = null;
int unstampedEventSize = mxEvent.CalculateSize();
try
{
if (fault is not null)
lock (syncRoot)
{
throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
if (fault is not null)
{
throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
}
if (events.Count >= capacity)
{
fault = CreateOverflowFault();
throw new MxAccessEventQueueOverflowException(capacity);
}
// WRK-11: stamp the sequence/timestamp on the caller's own event and
// enqueue that same instance under the lock instead of cloning. See
// the ownership invariant on the class summary — the caller hands the
// event over exclusively, so a defensive Clone() here is pure
// overhead on the hottest path.
mxEvent.WorkerSequence = ++lastEventSequence;
mxEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
WorkerEvent workerEvent = new()
{
Event = mxEvent,
};
events.Enqueue(new QueuedEvent(
workerEvent,
CalculateWorkerEventSize(mxEvent, unstampedEventSize)));
}
if (events.Count >= capacity)
{
fault = CreateOverflowFault();
throw new MxAccessEventQueueOverflowException(capacity);
}
// WRK-11: stamp the sequence/timestamp on the caller's own event and
// enqueue that same instance under the lock instead of cloning. See
// the ownership invariant on the class summary — the caller hands the
// event over exclusively, so a defensive Clone() here is pure
// overhead on the hottest path.
mxEvent.WorkerSequence = ++lastEventSequence;
mxEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
WorkerEvent workerEvent = new()
{
Event = mxEvent,
};
events.Enqueue(workerEvent);
}
finally
{
// Wake the drain loop outside the lock, on every exit path: the success path has an
// event to ship, and the overflow path has just self-recorded the fault the loop
// reports through DrainFault(). Signalling in a finally also keeps the already-faulted
// rejection harmless — a spurious wake only costs the waiter one empty re-drain.
SignalWake();
}
}
/// <summary>
/// Waits for a wake signal, the fallback timeout, or cancellation, so the worker's event
/// drain loop is signal-driven instead of polling. <see cref="Enqueue"/> and
/// <see cref="RecordFault"/> both signal, so an event arriving at an idle queue is picked
/// up immediately rather than at the caller's next tick, and an idle queue costs no wakeups
/// at all.
/// </summary>
/// <remarks>
/// The wait's outcome is deliberately not surfaced: the caller must re-drain (and re-check
/// <see cref="DrainFault"/>) after every wait regardless of whether a signal or the timeout
/// ended it, because the signal is coalesced — one pending wake can stand for any number of
/// enqueues. The timeout is therefore a ceiling on how long the caller may sleep, not a
/// poll period: it bounds how late a state change reaches the caller if some future path
/// mutates the queue without signalling.
/// <para>
/// Precondition: <b>at most one waiter</b>. One permit is sufficient only because the
/// queue has exactly one consumer (the worker's single event drain loop). A second
/// concurrent waiter would find no permit left for it and degrade to the fallback
/// timeout, so a design that needs multiple drainers must widen the signal rather than
/// reuse this one.
/// </para>
/// </remarks>
/// <param name="timeout">Maximum time to wait before the wait completes unsignalled.</param>
/// <param name="cancellationToken">Token cancelling the wait.</param>
/// <returns>A task that completes when the queue is signalled or the timeout elapses.</returns>
public Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
return eventSignal.WaitAsync(timeout, cancellationToken);
}
/// <summary>
@@ -186,7 +333,7 @@ public sealed class MxAccessEventQueue
return false;
}
workerEvent = events.Dequeue();
workerEvent = events.Dequeue().Event;
return true;
}
}
@@ -211,7 +358,7 @@ public sealed class MxAccessEventQueue
List<WorkerEvent> drained = new(drainCount);
for (int index = 0; index < drainCount; index++)
{
drained.Add(events.Dequeue());
drained.Add(events.Dequeue().Event);
}
return drained;
@@ -225,11 +372,13 @@ public sealed class MxAccessEventQueue
/// <remarks>
/// The size decision happens inside the queue lock, so an event is dequeued only once it is
/// known to fit: an event that does not fit stays at the head for the next call and is never
/// lost (WRK-21). Per-event cost is <c>WorkerEvent.CalculateSize()</c> — which already
/// includes the event's own tag and length prefix, the same shape the reply's
/// <c>events</c> repeated field packs it into — plus <see cref="RepeatedFieldOverheadBytes"/>
/// of pure slack, so the running total stays strictly ahead of the true serialized size and
/// the estimate errs on the safe side.
/// lost (WRK-21). Per-event cost is the serialized <c>WorkerEvent</c> size measured once at
/// enqueue time — which already includes the event's own tag and length prefix, the same
/// shape the reply's <c>events</c> repeated field packs it into — plus
/// <see cref="RepeatedFieldOverheadBytes"/> of pure slack, so the running total stays
/// strictly ahead of the true serialized size and the estimate errs on the safe side. Only
/// the memoized number is read here: this loop no longer walks a message under the lock the
/// STA needs for <see cref="Enqueue"/>, so a large drain cannot stall COM callbacks.
/// </remarks>
/// <param name="maxEvents">Maximum number of events to drain; 0 means "no count limit".</param>
/// <param name="maxTotalBytes">Byte budget for the drained events' estimated serialized size.</param>
@@ -248,8 +397,8 @@ public sealed class MxAccessEventQueue
while (drained.Count < countLimit && events.Count > 0)
{
WorkerEvent head = events.Peek();
int cost = head.CalculateSize() + RepeatedFieldOverheadBytes;
QueuedEvent head = events.Peek();
int cost = head.Size + RepeatedFieldOverheadBytes;
if (cost > remainingBudget)
{
truncatedBySize = true;
@@ -258,14 +407,14 @@ public sealed class MxAccessEventQueue
// The head alone cannot fit this budget, so repeating the call will not
// move it either. Report its sequence instead of silently stalling; the
// events that did fit are still returned.
oversizedHeadSequence = head.Event?.WorkerSequence ?? 0;
oversizedHeadSequence = head.Event.Event?.WorkerSequence ?? 0;
}
break;
}
remainingBudget -= cost;
drained.Add(events.Dequeue());
drained.Add(events.Dequeue().Event);
}
return new WorkerEventDrainResult(
@@ -291,6 +440,11 @@ public sealed class MxAccessEventQueue
{
fault ??= workerFault.Clone();
}
// The drain loop discovers faults by polling DrainFault() at the top of each pass, so a
// fault recorded while it is parked must wake it — otherwise the fault would be reported
// no sooner than the loop's fallback tick. Signalled outside the lock, as Enqueue does.
SignalWake();
}
/// <summary>
@@ -311,6 +465,46 @@ public sealed class MxAccessEventQueue
}
}
private void SignalWake()
{
// Fast path: a wake is already pending and no waiter has consumed it yet, so releasing
// again would only throw. This keeps a burst of enqueues on the STA at a single volatile
// read once the first event has signalled.
if (eventSignal.CurrentCount > 0)
{
return;
}
try
{
eventSignal.Release();
}
catch (SemaphoreFullException)
{
// A concurrent producer won the race between the check above and this Release. The
// pending wake it published is exactly the wake this call wanted, so there is nothing
// to do — the signal is a level, not a count.
}
}
// Serialized size of the WorkerEvent wrapping mxEvent, rebuilt from the size measured before the
// lock plus the exact cost of the two fields Enqueue stamps inside it. It mirrors the generated
// CalculateSize() instead of calling it, because calling it again under the lock is the cost
// this memoization exists to remove: WorkerEvent carries the single length-delimited `event`
// field, MxEvent charges worker_sequence as tag + varint and worker_timestamp as tag +
// length-delimited message, and both are always serialized here — the sequence is
// pre-incremented, so it is never the proto3 default that generated code would skip, and the
// timestamp is never null. Exactness matters: Drain must charge the same bytes it charged when
// it sized events itself, so an unchanged budget still yields unchanged batches.
private static int CalculateWorkerEventSize(MxEvent mxEvent, int unstampedEventSize)
{
int eventSize = unstampedEventSize
+ SingleByteFieldTagBytes + CodedOutputStream.ComputeUInt64Size(mxEvent.WorkerSequence)
+ SingleByteFieldTagBytes + CodedOutputStream.ComputeMessageSize(mxEvent.WorkerTimestamp);
return SingleByteFieldTagBytes + CodedOutputStream.ComputeLengthSize(eventSize) + eventSize;
}
private WorkerFault CreateOverflowFault()
{
string message = $"MXAccess outbound event queue reached capacity {capacity}.";
@@ -325,4 +519,24 @@ public sealed class MxAccessEventQueue
},
};
}
// A queued event and the serialized size measured for it at enqueue time. A struct, so carrying
// the size costs the queue one extra field per slot rather than an allocation per event on the
// STA's path. Nothing outside the queue sees it: every drain hands back the WorkerEvent alone,
// exactly as before.
private readonly struct QueuedEvent
{
public QueuedEvent(WorkerEvent workerEvent, int size)
{
Event = workerEvent;
Size = size;
}
// The queued event, returned unchanged by TryDequeue and both Drain overloads.
public WorkerEvent Event { get; }
// Serialized size of Event, valid for the queue's whole lifetime because an enqueued event
// is never mutated again (WRK-11).
public int Size { get; }
}
}
@@ -19,7 +19,35 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
internal const string WriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
/// <summary>
/// Environment variable the gateway's WorkerProcessLauncher sets from
/// MxGateway:Alarms:PollIntervalMilliseconds. A missing or invalid
/// value falls back to <see cref="DefaultAlarmPollInterval"/>.
/// </summary>
internal const string AlarmPollIntervalEnvironmentVariableName =
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
/// <summary>
/// Floor on the resolved alarm poll cadence. Mirrors the gateway-side
/// MxGateway:Alarms:PollIntervalMilliseconds minimum so a value that
/// slipped past validation (or an environment set by hand) still can
/// not starve the STA's command path.
/// </summary>
internal static readonly TimeSpan MinimumAlarmPollInterval = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Ceiling on the resolved alarm poll cadence (one hour). Mirrors the
/// gateway-side MxGateway:Alarms:PollIntervalMilliseconds maximum.
/// Without it, int.MaxValue milliseconds — which passed startup
/// validation before this existed — silently disables alarm polling
/// for ~24 days rather than reporting a misconfiguration.
/// </summary>
internal static readonly TimeSpan MaximumAlarmPollInterval = TimeSpan.FromMilliseconds(3_600_000);
/// <summary>Default alarm poll cadence when the environment says nothing usable.</summary>
internal static readonly TimeSpan DefaultAlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan alarmPollInterval = ResolveAlarmPollInterval();
private readonly IMxAccessComObjectFactory factory;
private readonly IMxAccessEventSink eventSink;
@@ -44,12 +72,15 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default dependencies.
/// The outbound event queue is sized from the launcher-provided
/// <c>MXGATEWAY_EVENT_QUEUE_CAPACITY</c> (see <see cref="MxAccessEventQueue.ResolveCapacity"/>);
/// callers that pass their own queue keep full control of its capacity.
/// </summary>
public MxAccessStaSession()
: this(
new StaRuntime(),
new MxAccessComObjectFactory(),
new MxAccessEventQueue())
new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity()))
{
}
@@ -65,7 +96,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
: this(
new StaRuntime(),
new MxAccessComObjectFactory(),
new MxAccessEventQueue(),
new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity()),
alarmCommandHandlerFactory)
{
}
@@ -80,7 +111,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink)
: this(staRuntime, factory, eventSink, new MxAccessEventQueue())
: this(staRuntime, factory, eventSink, new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity()))
{
}
@@ -191,6 +222,33 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
}
/// <summary>
/// Resolves the alarm poll cadence from the launcher-provided
/// environment variable. A missing, unparseable, or out-of-range value
/// falls back to <see cref="DefaultAlarmPollInterval"/> rather than
/// faulting the worker: an alarm poll that runs at the default cadence
/// is always safe, and a session that refuses to start over a
/// mistyped environment variable is not.
/// </summary>
/// <returns>The cadence the alarm poll loop waits between polls.</returns>
internal static TimeSpan ResolveAlarmPollInterval()
{
string? value = Environment.GetEnvironmentVariable(AlarmPollIntervalEnvironmentVariableName);
if (!int.TryParse(
value,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int milliseconds))
{
return DefaultAlarmPollInterval;
}
TimeSpan resolved = TimeSpan.FromMilliseconds(milliseconds);
return resolved < MinimumAlarmPollInterval || resolved > MaximumAlarmPollInterval
? DefaultAlarmPollInterval
: resolved;
}
/// <summary>
/// Starts the MXAccess COM session asynchronously.
/// </summary>
@@ -274,7 +332,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
{
try
{
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
await Task.Delay(alarmPollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -455,6 +513,12 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
return eventQueue.Drain(maxEvents, maxTotalBytes);
}
/// <inheritdoc />
public Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
return eventQueue.WaitForEventsAsync(timeout, cancellationToken);
}
/// <inheritdoc />
public WorkerFault? DrainFault()
{
@@ -32,11 +32,26 @@ public sealed class MxAccessValueCache
// the value is recorded from inside the waiter's own pumpStep (COM
// dispatches OnDataChange on the STA), so the message wake is what does the
// work there; this handle covers the cross-thread producers — the tests, and
// any future off-STA writer. Not IDisposable on purpose: the cache is
// co-owned by MxAccessBaseEventSink and MxAccessSession with no single
// teardown owner, and a late COM callback must never Set a disposed handle.
// One kernel event per session (one session per worker process), released by
// the handle's finalizer.
// any future off-STA writer.
//
// Single-waiter invariant: an AutoResetEvent releases exactly one waiter per
// Set. Today the sole waiter is the STA (one worker per session, and ReadBulk
// holds the apartment while it waits), so no wake is ever lost. A second
// concurrent waiter would not hang — the loser still re-checks the entry
// every fallback tick and still honors its deadline — it would just fall
// back to 50 ms latency for that iteration.
//
// NOT IDisposable, and that is one invariant with the wait's use of
// DangerousGetHandle: the cache is co-owned by MxAccessBaseEventSink and
// MxAccessSession with no single teardown owner, so a late COM callback
// could otherwise Set a disposed handle. StaWaitHelper deliberately does not
// DangerousAddRef the handle; GC.KeepAlive there closes the *finalization*
// race but not a dispose race — if this field were ever disposed while the
// STA sat inside the wait, the OS could recycle that handle value and the
// wait would silently attach to an unrelated object. Making these caches
// IDisposable is therefore not a free refactor: it must add DangerousAddRef/
// DangerousRelease around the wait in the same change. One kernel event per
// session (one session per worker process), released by the finalizer.
private readonly AutoResetEvent updateSignal = new(false);
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
@@ -31,11 +31,26 @@ public sealed class MxAccessWriteCompletionCache
// worker the completion is recorded from inside the waiter's own pumpStep
// (COM dispatches OnWriteComplete on the STA), so the message wake is what
// does the work there; this handle covers the cross-thread producers — the
// tests, and any future off-STA recorder. Not IDisposable on purpose: the
// cache is co-owned by MxAccessBaseEventSink and MxAccessSession with no
// single teardown owner, and a late COM callback must never Set a disposed
// handle. One kernel event per session (one session per worker process),
// released by the handle's finalizer.
// tests, and any future off-STA recorder.
//
// Single-waiter invariant: an AutoResetEvent releases exactly one waiter per
// Set. Today the sole waiter is the STA (one worker per session, and the
// write path holds the apartment while it waits), so no wake is ever lost. A
// second concurrent waiter would not hang — the loser still re-checks the
// entry every fallback tick and still honors its deadline — it would just
// fall back to 50 ms latency for that iteration.
//
// NOT IDisposable, and that is one invariant with the wait's use of
// DangerousGetHandle: the cache is co-owned by MxAccessBaseEventSink and
// MxAccessSession with no single teardown owner, so a late COM callback
// could otherwise Set a disposed handle. StaWaitHelper deliberately does not
// DangerousAddRef the handle; GC.KeepAlive there closes the *finalization*
// race but not a dispose race — if this field were ever disposed while the
// STA sat inside the wait, the OS could recycle that handle value and the
// wait would silently attach to an unrelated object. Making these caches
// IDisposable is therefore not a free refactor: it must add DangerousAddRef/
// DangerousRelease around the wait in the same change. One kernel event per
// session (one session per worker process), released by the finalizer.
private readonly AutoResetEvent completionSignal = new(false);
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
@@ -49,11 +50,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private const string DefaultVersion = "1.0";
private const int DefaultMaxAlarmsPerFetch = 1024;
/// <summary>
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> minimum so a value that
/// slipped past startup validation still leaves a usable snapshot
/// window.
/// </summary>
internal const int MinimumMaxAlarmsPerFetch = 64;
/// <summary>
/// Ceiling on the resolved per-fetch cap. Mirrors the gateway-side
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> maximum. The worker is a
/// 32-bit process: every poll materializes the whole reply as one BSTR
/// and then a full <see cref="XmlDocument"/> over it, so an unbounded
/// cap (<see cref="int.MaxValue"/> passed startup validation before
/// this existed) is an out-of-memory fault on the STA, not a slow poll.
/// </summary>
internal const int MaximumMaxAlarmsPerFetch = 65_536;
/// <summary>
/// Environment variable the gateway's <c>WorkerProcessLauncher</c>
/// sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
/// unparseable, or out-of-range value falls back to
/// <see cref="DefaultMaxAlarmsPerFetch"/> — a bad environment value
/// must never keep the alarm consumer from starting.
/// </summary>
internal const string MaxAlarmsPerFetchEnvironmentVariableName =
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <summary>
/// Minimum gap between truncated-fetch warnings. A galaxy parked above
/// the cap truncates on *every* poll, so an unthrottled warning would
/// be two log lines a second forever.
/// </summary>
private const long TruncationWarningIntervalMilliseconds = 60_000;
private readonly object syncRoot = new object();
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
private readonly int maxAlarmsPerFetch;
private readonly Stopwatch truncationWarningClock = Stopwatch.StartNew();
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
private long truncatedFetchCount;
private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private bool subscribed;
@@ -67,7 +106,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// every COM call stays on the STA that owns the apartment.
/// </summary>
public WnWrapAlarmConsumer()
: this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch)
: this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
{
}
@@ -86,6 +125,31 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
: DefaultMaxAlarmsPerFetch;
}
/// <summary>
/// Resolves the per-fetch cap from the launcher-provided environment
/// variable. A missing, unparseable, or out-of-range value falls back
/// to <see cref="DefaultMaxAlarmsPerFetch"/> rather than throwing:
/// polling at the default cap is always safe, and a session that
/// refuses to subscribe over a mistyped environment variable is not.
/// The ceiling is enforced here as well as in the gateway validator so
/// a hand-set environment (or a future launcher bug) cannot hand the
/// x86 worker a cap that faults it on the first poll.
/// </summary>
/// <returns>The cap passed to <c>GetXmlCurrentAlarms2</c>.</returns>
internal static int ResolveMaxAlarmsPerFetch()
{
string? value = Environment.GetEnvironmentVariable(MaxAlarmsPerFetchEnvironmentVariableName);
return int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int cap)
&& cap >= MinimumMaxAlarmsPerFetch
&& cap <= MaximumMaxAlarmsPerFetch
? cap
: DefaultMaxAlarmsPerFetch;
}
/// <summary>
/// Fires once per detected alarm-state transition (raise, acknowledge,
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
@@ -282,6 +346,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
/// <inheritdoc />
/// <remarks>
/// This is the set the gateway's reconcile pass diffs its cache
/// against, so an alarm missing here is what the gateway reads as a
/// Clear. After a truncated fetch the snapshot deliberately still
/// carries alarms the capped reply could not mention — see
/// <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
/// at-least-once: a stale entry is repaired by the next sub-cap poll,
/// whereas a dropped one is broadcast as a Clear that never happened.
/// </remarks>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
@@ -300,6 +373,16 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
}
/// <summary>
/// Sink for the rate-limited truncated-fetch warning. Defaults to
/// <see cref="Console.Error"/>, the stream
/// <c>WorkerConsoleLogger</c> already writes to, so the line lands in
/// the worker's captured stderr alongside the rest of its log. Tests
/// substitute a collector. The message carries identifiers and counts
/// only — never alarm values, tag names, or comments.
/// </summary>
internal Action<string>? TruncationWarningSink { get; set; }
/// <inheritdoc />
public void PollOnce()
{
@@ -316,17 +399,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
string xml = xmlObj?.ToString() ?? string.Empty;
if (xml.Length == 0) return;
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
// and gives no "there is more" flag, so a reply holding exactly the
// cap is indistinguishable from a galaxy that happens to have exactly
// that many active alarms. Treat the ambiguous case as truncated: the
// false-positive cost is a snapshot that stays stale for one poll, the
// false-negative cost is every alarm past the cap reading as cleared.
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
IReadOnlyList<MxAlarmTransitionEvent> transitions;
int retainedCount;
lock (syncRoot)
{
transitions = ComputeTransitions(latestSnapshot, next);
latestSnapshot.Clear();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
latestSnapshot[kv.Key] = kv.Value;
}
ApplySnapshotUpdate(latestSnapshot, next, truncated);
retainedCount = latestSnapshot.Count;
}
if (truncated)
{
WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
}
if (transitions.Count == 0) return;
@@ -338,6 +432,141 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
}
/// <summary>
/// Decides whether a fetch that came back holding
/// <paramref name="fetchedRecordCount"/> records hit the cap.
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
/// offers no "more available" flag, so a reply at exactly the cap is
/// indistinguishable from a galaxy that happens to hold exactly that
/// many active alarms; both are treated as truncated. Exposed as
/// <c>internal static</c> so the rule is unit-testable without the
/// wnwrapConsumer COM object.
/// </summary>
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
/// <returns><see langword="true"/> when the reply must be treated as truncated.</returns>
internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch)
{
return fetchedRecordCount >= maxAlarmsPerFetch;
}
/// <summary>
/// Folds a freshly fetched snapshot into the retained one.
/// </summary>
/// <remarks>
/// <para>
/// A complete fetch is authoritative about <em>absence</em>:
/// replacing the snapshot wholesale is exactly what lets a cleared
/// alarm drop out of <see cref="SnapshotActiveAlarms"/>, which is
/// the evidence the gateway's reconcile pass turns into a Clear.
/// </para>
/// <para>
/// A truncated fetch is authoritative about <em>presence</em>
/// only. Merging rather than replacing means alarms the capped
/// reply did carry update normally, while alarms it had no room to
/// mention are retained untouched — so the gateway infers no Clear
/// from their absence. The cost is bounded staleness: a genuinely
/// cleared alarm can linger in the snapshot until the first
/// sub-cap fetch evicts it. That direction is the safe one for an
/// at-least-once alarm feed — a late Clear is repaired by the next
/// complete poll, whereas a Clear that never happened is broadcast
/// to every subscriber and cannot be taken back.
/// </para>
/// </remarks>
/// <param name="snapshot">The retained snapshot, updated in place.</param>
/// <param name="next">The snapshot just parsed from the fetch.</param>
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
internal static void ApplySnapshotUpdate(
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot,
Dictionary<Guid, MxAlarmSnapshotRecord> next,
bool truncated)
{
if (snapshot is null) throw new ArgumentNullException(nameof(snapshot));
if (next is null) throw new ArgumentNullException(nameof(next));
if (!truncated)
{
snapshot.Clear();
}
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
snapshot[kv.Key] = kv.Value;
}
}
/// <summary>
/// Emits the truncated-fetch warning at most once per
/// <see cref="TruncationWarningIntervalMilliseconds"/>, counting every
/// truncated poll in between so the operator can tell a one-off burst
/// from a galaxy permanently parked above the cap.
/// </summary>
/// <param name="fetchedRecordCount">ALARM records the capped reply carried.</param>
/// <param name="parsedRecordCount">Records that survived GUID parsing.</param>
/// <param name="retainedCount">Snapshot size after the merge.</param>
private void WarnTruncatedFetch(int fetchedRecordCount, int parsedRecordCount, int retainedCount)
{
long total;
long elapsed;
lock (syncRoot)
{
total = ++truncatedFetchCount;
elapsed = truncationWarningClock.ElapsedMilliseconds;
if (!ShouldWarnTruncation(elapsed, lastTruncationWarningMilliseconds))
{
return;
}
lastTruncationWarningMilliseconds = elapsed;
}
// Format matches WorkerConsoleLogger's "level=<L> event=<E> k=v k=v"
// line shape so the worker's stderr parses uniformly. Deviation, on
// purpose: IWorkerLogger exposes only Information and Error, so
// "Warning" is a third level string no other worker line emits. A
// truncated snapshot is not an error (the poll succeeded and the
// snapshot is safe) but it is not routine either, so downgrading it to
// Information would bury it. Revisit if the truncation signal becomes
// structural — see docs/DesignDecisions.md.
//
// Identifiers and counts only — no tag names, values, limits, or
// comments, per the gateway's "don't log tag values by default" rule.
// The trailing note stays a single bare k=v token (no spaces, no
// semicolons); remediation prose lives in docs/GatewayConfiguration.md.
string message = string.Format(
CultureInfo.InvariantCulture,
"level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
+ "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
+ "note=truncated-snapshot-retained",
maxAlarmsPerFetch,
fetchedRecordCount,
parsedRecordCount,
retainedCount,
total);
Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
sink(message);
}
/// <summary>
/// Rate-limit decision for the truncated-fetch warning: emit only when
/// a full <see cref="TruncationWarningIntervalMilliseconds"/> has
/// elapsed since the last one. A galaxy parked above the cap truncates
/// on every poll, so consecutive truncated polls inside one interval
/// must produce exactly one line. Exposed as <c>internal static</c>
/// so the throttle is unit-testable without the wnwrapConsumer COM
/// object.
/// </summary>
/// <param name="elapsedMilliseconds">Consumer-lifetime clock reading for this poll.</param>
/// <param name="lastWarningMilliseconds">Clock reading when the last warning was emitted.</param>
/// <returns><see langword="true"/> when this poll may emit a warning.</returns>
internal static bool ShouldWarnTruncation(
long elapsedMilliseconds,
long lastWarningMilliseconds)
{
return elapsedMilliseconds - lastWarningMilliseconds >= TruncationWarningIntervalMilliseconds;
}
/// <summary>
/// Pure snapshot-to-transitions diff. Compares the previous polled
/// snapshot to the next snapshot and produces one
@@ -355,6 +584,21 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
/// </list>
/// <para>
/// The absence rule is deliberately inert here: this diff never
/// turns a disappearance into a Clear. The clear is inferred one
/// level up, by the gateway's reconcile pass diffing its cache
/// against <see cref="SnapshotActiveAlarms"/>, which reads the
/// snapshot <see cref="PollOnce"/> maintains from these same
/// dictionaries. That is why the truncation guard lives in
/// <see cref="PollOnce"/>'s snapshot update rather than in this
/// method: suppressing "absence implies Clear" means declining to
/// *evict* alarms a capped fetch could not mention, not declining
/// to emit a transition this method was never going to emit.
/// Passing a truncated <paramref name="next"/> straight through
/// stays correct for the alarms it does carry — first sightings
/// and state changes are computed from presence alone.
/// </para>
/// </remarks>
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
@@ -393,9 +637,29 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <param name="xml">The XML snapshot payload.</param>
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
{
return ParseSnapshotXml(xml, out _);
}
/// <summary>
/// <see cref="ParseSnapshotXml(string)"/> plus the raw
/// <c>ALARM</c>-element count, which <see cref="PollOnce"/> compares
/// against the per-fetch cap to detect a truncated reply. The raw
/// count — not the dictionary size — is the right signal: records
/// dropped for a malformed GUID still consumed a slot in the capped
/// reply, so counting only the survivors would under-report and let a
/// truncated fetch pass as complete.
/// </summary>
/// <param name="xml">The XML snapshot payload.</param>
/// <param name="alarmRecordCount">Number of <c>ALARM</c> elements in the payload.</param>
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
internal static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(
string xml,
out int alarmRecordCount)
{
Dictionary<Guid, MxAlarmSnapshotRecord> records =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
alarmRecordCount = 0;
if (string.IsNullOrWhiteSpace(xml)) return records;
@@ -404,45 +668,103 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
if (alarmNodes is null) return records;
alarmRecordCount = alarmNodes.Count;
foreach (XmlNode alarmNode in alarmNodes)
{
string guidHex = TextOf(alarmNode, "GUID");
// One pass over the child elements instead of 17 SelectSingleNode
// XPath evaluations per record. At the 1024-record cap that is the
// difference between ~17k XPath expression compilations plus child
// walks and one walk per record, on the STA that also serves reads
// and writes.
//
// Parity with the XPath it replaces: SelectSingleNode(name) picks
// the FIRST child ELEMENT of that name (ordinal, case-sensitive)
// and yields its InnerText; an absent child yields string.Empty,
// and so does a present-but-empty one. Hence: element nodes only,
// first-match-wins (null means "not seen yet"), and a single
// coalesce to string.Empty at the end — which keeps the absent and
// empty cases indistinguishable exactly as before.
//
// One theoretical divergence: XmlNode.Name is the QName, so a child
// carrying its own default-namespace declaration (<GUID xmlns="...">)
// matches here where the unprefixed XPath name test would not. It is
// theoretical — wnwrap emits no namespaces, and a namespaced ALARM
// wrapper makes the outer SelectNodes return nothing either way — and
// it errs permissive (field populated rather than silently dropped).
string? guidHex = null;
string? xmlDate = null;
string? xmlTime = null;
string? gmtOffset = null;
string? dstAdjust = null;
string? providerNode = null;
string? providerName = null;
string? group = null;
string? tagName = null;
string? type = null;
string? value = null;
string? limit = null;
string? priority = null;
string? state = null;
string? operatorNode = null;
string? operatorName = null;
string? alarmComment = null;
foreach (XmlNode child in alarmNode.ChildNodes)
{
if (child.NodeType != XmlNodeType.Element) continue;
switch (child.Name)
{
case "GUID": guidHex ??= child.InnerText; break;
case "DATE": xmlDate ??= child.InnerText; break;
case "TIME": xmlTime ??= child.InnerText; break;
case "GMTOFFSET": gmtOffset ??= child.InnerText; break;
case "DSTADJUST": dstAdjust ??= child.InnerText; break;
case "PROVIDER_NODE": providerNode ??= child.InnerText; break;
case "PROVIDER_NAME": providerName ??= child.InnerText; break;
case "GROUP": group ??= child.InnerText; break;
case "TAGNAME": tagName ??= child.InnerText; break;
case "TYPE": type ??= child.InnerText; break;
case "VALUE": value ??= child.InnerText; break;
case "LIMIT": limit ??= child.InnerText; break;
case "PRIORITY": priority ??= child.InnerText; break;
case "STATE": state ??= child.InnerText; break;
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
}
}
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
string xmlDate = TextOf(alarmNode, "DATE");
string xmlTime = TextOf(alarmNode, "TIME");
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
xmlDate, xmlTime, gmtOffset, dstAdjust);
xmlDate ?? string.Empty,
xmlTime ?? string.Empty,
ParseInt(gmtOffset ?? string.Empty),
ParseInt(dstAdjust ?? string.Empty));
records[guid] = new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
TransitionTimestampUtc = tsUtc,
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
Group = TextOf(alarmNode, "GROUP"),
TagName = TextOf(alarmNode, "TAGNAME"),
Type = TextOf(alarmNode, "TYPE"),
Value = TextOf(alarmNode, "VALUE"),
Limit = TextOf(alarmNode, "LIMIT"),
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
ProviderNode = providerNode ?? string.Empty,
ProviderName = providerName ?? string.Empty,
Group = group ?? string.Empty,
TagName = tagName ?? string.Empty,
Type = type ?? string.Empty,
Value = value ?? string.Empty,
Limit = limit ?? string.Empty,
Priority = ParseInt(priority ?? string.Empty),
State = AlarmRecordTransitionMapper.ParseStateKind(state ?? string.Empty),
OperatorNode = operatorNode ?? string.Empty,
OperatorName = operatorName ?? string.Empty,
AlarmComment = alarmComment ?? string.Empty,
};
}
return records;
}
private static string TextOf(XmlNode parent, string childName)
{
XmlNode? node = parent.SelectSingleNode(childName);
return node?.InnerText ?? string.Empty;
}
private static int ParseInt(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
@@ -9,10 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Sta;
public sealed class StaMessagePump
{
private const uint Infinite = 0xFFFFFFFF;
private const uint MsgWaitFailed = 0xFFFFFFFF;
private const uint MwmoInputAvailable = 0x0004;
private const uint PmRemove = 0x0001;
private const uint QsAllInput = 0x04FF;
/// <summary>Waits for a command wake event or Windows messages, pumping any pending messages.</summary>
/// <param name="commandWakeEvent">Event to signal when work is available.</param>
@@ -28,14 +25,14 @@ public sealed class StaMessagePump
SafeWaitHandle safeHandle = commandWakeEvent.SafeWaitHandle;
IntPtr[] handles = [safeHandle.DangerousGetHandle()];
uint result = MsgWaitForMultipleObjectsEx(
uint result = StaNativeMethods.MsgWaitForMultipleObjectsEx(
(uint)handles.Length,
handles,
timeoutMilliseconds,
QsAllInput,
MwmoInputAvailable);
StaNativeMethods.QsAllInput,
StaNativeMethods.MwmoInputAvailable);
if (result == MsgWaitFailed)
if (result == StaNativeMethods.MsgWaitFailed)
{
throw new InvalidOperationException(
"The worker STA message pump failed while waiting for command work or Windows messages.");
@@ -75,14 +72,6 @@ public sealed class StaMessagePump
: (uint)Math.Ceiling(timeout.TotalMilliseconds);
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint MsgWaitForMultipleObjectsEx(
uint count,
IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool PeekMessage(
out NativeMessage message,
@@ -0,0 +1,58 @@
using System;
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// <summary>
/// The single declaration of the Win32 wait the worker's STA is built on.
/// Both waiters use it: <see cref="StaMessagePump"/> for the runtime's outer
/// idle wait, and <see cref="StaWaitHelper"/> for the inner completion waits
/// a command performs while it already holds the apartment.
/// </summary>
/// <remarks>
/// Deliberately one copy. This is the subtlest Win32 contract in the worker —
/// the wake mask decides which events can wake the STA, and
/// <see cref="MwmoInputAvailable"/> decides whether a message already seen by
/// a <c>PeekMessage</c> still counts as a wake. Two copies of it could drift
/// apart silently, and a divergence would show up only as COM events
/// arriving late on a live provider.
/// </remarks>
internal static class StaNativeMethods
{
/// <summary>Return value of a failed wait (<c>WAIT_FAILED</c>).</summary>
internal const uint MsgWaitFailed = 0xFFFFFFFF;
/// <summary>
/// Wake even when the pending input has already been seen (but not
/// removed) by an earlier <c>PeekMessage</c>. Both callers peek before
/// they wait, so without this flag a message that arrived during the peek
/// would not wake the wait that follows it.
/// </summary>
internal const uint MwmoInputAvailable = 0x0004;
/// <summary>Wake on any queued input, including sent and posted messages.</summary>
internal const uint QsAllInput = 0x04FF;
/// <summary>
/// Waits for the given handles, for queued input matching
/// <paramref name="wakeMask"/>, or for the timeout — whichever comes
/// first.
/// </summary>
/// <param name="count">Number of handles in <paramref name="handles"/>.</param>
/// <param name="handles">Handles to wait on; marshaled as a pointer to the array.</param>
/// <param name="milliseconds">Timeout in milliseconds; <c>0xFFFFFFFF</c> means INFINITE.</param>
/// <param name="wakeMask">Input types that may wake the wait (<see cref="QsAllInput"/>).</param>
/// <param name="flags">Wait flags (<see cref="MwmoInputAvailable"/>).</param>
/// <returns>
/// <c>WAIT_OBJECT_0 + i</c> for a signaled handle, <c>WAIT_OBJECT_0 + count</c>
/// for available input, <c>WAIT_TIMEOUT</c> (258), or
/// <see cref="MsgWaitFailed"/>.
/// </returns>
[DllImport("user32.dll", SetLastError = true)]
internal static extern uint MsgWaitForMultipleObjectsEx(
uint count,
IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
}
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
@@ -17,23 +18,22 @@ namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// Deliberately separate from <see cref="StaMessagePump"/> rather than a new
/// method on it: the two caches that use this are plain per-session objects
/// with no pump dependency (they take the pump step as an injected delegate
/// so tests can drive them off the STA). Keeping the helper in this folder
/// keeps every <c>user32</c> wait signature in the worker reviewable in one
/// place; the declaration below mirrors the pump's exactly.
/// so tests can drive them off the STA). The wait itself is not duplicated —
/// both this and the pump call the single declaration in
/// <see cref="StaNativeMethods"/>.
/// </remarks>
internal static class StaWaitHelper
{
/// <summary>
/// Ceiling on the blind fallback tick. A caller's poll interval is
/// clamped to this so no wait can go longer than 50 ms without calling
/// its pump step, even when the process has no message queue to wake it
/// (unit-test threads) and the wait handle is never signaled.
/// Ceiling on the fallback tick — the longest a wait may block when
/// nothing wakes it. A caller's poll interval is clamped to this, so a
/// caller's pump step keeps running at least this often no matter what
/// interval it asked for, even on a thread that never receives a message
/// and whose signal is never set.
/// </summary>
internal const int MaxFallbackTickMilliseconds = 50;
private const uint MsgWaitFailed = 0xFFFFFFFF;
private const uint MwmoInputAvailable = 0x0004;
private const uint QsAllInput = 0x04FF;
private static int waitFailureReported;
/// <summary>
/// Blocks the calling thread until <paramref name="signal"/> is set, a
@@ -42,6 +42,29 @@ internal static class StaWaitHelper
/// never blinds the thread to inbound COM event messages: the wait
/// returns the instant one arrives, so the caller can dispatch it.
/// </summary>
/// <remarks>
/// <para>
/// <em>Caller contract: the pump step must drain the message queue
/// to empty on every call.</em> The wait uses
/// <see cref="StaNativeMethods.MwmoInputAvailable"/>, which reports
/// input that is merely <em>present</em> — including input an earlier
/// <c>PeekMessage</c> has already seen but not removed. A caller
/// whose pump step drains only part of the queue, or does nothing at
/// all, therefore leaves a message that satisfies the wake condition
/// forever: every wait returns immediately, and the caller's poll
/// loop degenerates into a busy loop that spins the thread at 100%
/// until its deadline. That is a CPU fault, not a correctness one —
/// the loop still honors its deadline and return shape — but it must
/// not ship. Drain with <c>PeekMessage(..., PM_REMOVE)</c> in a loop
/// until it returns false, exactly as
/// <see cref="StaMessagePump.PumpPendingMessages"/> does.
/// </para>
/// <para>
/// The wait does not <c>DangerousAddRef</c> the handle. That is
/// sound only because the callers never dispose their wait handles —
/// see the coupled invariant documented on the caches' signal fields.
/// </para>
/// </remarks>
/// <param name="signal">Wait handle set by whoever produces the awaited result.</param>
/// <param name="timeoutMilliseconds">Maximum wait; values at or below zero return immediately.</param>
internal static void WaitForSignalOrMessages(
@@ -60,20 +83,28 @@ internal static class StaWaitHelper
SafeWaitHandle safeHandle = signal.SafeWaitHandle;
IntPtr[] handles = [safeHandle.DangerousGetHandle()];
uint result = MsgWaitForMultipleObjectsEx(
uint result = StaNativeMethods.MsgWaitForMultipleObjectsEx(
(uint)handles.Length,
handles,
(uint)timeoutMilliseconds,
QsAllInput,
MwmoInputAvailable);
StaNativeMethods.QsAllInput,
StaNativeMethods.MwmoInputAvailable);
// Read before anything else can run another P/Invoke and overwrite the
// thread's last-error slot.
int lastError = result == StaNativeMethods.MsgWaitFailed
? Marshal.GetLastWin32Error()
: 0;
// The handle is a field of a live caller object, so it cannot be
// collected mid-call; KeepAlive states that dependency explicitly
// rather than relying on it.
GC.KeepAlive(signal);
if (result == MsgWaitFailed)
if (result == StaNativeMethods.MsgWaitFailed)
{
ReportWaitFailureOnce(lastError);
// Degrade to the pre-existing blind sleep instead of throwing or
// hot-spinning: the caller is a bounded, deadline-driven poll loop,
// and a failed wait must not turn a timed-out write into a fault.
@@ -108,11 +139,30 @@ internal static class StaWaitHelper
: tick;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint MsgWaitForMultipleObjectsEx(
uint count,
IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
/// <summary>
/// Traces the first wait failure of the process and stays silent after
/// that.
/// </summary>
/// <remarks>
/// The degrade path is silent by design — a failed wait still honors the
/// caller's deadline — but a <em>permanently</em> failing wait means every
/// completion wait has quietly reverted to the sleep-polled behavior this
/// class exists to replace, and nothing else would ever say so. One trace
/// makes that discoverable; latching it keeps a repeating failure from
/// flooding the trace listener from inside a poll loop.
/// </remarks>
/// <param name="lastError">The Win32 error captured immediately after the failed wait.</param>
private static void ReportWaitFailureOnce(int lastError)
{
if (Interlocked.Exchange(ref waitFailureReported, 1) != 0)
{
return;
}
Trace.TraceWarning(
"MsgWaitForMultipleObjectsEx failed in the worker STA completion wait (Win32 error {0}). "
+ "The wait has degraded to blind sleeping: deadlines are still honored, but inbound "
+ "MXAccess COM events will only dispatch at poll-tick granularity. Reported once per process.",
lastError);
}
}