Compare commits
10 Commits
dc9424d3bd
...
b2d8dd70ed
| Author | SHA1 | Date | |
|---|---|---|---|
| b2d8dd70ed | |||
| 58d97ad4e8 | |||
| b5ea6bb461 | |||
| 7c9add3d73 | |||
| 896d81e286 | |||
| 25cbe5cd3e | |||
| 13583322b5 | |||
| f3e1de5f37 | |||
| 94fdc18c3c | |||
| f4a6cb1db2 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+18
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user