/* ======================================================================== * 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.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; using Opc.Ua.Tests; namespace Opc.Ua.Server.Tests { /// /// Verifies that a notification message keeps its pooled payload until the retransmission /// queue and every Publish response that carries it have released it, and that copies /// handed out for republish and mirroring are independent of queue recycling. /// [TestFixture] [Category("Subscription")] [NonParallelizable] public sealed class NotificationEncodingLifetimeRegressionTests { /// /// Verifies that delayed encoding preserves notifications after acknowledgement, eviction, /// clearing, and pool reuse, and that a message shared with its response is recycled /// only once the response was sent. /// [Test] public async Task ReturnedNotificationSurvivesPoolReuseUntilEncodingCompletesAsync( [Values("publish", "queued", "republish", "fullMirror", "deltaMirror")] string surface, [Values("acknowledge", "evict", "clear")] string mutation, [Values(false, true)] bool events) { NotificationMessage? mirrored = null; var fullStore = new Mock(); fullStore.Setup(store => store.StoreRetransmissionState( 1, It.IsAny(), It.IsAny>())) .Callback>((_, _, values) => mirrored ??= values[0]); var deltaStore = new Mock(); deltaStore.Setup(store => store.StoreRetransmissionStateDelta( 1, It.IsAny(), It.IsAny>(), It.IsAny>())) .Callback, ArrayOf>( (_, _, values, _) => mirrored ??= values[0]); ISubscriptionRetransmissionStore? store = surface switch { "fullMirror" => fullStore.Object, "deltaMirror" => deltaStore.Object, _ => null }; var queue = new SentMessageQueue(() => 1, 2, store, NullLogger.Instance); var source = (NotificationMessage)NotificationMessageActivator.Instance.CreateInstance(); source.SequenceNumber = surface == "queued" ? 2u : 1u; var item = (MonitoredItemNotification)MonitoredItemNotificationActivator.Instance.CreateInstance(); item.ClientHandle = 77; item.Value = new DataValue(42); var eventItem = (EventFieldList)EventFieldListActivator.Instance.CreateInstance(); eventItem.ClientHandle = 77; eventItem.EventFields = [42]; var data = new DataChangeNotification { MonitoredItems = [item] }; var eventList = new EventNotificationList { Events = [eventItem] }; source.NotificationData = [new ExtensionObject(events ? eventList : data)]; List messages = surface == "queued" ? [new NotificationMessage { SequenceNumber = 1 }, source] : [source]; NotificationMessage published = queue.Enqueue(messages, [], out _, out _, out NotificationMessageLease publishedLease); NotificationMessageLease? sharedLease = null; NotificationMessage selected; switch (surface) { case "queued": publishedLease.Release(); selected = queue.TryDequeueQueued([], false, out _, out sharedLease)!; break; case "publish": sharedLease = publishedLease; selected = published; break; default: // the response that carried the message was sent long ago. publishedLease.Release(); selected = surface == "republish" ? queue.FindForRepublish(source.SequenceNumber)! : mirrored!; break; } bool shared = sharedLease != null; Assert.That(ReferenceEquals(selected, source), Is.EqualTo(shared), "Publish shares the retained message; republish and mirrors copy it."); uint expectedSequence = source.SequenceNumber; var releaseEncoder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); PublishResponse response = shared ? new LeasedPublishResponse(sharedLease) { NotificationMessage = selected } : new PublishResponse { NotificationMessage = selected }; Task encoding = EncodeAfterReleaseAsync(response, releaseEncoder.Task); try { if (mutation == "acknowledge") { Assert.That(queue.TryAcknowledge(expectedSequence), Is.True); } else if (mutation == "evict") { queue.Enqueue( [new NotificationMessage { SequenceNumber = 3 }, new NotificationMessage { SequenceNumber = 4 }], [], out _, out _); } else { queue.Clear(); } // a payload still owned by a response being sent is not recycled. Assert.That(source.IsEmpty, Is.EqualTo(!shared)); } finally { releaseEncoder.TrySetResult(true); } var reused = (NotificationMessage)NotificationMessageActivator.Instance.CreateInstance(); reused.SequenceNumber = 999; PublishResponse decoded; try { decoded = await encoding.ConfigureAwait(false); } finally { reused.Reuse(); } // the transport has sent the response: the last owner recycles the payload. ((IPooledEncodeable)response).Reuse(); Assert.That(source.IsEmpty, Is.True); Assert.That(decoded.NotificationMessage.SequenceNumber, Is.EqualTo(expectedSequence)); Assert.That(decoded.NotificationMessage.NotificationData, Has.Count.EqualTo(1)); if (events) { Assert.That(decoded.NotificationMessage.NotificationData[0].TryGetValue(out EventNotificationList? value), Is.True); Assert.That(value!.Events[0].ClientHandle, Is.EqualTo(77u)); Assert.That(value.Events[0].EventFields[0].GetInt32(), Is.EqualTo(42)); Assert.That(eventItem.ClientHandle, Is.Zero); item.Reuse(); } else { Assert.That(decoded.NotificationMessage.NotificationData[0].TryGetValue(out DataChangeNotification? value), Is.True); Assert.That(value!.MonitoredItems[0].ClientHandle, Is.EqualTo(77u)); Assert.That(value.MonitoredItems[0].Value.WrappedValue.GetInt32(), Is.EqualTo(42)); // data change notifications are not pooled by the server; they are left to the GC. Assert.That(item.ClientHandle, Is.EqualTo(77u)); item.Reuse(); eventItem.Reuse(); } queue.Clear(); } /// /// A client may acknowledge a sequence number before the server finished sending the /// response that carries it. The acknowledgement must not recycle the payload that is /// still being encoded; the send completion does. /// [Test] public void AcknowledgeBeforeSendCompletionDoesNotRecyclePayloadStillBeingSent() { var queue = new SentMessageQueue(() => 1, 4, null, NullLogger.Instance); var message = new CountingNotificationMessage { SequenceNumber = 1 }; var item = new CountingMonitoredItemNotification { ClientHandle = 5, Value = new DataValue(7) }; message.NotificationData = [new ExtensionObject(new DataChangeNotification { MonitoredItems = [item] })]; NotificationMessage sent = queue.Enqueue([message], [], out _, out _, out NotificationMessageLease lease); var response = new LeasedPublishResponse(lease) { NotificationMessage = sent }; Assert.That(queue.TryAcknowledge(1), Is.True); Assert.Multiple(() => { Assert.That(message.ReuseCount, Is.Zero, "acknowledged before it was sent"); Assert.That(item.ReuseCount, Is.Zero); Assert.That(Encode(response).NotificationMessage.SequenceNumber, Is.EqualTo(1u)); }); ((IPooledEncodeable)response).Reuse(); Assert.Multiple(() => { Assert.That(message.ReuseCount, Is.EqualTo(1)); // data change notifications are not pooled by the server and never reused. Assert.That(item.ReuseCount, Is.Zero); }); } /// /// The send completion before the acknowledgement keeps the retained message for /// Republish, which returns the original content. /// [Test] public void RepublishAfterSendCompletedReturnsOriginalContent() { var queue = new SentMessageQueue(() => 1, 4, null, NullLogger.Instance); var message = new CountingNotificationMessage { SequenceNumber = 1 }; var item = new CountingMonitoredItemNotification { ClientHandle = 5, Value = new DataValue(7) }; message.NotificationData = [new ExtensionObject(new DataChangeNotification { MonitoredItems = [item] })]; NotificationMessage sent = queue.Enqueue([message], [], out _, out _, out NotificationMessageLease lease); var response = new LeasedPublishResponse(lease) { NotificationMessage = sent }; Encode(response); ((IPooledEncodeable)response).Reuse(); Assert.That(message.ReuseCount, Is.Zero, "still retained for republish"); NotificationMessage republished = queue.FindForRepublish(1)!; Assert.That(republished, Is.Not.SameAs(message)); Assert.That(republished.IsEqual(message), Is.True); Assert.That(queue.TryAcknowledge(1), Is.True); Assert.That(message.ReuseCount, Is.EqualTo(1)); // the republished copy is independent of the recycled original. PublishResponse decoded = Encode(new PublishResponse { NotificationMessage = republished }); Assert.That(decoded.NotificationMessage.SequenceNumber, Is.EqualTo(1u)); Assert.That( decoded.NotificationMessage.NotificationData[0].TryGetValue(out DataChangeNotification? data), Is.True); Assert.That(data!.MonitoredItems[0].ClientHandle, Is.EqualTo(5u)); Assert.That(data.MonitoredItems[0].Value.WrappedValue.GetInt32(), Is.EqualTo(7)); } /// /// When sending fails the transport drops the response, and a failure path may release /// it more than once (fault after a failed send, retried send on another channel). The /// payload is released exactly once and only after the queue released it too. /// [Test] public void ChannelFailureMidSendReleasesPayloadExactlyOnce( [Values(false, true)] bool acknowledgeFirst) { var queue = new SentMessageQueue(() => 1, 4, null, NullLogger.Instance); var message = new CountingNotificationMessage { SequenceNumber = 1 }; message.NotificationData = [new ExtensionObject(new DataChangeNotification { MonitoredItems = [new CountingMonitoredItemNotification { ClientHandle = 5 }] })]; NotificationMessage sent = queue.Enqueue([message], [], out _, out _, out NotificationMessageLease lease); var response = new LeasedPublishResponse(lease) { NotificationMessage = sent }; // a copy made while the response was in flight owns no reference. var copy = (PublishResponse)response.Clone(); ((IPooledEncodeable)copy).Reuse(); if (acknowledgeFirst) { Assert.That(queue.TryAcknowledge(1), Is.True); } // the send failed: the listener releases the response, then its error path again. ((IPooledEncodeable)response).Reuse(); ((IPooledEncodeable)response).Reuse(); Assert.That(message.ReuseCount, Is.EqualTo(acknowledgeFirst ? 1 : 0)); if (!acknowledgeFirst) { // the client never got it and republishes; it acknowledges afterwards. Assert.That(queue.FindForRepublish(1), Is.Not.Null); Assert.That(queue.TryAcknowledge(1), Is.True); } queue.Clear(); Assert.That(message.ReuseCount, Is.EqualTo(1)); Assert.That(lease.References, Is.Zero); } /// /// The rented array that holds the data change notifications of a message is returned /// to its pool with the payload, after the acknowledgement and the send completion, /// and the message encodes only its slice of it. /// [Test] public void RentedNotificationArrayIsReturnedWithThePayload([Values(false, true)] bool acknowledgeFirst) { var queue = new SentMessageQueue(() => 1, 4, null, NullLogger.Instance); MonitoredItemNotification[] rented = System.Buffers.ArrayPool.Shared.Rent(3); for (int ii = 0; ii < 3; ii++) { rented[ii] = new MonitoredItemNotification { ClientHandle = (uint)ii + 1, Value = new DataValue(ii) }; } var message = new NotificationMessage { SequenceNumber = 1, NotificationData = [new ExtensionObject(new DataChangeNotification { MonitoredItems = new ArrayOf(rented.AsMemory(0, 3)) })] }; MonitoredItemNotification[] originals = rented.Take(3).ToArray(); NotificationMessage sent = queue.Enqueue( [message], [], out _, out _, out NotificationMessageLease lease, [rented]); var response = new LeasedPublishResponse(lease) { NotificationMessage = sent }; PublishResponse decoded = Encode(response); Assert.That( decoded.NotificationMessage.NotificationData[0].TryGetValue(out DataChangeNotification? data), Is.True); Assert.That(data!.MonitoredItems.Count, Is.EqualTo(3)); Assert.That(data.MonitoredItems[2].ClientHandle, Is.EqualTo(3u)); if (acknowledgeFirst) { Assert.That(queue.TryAcknowledge(1), Is.True); Assert.That(rented[0], Is.SameAs(originals[0]), "still being sent"); ((IPooledEncodeable)response).Reuse(); } else { ((IPooledEncodeable)response).Reuse(); Assert.That(rented[0], Is.SameAs(originals[0]), "still retained for republish"); Assert.That(queue.TryAcknowledge(1), Is.True); } // returned with clearArray, so it no longer references the recycled notifications // (another test may have rented it again since). for (int ii = 0; ii < originals.Length; ii++) { Assert.That(rented[ii], Is.Not.SameAs(originals[ii])); } } /// /// Messages dropped unsent on queue overflow return their rented arrays. /// [Test] public void OverflowReturnsTheRentedArraysOfDroppedMessages() { var queue = new SentMessageQueue(() => 1, 1, null, NullLogger.Instance); var droppedItem = new MonitoredItemNotification { ClientHandle = 1 }; var keptItem = new MonitoredItemNotification { ClientHandle = 2 }; MonitoredItemNotification[] dropped = System.Buffers.ArrayPool.Shared.Rent(1); dropped[0] = droppedItem; MonitoredItemNotification[] kept = System.Buffers.ArrayPool.Shared.Rent(1); kept[0] = keptItem; List messages = [ new NotificationMessage { SequenceNumber = 1 }, new NotificationMessage { SequenceNumber = 2 } ]; List rented = [dropped, kept]; NotificationMessage sent = queue.Enqueue(messages, [], out _, out uint discarded, out NotificationMessageLease lease, rented); Assert.Multiple(() => { Assert.That(sent.SequenceNumber, Is.EqualTo(2u)); Assert.That(discarded, Is.EqualTo(1u)); Assert.That(dropped[0], Is.Not.SameAs(droppedItem), "returned with the dropped message"); Assert.That(kept[0], Is.SameAs(keptItem)); Assert.That(rented, Has.Count.EqualTo(1), "trimmed with the messages"); }); lease.Release(); queue.Clear(); Assert.That(kept[0], Is.Not.SameAs(keptItem)); } /// /// A release beyond the owners never recycles the payload a second time, and a /// later regular release still finds the count intact. /// [Test] public void ExtraReleaseNeverRecyclesTwice() { var message = new CountingNotificationMessage { SequenceNumber = 1 }; var lease = new NotificationMessageLease(message); lease.Release(); lease.Release(); Assert.That(message.ReuseCount, Is.EqualTo(1)); Assert.That(lease.References, Is.Zero); var shared = new CountingNotificationMessage { SequenceNumber = 2 }; var sharedLease = new NotificationMessageLease(shared); sharedLease.AddReference(); sharedLease.Release(); Assert.That(shared.ReuseCount, Is.Zero, "one owner left"); sharedLease.Release(); sharedLease.Release(); Assert.That(shared.ReuseCount, Is.EqualTo(1)); } /// /// The retained messages are exposed read-only, in queue order, as the shared /// instances (not copies). /// [Test] public void SentMessagesExposesTheRetainedMessagesInOrder() { var queue = new SentMessageQueue(() => 1, 4, null, NullLogger.Instance); var first = new NotificationMessage { SequenceNumber = 1 }; var second = new NotificationMessage { SequenceNumber = 2 }; queue.Enqueue([first, second], [], out _, out _, out NotificationMessageLease lease); lease.Release(); IReadOnlyList sent = queue.SentMessages; Assert.Multiple(() => { Assert.That(sent, Has.Count.EqualTo(2)); Assert.That(sent[1], Is.SameAs(second)); Assert.That(sent.ToList(), Is.EqualTo(new[] { first, second })); Assert.That(((System.Collections.IEnumerable)sent).Cast().First(), Is.SameAs(first)); }); queue.Clear(); Assert.That(sent, Is.Empty); } /// /// A keep-alive is not retained for republish, so its response is the only owner and /// recycles it once it was sent. /// [Test] public void KeepAliveMessageIsOwnedByItsResponseOnly() { var keepAlive = new CountingNotificationMessage { SequenceNumber = 3 }; var lease = new NotificationMessageLease(keepAlive); var response = new LeasedPublishResponse(lease) { NotificationMessage = keepAlive }; Assert.That(keepAlive.ReuseCount, Is.Zero); ((IPooledEncodeable)response).Reuse(); Assert.That(keepAlive.ReuseCount, Is.EqualTo(1)); } private static PublishResponse Encode(PublishResponse response) { ServiceMessageContext context = ServiceMessageContext.Create(NUnitTelemetryContext.Create()); using var encoder = new BinaryEncoder(context); response.Encode(encoder); using var decoder = new BinaryDecoder(encoder.CloseAndReturnBuffer()!, context); var decoded = new PublishResponse(); decoded.Decode(decoder); return decoded; } /// /// Waits for queue mutation before round-tripping the retained publish response through binary encoding. /// private static async Task EncodeAfterReleaseAsync(PublishResponse response, Task release) { await release.ConfigureAwait(false); return Encode(response); } /// /// Counts recycling instead of returning the instance to the shared pool. /// private sealed class CountingNotificationMessage : NotificationMessage { public int ReuseCount { get; private set; } protected override void ReuseCore() { ReuseCount++; } } /// /// Counts recycling instead of returning the instance to the shared pool. /// private sealed class CountingMonitoredItemNotification : MonitoredItemNotification { public int ReuseCount { get; private set; } protected override void ReuseCore() { ReuseCount++; } } } }