using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.Ipc; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using ZB.MOM.WW.MxGateway.Worker.Sta; namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; /// /// Single configurable test double shared by /// the IPC tests. Replaces the two independent (and previously diverged) /// FakeRuntimeSession copies in WorkerPipeSessionTests and /// WorkerPipeClientTests: one supported dispatch blocking and event enqueue, the /// other did not. This consolidated double supports every configuration both /// call sites needed, so a minimal caller simply leaves the options unset. /// internal sealed class FakeRuntimeSession : IWorkerRuntimeSession { private readonly ManualResetEventSlim releaseDispatch = new(false); private readonly object gate = new(); private readonly Queue events = new(); private readonly List 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, outboundEventQueueDepth: 0, lastEventSequence: 0, currentCommandCorrelationId: string.Empty); /// Gets the event signaled when dispatch begins. public ManualResetEventSlim DispatchStarted { get; } = new(false); /// Blocks dispatch execution until explicitly released. public bool BlockDispatch { get; set; } /// Gets or sets whether to throw an exception after dispatch is released. public bool ThrowAfterDispatchReleased { get; set; } /// Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException. public bool ThrowTimeoutOnShutdown { get; set; } /// /// Optional diagnostic message stuffed into every dispatched command reply. A long value /// pushes the STA command reply past a small negotiated frame maximum, which is how a test /// drives the ProcessCommandAsync reply-size backstop. /// public string? DispatchReplyDiagnosticMessage { get; set; } /// Gets a value indicating whether Dispose was called. public bool Disposed { get; private set; } /// public Task StartAsync( string sessionId, int workerProcessId, CancellationToken cancellationToken = default) { return Task.FromResult(new WorkerReady { WorkerProcessId = workerProcessId, MxaccessProgid = MxAccessInteropInfo.ProgId, MxaccessClsid = MxAccessInteropInfo.Clsid, ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), }); } /// public Task DispatchAsync(StaCommand command) { return Task.Run( () => { SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( DateTimeOffset.UtcNow, pendingCommandCount: 0, outboundEventQueueDepth: 0, lastEventSequence: 0, command.CorrelationId)); DispatchStarted.Set(); if (BlockDispatch) { releaseDispatch.Wait(TimeSpan.FromSeconds(5)); } SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( DateTimeOffset.UtcNow, pendingCommandCount: 0, outboundEventQueueDepth: 0, lastEventSequence: 0, currentCommandCorrelationId: string.Empty)); if (ThrowAfterDispatchReleased) { throw new InvalidOperationException("Command failed after shutdown started."); } MxCommandReply reply = new() { SessionId = command.SessionId, CorrelationId = command.CorrelationId, Kind = command.Kind, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK", }, }; if (DispatchReplyDiagnosticMessage is not null) { reply.DiagnosticMessage = DispatchReplyDiagnosticMessage; } return reply; }); } /// public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() { lock (gate) { return snapshot; } } /// /// When set, returns no events for the /// WorkerPipeSession background drain loop's fixed batch size, so an /// explicit DrainEvents control command (which drains all via /// maxEvents == 0) can claim the queued events deterministically /// without racing the 25 ms background loop. Mirrors /// WorkerPipeSession.EventDrainBatchSize. /// public uint? SuppressDrainForBatchSize { get; set; } /// /// Records the maxEvents argument of the most recent non-suppressed /// call — i.e. the effective cap the session passed for an explicit /// DrainEvents control command. Lets a test assert the worker bounds the drain rather /// than forwarding the client's raw max_events = 0. /// public uint? LastDrainMaxEvents { get; private set; } /// /// Optional real event queue backing the drain paths. When set, both /// and delegate to it /// so a test can exercise the production byte-budgeting logic behind the fake session. /// public MxAccessEventQueue? BackingQueue { get; set; } /// /// When set, ignores the byte budget and drains purely /// by count. Simulates the "sizing bug or future command" case the control-reply size /// backstop exists for, so a test can drive an oversized reply without a real budgeting /// defect. /// public bool IgnoreDrainByteBudget { get; set; } /// /// Records the maxTotalBytes argument of the most recent byte-budgeted /// call. /// public int? LastDrainMaxTotalBytes { get; private set; } /// public IReadOnlyList DrainEvents(uint maxEvents) { if (SuppressDrainForBatchSize is uint suppressed && maxEvents == suppressed) { return Array.Empty(); } LastDrainMaxEvents = maxEvents; if (BackingQueue is not null) { return BackingQueue.Drain(maxEvents); } lock (gate) { int drainCount = maxEvents == 0 ? events.Count : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); List drained = new(drainCount); for (int index = 0; index < drainCount; index++) { drained.Add(events.Dequeue()); } return drained; } } /// public WorkerEventDrainResult DrainEvents(uint maxEvents, int maxTotalBytes) { if (SuppressDrainForBatchSize is uint suppressed && maxEvents == suppressed) { return new WorkerEventDrainResult( Array.Empty(), truncatedBySize: false, remainingCount: PendingEventCount, oversizedHeadSequence: 0); } LastDrainMaxEvents = maxEvents; LastDrainMaxTotalBytes = maxTotalBytes; if (BackingQueue is not null && !IgnoreDrainByteBudget) { return BackingQueue.Drain(maxEvents, maxTotalBytes); } // Count-only drain: either no backing queue (the simple fakes) or a deliberately // budget-blind drain used to exercise the reply-size backstop. IReadOnlyList drained = BackingQueue is not null ? BackingQueue.Drain(maxEvents) : DrainByCount(maxEvents); return new WorkerEventDrainResult( drained, truncatedBySize: false, remainingCount: PendingEventCount, oversizedHeadSequence: 0); } private int PendingEventCount { get { if (BackingQueue is not null) { return BackingQueue.Count; } lock (gate) { return events.Count; } } } private IReadOnlyList DrainByCount(uint maxEvents) { lock (gate) { int drainCount = maxEvents == 0 ? events.Count : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue))); List drained = new(drainCount); for (int index = 0; index < drainCount; index++) { drained.Add(events.Dequeue()); } return drained; } } /// /// When set, 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. /// public bool WaitForEventsOnSignalOnly { get; set; } /// /// The timeout argument of the most recent call, so /// a test can assert the drain loop still passes its fallback ceiling. /// public TimeSpan? LastWaitForEventsTimeout { get { lock (gate) { return lastWaitForEventsTimeout; } } } /// 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); } /// public WorkerFault? DrainFault() { return null; } /// /// Gets a snapshot of every correlation id passed to /// . Recording lets the IPC tests /// assert that a WorkerCancel envelope dispatched on the /// gateway side reaches the runtime session. /// public IReadOnlyList CancelledCorrelationIds { get { lock (gate) { return new List(cancelledCorrelationIds); } } } private bool cancelCommandReturnValue; /// /// Optional return value yielded by . /// Defaults to false (the runtime had no matching in-flight /// command), matching the previous test-double behaviour. Mutated /// and read under lock(gate) to match the locking convention /// the rest of this fake uses for cancelledCorrelationIds, /// snapshot, and events. /// public bool CancelCommandReturnValue { get { lock (gate) { return cancelCommandReturnValue; } } set { lock (gate) { cancelCommandReturnValue = value; } } } /// public bool CancelCommand(string correlationId) { lock (gate) { cancelledCorrelationIds.Add(correlationId); return cancelCommandReturnValue; } } /// public void RequestShutdown() { releaseDispatch.Set(); } /// public Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default) { releaseDispatch.Set(); if (ThrowTimeoutOnShutdown) { return Task.FromException( new TimeoutException("Simulated graceful shutdown timeout.")); } return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); } /// Releases a blocked dispatch. public void ReleaseDispatch() { releaseDispatch.Set(); } /// Sets the current heartbeat snapshot. /// The snapshot to set. public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) { lock (gate) { snapshot = value; } } /// Enqueues a worker event to be drained. /// The event to enqueue. public void EnqueueEvent(WorkerEvent workerEvent) { lock (gate) { events.Enqueue(workerEvent); } SignalWake(); } /// /// Enqueues a batch of worker events atomically under one lock so the drain loop cannot /// observe a partial batch. Lets a test assert the drain loop coalesces a whole batch into one /// flush (WRK-25) without racing a mid-enqueue drain that would split the batch. /// /// The events to enqueue in order. public void EnqueueEvents(IEnumerable workerEvents) { lock (gate) { foreach (WorkerEvent workerEvent in workerEvents) { 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. } } /// public void Dispose() { Disposed = true; releaseDispatch.Set(); releaseDispatch.Dispose(); DispatchStarted.Dispose(); } }