using System; using System.Collections.Generic; using System.Threading; using Google.Protobuf.Collections; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.Sta; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Per-session cache of the most recent OnWriteComplete status rows /// for each (server handle, item handle) pair. Written by the MXAccess /// event sink as completion callbacks arrive; read by the write command /// executor so a WriteSecured/WriteSecured2 reply can carry the correlated /// MXAccess outcome instead of proving command acceptance only. /// /// /// Same threading posture as : writers and /// readers run on the worker's STA thread (COM dispatches events on the /// apartment thread; commands also execute on the STA), so no internal /// locking is required. A single sync root keeps it nominally thread-safe /// for tests that drive it from a non-STA thread. /// public sealed class MxAccessWriteCompletionCache { private readonly Dictionary entries = new(); private readonly object syncRoot = new(); // Set by Record so a waiter blocked in TryWaitForCompletion wakes the moment // a completion lands instead of after the next blind poll tick. In the live // 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. // // 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); /// Records the status rows of a fresh OnWriteComplete callback for the given handle pair. /// MXAccess server handle. /// MXAccess item handle. /// Status rows from the mapped OnWriteComplete event; cloned before storing. public void Record( int serverHandle, int itemHandle, RepeatedField statuses) { if (statuses is null) { throw new ArgumentNullException(nameof(statuses)); } lock (syncRoot) { long key = CreateItemKey(serverHandle, itemHandle); ulong version = entries.TryGetValue(key, out CompletionEntry existing) ? existing.Version + 1 : 1UL; // Still a defensive copy, deliberately — this is NOT an oversight // left behind by the borrow that MxAccessValueCache.Set adopted (see // the ownership contract there). The value cache is handed the whole // enqueued MxEvent, so the queue's write-once ownership invariant // covers everything it retains. This method is handed a bare // RepeatedField whose provenance its signature cannot constrain: // the production sink does pass an enqueued event's Statuses, but // callers that build and keep their own rows are equally valid // against this API, and a borrowed alias would then let a later // mutation rewrite an already-recorded completion. The write path is // command-rate, not the per-OnDataChange streaming hot path, so the // clone costs nothing worth reclaiming. entries[key] = new CompletionEntry(version, statuses.Clone()); } // Signaled outside the lock: the waiter re-takes syncRoot the instant it // wakes, so signaling while holding it would hand it guaranteed contention. completionSignal.Set(); } /// Returns the current completion version for a handle pair, or 0 if none was recorded. /// MXAccess server handle. /// MXAccess item handle. /// The current completion version, or 0 if no completion was recorded. public ulong CurrentVersion( int serverHandle, int itemHandle) { lock (syncRoot) { return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing) ? existing.Version : 0UL; } } /// /// Waits for a completion newer than until it /// arrives or the deadline elapses, calling on every /// iteration so the worker's STA can dispatch the inbound MXAccess /// OnWriteComplete message. Same loop shape as /// . /// /// /// The idle part of each iteration blocks in /// rather than /// Thread.Sleep: a sleeping STA pumps no Windows messages, so the /// inbound OnWriteComplete could only ever be dispatched at poll-tick /// granularity. The Win32 wait returns the instant a message needs /// pumping (or the completion is recorded from another thread), so the /// apartment keeps dispatching COM events continuously for the whole /// wait. Timeouts, deadline math, and both return shapes are unchanged. /// /// MXAccess server handle. /// MXAccess item handle. /// Version snapshot captured before the write COM call. /// Absolute UTC deadline. /// Action that pumps any pending Windows messages. /// The recorded status rows if a completion arrived before the deadline; empty otherwise. /// Upper bound on one idle wait slice — the cadence at which runs when nothing wakes the wait. Default 5 ms, itself capped at . /// if a completion newer than arrived before the deadline; otherwise . public bool TryWaitForCompletion( int serverHandle, int itemHandle, ulong sinceVersion, DateTime deadlineUtc, Action pumpStep, out RepeatedField statuses, int pollIntervalMs = 5) { if (pumpStep is null) { throw new ArgumentNullException(nameof(pumpStep)); } while (true) { // Pumped unconditionally at the top of every iteration — including // the one that immediately follows a signaled wait, and the very // first before any waiting. Dispatching the message is what fills // the cache in the live worker, and unit tests stand in for the STA // by recording the completion from inside their fake pumpStep, so // this call must never be conditional on a real message queue. pumpStep(); lock (syncRoot) { if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry) && entry.Version > sinceVersion) { statuses = entry.Statuses; return true; } } // Same expiry test as the sleep-polled version: remaining <= zero is // exactly DateTime.UtcNow >= deadlineUtc. TimeSpan remaining = deadlineUtc - DateTime.UtcNow; if (remaining <= TimeSpan.Zero) { statuses = new RepeatedField(); return false; } StaWaitHelper.WaitForSignalOrMessages( completionSignal, StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs)); } } private static long CreateItemKey( int serverHandle, int itemHandle) { return ((long)serverHandle << 32) | (uint)itemHandle; } /// /// Snapshot of the most recent OnWriteComplete status rows for a handle /// pair. increments by one on every /// call so the write executor can detect "a new /// completion arrived since I captured my baseline". /// /// /// Plain readonly struct (not a record) so this compiles under the /// worker's net48 target, which lacks IsExternalInit. /// private readonly struct CompletionEntry { public CompletionEntry( ulong version, RepeatedField statuses) { Version = version; Statuses = statuses; } public ulong Version { get; } public RepeatedField Statuses { get; } } }