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();
}
}