/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace Opc.Ua.Client.Tests.Stack.Client.Fakes
{
///
/// Creates real subscription engines limited to one concurrent publish request and observes calls into
/// the real session publish path, including requests that have not yet reached the transport.
///
/// The clock used by the engines for publishing and retry timers.
internal sealed class ObservingSubscriptionEngineFactory(TimeProvider timeProvider) : ISubscriptionEngineFactory
{
///
/// Gets the most recently created real subscription engine, available after .
///
public DefaultSubscriptionEngine Engine { get; private set; } = null!;
///
/// Gets the number of engines created so tests can detect unwanted engine replacement during recovery.
///
public int CreateCount { get; private set; }
///
///
/// Wraps the supplied context without replacing its publish operations and limits publish concurrency to one.
///
public ISubscriptionEngine Create(ISubscriptionEngineContext context)
{
Engine = new DefaultSubscriptionEngine(new ForwardingContext(context, m_attempts.Writer), timeProvider)
{
MinPublishRequestCount = 1,
MaxPublishRequestCount = 1
};
CreateCount++;
return Engine;
}
///
/// Dequeues the next attempt after the real context has been invoked, even if the request is still
/// waiting for channel readiness.
///
/// The token used to cancel waiting for an observation, not the observed publish.
public ValueTask NextAttemptAsync(CancellationToken ct)
{
return m_attempts.Reader.ReadAsync(ct);
}
///
/// Captures a real publish invocation and its completion task independently of transport receipt.
///
/// The request handle recorded after invoking the real context.
/// The acknowledgements passed to the context for this attempt.
/// The real publish task, which may still be waiting for channel readiness.
/// The token passed to the real publish operation.
internal sealed record PublishAttempt(
uint RequestHandle,
ArrayOf Acknowledgements,
Task Operation,
CancellationToken CancellationToken);
///
/// Delegates engine services and callbacks to the real session context while recording publish operations
/// after they have entered that context.
///
/// The real session context whose behavior is preserved.
/// The queue that receives publish observations.
private sealed class ForwardingContext(
ISubscriptionEngineContext context,
ChannelWriter attempts) : ISubscriptionEngineContext
{
///
public NodeId SessionId => context.SessionId;
///
public bool Reconnecting => context.Reconnecting;
///
public bool Connected => context.Connected;
///
public bool Closing => context.Closing;
///
public bool KeepAliveStopped => context.KeepAliveStopped;
///
public DateTime LastKeepAliveTime => context.LastKeepAliveTime;
///
public bool Disposed => context.Disposed;
///
public bool DeleteSubscriptionsOnClose => context.DeleteSubscriptionsOnClose;
///
public int OperationTimeout => context.OperationTimeout;
///
public double SessionTimeout => context.SessionTimeout;
///
public ServerState ServerState => context.ServerState;
///
public DiagnosticsMasks ReturnDiagnostics => context.ReturnDiagnostics;
///
public ITelemetryContext Telemetry => context.Telemetry;
///
public IReadOnlyList Subscriptions => context.Subscriptions;
///
public SemaphoreSlim ReconnectLock => context.ReconnectLock;
///
public int GoodPublishRequestCount => context.GoodPublishRequestCount;
///
public ISubscriptionServiceSetClientMethods SubscriptionServiceSet => context.SubscriptionServiceSet;
///
public IMonitoredItemServiceSetClientMethods MonitoredItemServiceSet => context.MonitoredItemServiceSet;
///
public IMethodServiceSetClientMethods MethodServiceSet => context.MethodServiceSet;
///
///
/// Records the actual operation only after delegation has entered the session/channel path.
///
public ValueTask PublishAsync(
RequestHeader requestHeader,
ArrayOf acknowledgements,
CancellationToken ct = default)
{
// Enter the actual Session/ChannelEntry path before publishing the observation.
Task operation = context.PublishAsync(requestHeader, acknowledgements, ct).AsTask();
if (!attempts.TryWrite(new PublishAttempt(
requestHeader.RequestHandle, acknowledgements, operation, ct)))
{
throw new InvalidOperationException("The publish observation queue was unexpectedly closed.");
}
return new ValueTask(operation);
}
///
public ValueTask TransferSubscriptionsAsync(
RequestHeader? requestHeader,
ArrayOf subscriptionIds,
bool sendInitialValues,
CancellationToken ct = default)
{
return context.TransferSubscriptionsAsync(requestHeader, subscriptionIds, sendInitialValues, ct);
}
///
public ValueTask DeleteSubscriptionsAsync(
RequestHeader? requestHeader,
ArrayOf subscriptionIds,
CancellationToken ct = default)
{
return context.DeleteSubscriptionsAsync(requestHeader, subscriptionIds, ct);
}
///
public void OnKeepAlive(ServerState serverState, DateTime timestamp)
{
context.OnKeepAlive(serverState, timestamp);
}
///
public void OnPublishError(ServiceResult error, uint subscriptionId, uint sequenceNumber)
{
context.OnPublishError(error, subscriptionId, sequenceNumber);
}
///
public void OnPublishNotification(Subscription subscription, NotificationEventArgs notification)
{
context.OnPublishNotification(subscription, notification);
}
///
public void OnKeepAliveError(ServiceResult error)
{
context.OnKeepAliveError(error);
}
///
public void AsyncRequestStarted(Task task, Activity? activity, uint requestHandle, uint requestTypeId)
{
context.AsyncRequestStarted(task, activity, requestHandle, requestTypeId);
}
///
public void AsyncRequestCompleted(Task task, uint requestHandle, uint requestTypeId)
{
context.AsyncRequestCompleted(task, requestHandle, requestTypeId);
}
///
public (List toSend, List updatedPending)
PrepareAcknowledgementsToSend(List currentAcknowledgements)
{
return context.PrepareAcknowledgementsToSend(currentAcknowledgements);
}
///
public ValueTask DeleteOrphanedSubscriptionAsync(uint subscriptionId)
{
return context.DeleteOrphanedSubscriptionAsync(subscriptionId);
}
///
public bool TryDispatchToSessionSubscription(
uint subscriptionId,
NotificationMessage message,
ArrayOf availableSequenceNumbers,
ArrayOf stringTable,
bool moreNotifications)
{
return context.TryDispatchToSessionSubscription(
subscriptionId, message, availableSequenceNumbers, stringTable, moreNotifications);
}
}
private readonly Channel m_attempts = Channel.CreateUnbounded();
}
}