diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/session/TransferFailureOrderingTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/session/TransferFailureOrderingTest.java
new file mode 100644
index 0000000000..9137a1aa6b
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/session/TransferFailureOrderingTest.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.session;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.OpcUaSession;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.sdk.client.subscriptions.OpcUaSubscription;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.DelegatingSessionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsResponse;
+import org.eclipse.milo.opcua.stack.core.util.Unit;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The ordering between failed-Subscription cleanup and reconnect initialization.
+ *
+ *
A replacement Session is forced by refusing re-activation. Its TransferSubscriptions answer
+ * then either contains mixed Good/Bad operation results or reports an expected unsupported-service
+ * failure. The failed Subscription holds its overridable callback, widening the scheduling window
+ * deterministically: cleanup must already be complete and the multi-threaded Session FSM must be
+ * free to enter Initializing while application notification remains held.
+ */
+public class TransferFailureOrderingTest {
+
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+ private static final long RECONNECT_TIMEOUT_MILLIS = 30_000;
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+ private static final long DISCONNECT_TIMEOUT_MILLIS = 5_000;
+
+ /**
+ * A failed TransferResult and an expected service-level transfer failure both mean the affected
+ * Subscription does not exist on the replacement Session. It must therefore be absent before
+ * Initializing can lead to onSessionActive recovery, while a successfully transferred peer is
+ * retained.
+ */
+ @Test
+ void mixedTransferResultsAreRemovedBeforeReconnectInitialization() throws Exception {
+ transferFailureCallbacksDoNotDelayReconnectInitialization(TransferResponse.MIXED_RESULTS);
+ }
+
+ @Test
+ void unsupportedTransferIsRemovedBeforeReconnectInitialization() throws Exception {
+ transferFailureCallbacksDoNotDelayReconnectInitialization(TransferResponse.SERVICE_UNSUPPORTED);
+ }
+
+ private void transferFailureCallbacksDoNotDelayReconnectInitialization(TransferResponse response)
+ throws Exception {
+
+ try (var fixture = new Fixture(response, FailureNotification.HOLD)) {
+ fixture.beginReplacementSessionReconnect();
+ fixture.awaitTransferFailureNotification();
+
+ assertTrue(
+ fixture.awaitReconnectInitialization(RECONNECT_TIMEOUT_MILLIS),
+ "a blocking transfer-failure callback kept the replacement Session in Transferring");
+
+ List subscriptionsAtActive = fixture.awaitSubscriptionsAtSessionActive();
+ List expected =
+ response == TransferResponse.MIXED_RESULTS
+ ? List.of(fixture.successfulSubscription)
+ : List.of();
+
+ assertEquals(
+ expected,
+ subscriptionsAtActive,
+ "onSessionActive observed Subscriptions that were not transferred to the replacement"
+ + " Session");
+ assertTrue(
+ fixture.failedSubscription.getSubscriptionId().isEmpty(),
+ "the failed Subscription was not reset before the Session became Active");
+
+ fixture.releaseTransferFailureNotification();
+ }
+ }
+
+ /**
+ * Application callbacks must not be able to strand the Session FSM in Transferring. If an
+ * overridden notification fails before delegating, the local reset is still required before the
+ * reconnect proceeds.
+ */
+ @Test
+ void exceptionFromTransferFailureNotificationDoesNotWedgeReconnect() throws Exception {
+ try (var fixture = new Fixture(TransferResponse.MIXED_RESULTS, FailureNotification.THROW)) {
+
+ fixture.beginReplacementSessionReconnect();
+ fixture.awaitTransferFailureNotification();
+
+ assertTrue(
+ fixture.awaitReconnectInitialization(RECONNECT_TIMEOUT_MILLIS),
+ "an exception from transfer-failure notification wedged the Session FSM");
+ assertEquals(
+ List.of(fixture.successfulSubscription),
+ fixture.awaitSubscriptionsAtSessionActive(),
+ "the notification failed before delegating, but internal cleanup did not reset the"
+ + " failed Subscription");
+ assertTrue(
+ fixture.failedSubscription.getSubscriptionId().isEmpty(),
+ "internal cleanup did not clear the failed SubscriptionId");
+ }
+ }
+
+ private enum TransferResponse {
+ MIXED_RESULTS,
+ SERVICE_UNSUPPORTED
+ }
+
+ private enum FailureNotification {
+ HOLD,
+ THROW
+ }
+
+ /** Refuses one re-activation, forcing the FSM to create a replacement Session. */
+ private static final class RefusingSessionServiceSet extends DelegatingSessionServiceSet {
+
+ private final AtomicBoolean refuseNextActivation = new AtomicBoolean(false);
+
+ RefusingSessionServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ @Override
+ public ActivateSessionResponse onActivateSession(
+ ServiceRequestContext context, ActivateSessionRequest request) throws UaException {
+
+ if (refuseNextActivation.compareAndSet(true, false)) {
+ throw new UaException(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ return super.onActivateSession(context, request);
+ }
+ }
+
+ /** Supplies either mixed operation results or an expected service-level failure. */
+ private static final class TransferSubscriptionServiceSet
+ extends ScriptableSubscriptionServiceSet {
+
+ private volatile TransferResponse response;
+ private volatile UInteger failedSubscriptionId;
+
+ TransferSubscriptionServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ @Override
+ public TransferSubscriptionsResponse onTransferSubscriptions(
+ ServiceRequestContext context, TransferSubscriptionsRequest request) throws UaException {
+
+ TransferResponse response = this.response;
+
+ if (response == null) {
+ return super.onTransferSubscriptions(context, request);
+ } else if (response == TransferResponse.SERVICE_UNSUPPORTED) {
+ throw new UaException(StatusCodes.Bad_ServiceUnsupported);
+ }
+
+ UInteger[] subscriptionIds = request.getSubscriptionIds();
+ int count = subscriptionIds != null ? subscriptionIds.length : 0;
+ var results = new TransferResult[count];
+
+ for (int i = 0; i < count; i++) {
+ StatusCode status =
+ subscriptionIds[i].equals(failedSubscriptionId)
+ ? new StatusCode(StatusCodes.Bad_SubscriptionIdInvalid)
+ : StatusCode.GOOD;
+
+ results[i] = new TransferResult(status, new UInteger[0]);
+ }
+
+ var responseHeader =
+ new ResponseHeader(
+ DateTime.now(),
+ request.getRequestHeader().getRequestHandle(),
+ StatusCode.GOOD,
+ null,
+ null,
+ null);
+
+ return new TransferSubscriptionsResponse(responseHeader, results, null);
+ }
+ }
+
+ /** Holds or rejects the overridable notification after internal transfer cleanup has run. */
+ private static final class ControllableSubscription extends OpcUaSubscription {
+
+ private final FailureNotification behavior;
+ private final CountDownLatch notificationEntered = new CountDownLatch(1);
+ private final CountDownLatch notificationGate = new CountDownLatch(1);
+
+ ControllableSubscription(OpcUaClient client, FailureNotification behavior) {
+ super(client);
+ this.behavior = behavior;
+ }
+
+ @Override
+ public void notifyTransferFailed(StatusCode status) {
+ notificationEntered.countDown();
+
+ if (behavior == FailureNotification.THROW) {
+ throw new IllegalStateException("scripted transfer-failure notification exception");
+ }
+
+ try {
+ if (!notificationGate.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
+ throw new IllegalStateException("timed out waiting to release transfer-failure cleanup");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+
+ super.notifyTransferFailed(status);
+ }
+ }
+
+ private static final class Fixture implements AutoCloseable {
+
+ private final ExecutorService executor =
+ Executors.newFixedThreadPool(4, daemonThreadFactory("transfer-failure-ordering"));
+
+ private final CountDownLatch sessionInactive = new CountDownLatch(1);
+ private final CountDownLatch reconnectInitializationStarted = new CountDownLatch(1);
+ private final CompletableFuture> subscriptionsAtSessionActive =
+ new CompletableFuture<>();
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final TransferSubscriptionServiceSet subscriptionServiceSet;
+ private final RefusingSessionServiceSet sessionServiceSet;
+
+ private final ControllableSubscription failedSubscription;
+ private final OpcUaSubscription successfulSubscription;
+
+ Fixture(TransferResponse response, FailureNotification notification) throws Exception {
+ server = TestServer.create().getServer();
+ subscriptionServiceSet = new TransferSubscriptionServiceSet(server);
+ sessionServiceSet = new RefusingSessionServiceSet(server);
+
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), subscriptionServiceSet);
+ server.addServiceSet(endpoint.getPath(), sessionServiceSet);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ transportConfig -> transportConfig.setExecutor(executor),
+ config ->
+ config
+ .setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ .setMaxPendingPublishRequests(uint(3)));
+ client.connect();
+
+ client.addSessionActivityListener(
+ new SessionActivityListener() {
+ @Override
+ public void onSessionInactive(UaSession session) {
+ sessionInactive.countDown();
+ }
+
+ @Override
+ public void onSessionActive(UaSession session) {
+ if (sessionInactive.getCount() == 0) {
+ subscriptionsAtSessionActive.complete(client.getSubscriptions());
+ }
+ }
+ });
+ client.addSessionInitializer(
+ (OpcUaClient ignoredClient, OpcUaSession ignoredSession) -> {
+ reconnectInitializationStarted.countDown();
+ return CompletableFuture.completedFuture(Unit.VALUE);
+ });
+
+ failedSubscription = new ControllableSubscription(client, notification);
+ failedSubscription.create();
+
+ successfulSubscription = new OpcUaSubscription(client);
+ successfulSubscription.create();
+
+ subscriptionServiceSet.failedSubscriptionId =
+ failedSubscription.getSubscriptionId().orElseThrow();
+ subscriptionServiceSet.response = response;
+
+ assertTrue(
+ awaitTrue(() -> subscriptionServiceSet.getParkedRequestCount() > 0, AWAIT_TIMEOUT_MILLIS),
+ "the client did not establish a Publish pipeline before the reconnect");
+ }
+
+ void beginReplacementSessionReconnect() {
+ sessionServiceSet.refuseNextActivation.set(true);
+ subscriptionServiceSet.enqueueServiceFault(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ void awaitTransferFailureNotification() throws Exception {
+ assertTrue(
+ sessionInactive.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the scripted Session fault did not make the Session inactive");
+ assertTrue(
+ failedSubscription.notificationEntered.await(
+ RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the replacement Session never reached failed-Subscription cleanup");
+ }
+
+ boolean awaitReconnectInitialization(long timeoutMillis) throws InterruptedException {
+ return reconnectInitializationStarted.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ void releaseTransferFailureNotification() {
+ failedSubscription.notificationGate.countDown();
+ }
+
+ List awaitSubscriptionsAtSessionActive() throws Exception {
+ return subscriptionsAtSessionActive.get(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public void close() throws Exception {
+ releaseTransferFailureNotification();
+ subscriptionServiceSet.failParkedRequests(StatusCodes.Bad_NoSubscription);
+
+ try {
+ client.disconnectAsync().get(DISCONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ignored) {
+ // An assertion can leave reconnect work in flight. Server and executor teardown below
+ // releases it without obscuring the assertion that failed.
+ } finally {
+ try {
+ server.shutdown().get(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+ }
+ }
+
+ private static ThreadFactory daemonThreadFactory(String prefix) {
+ var sequence = new AtomicInteger();
+
+ return runnable -> {
+ var thread = new Thread(runnable, prefix + "-" + sequence.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ };
+ }
+
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis)
+ throws Exception {
+
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(10);
+ }
+
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/AbandonedGapAcknowledgementTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/AbandonedGapAcknowledgementTest.java
new file mode 100644
index 0000000000..f5f3dac30e
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/AbandonedGapAcknowledgementTest.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the client acknowledges when it gives a gap up as lost data.
+ *
+ *
Part 4 §5.14.7.1, of availableSequenceNumbers: "The Client should acknowledge all Messages in
+ * this list for which it will not request retransmission." An unacknowledged NotificationMessage
+ * stays in the Server's retransmission queue, is re-advertised in the availableSequenceNumbers of
+ * every subsequent PublishResponse, and holds its memory for the life of the Subscription. So when
+ * the client decides a gap is too large to recover — {@code missingSequenceNumbers} reports it as
+ * lost data and never asks for any of it — the messages it is abandoning have to be acknowledged,
+ * or the Server holds them forever for a retransmission that will never be requested. That
+ * acknowledgement is what 93bedb32c added.
+ *
+ *
This deliberately acknowledges NotificationMessages the client never received, which is
+ * the opposite of what {@link PublishAcknowledgementTest} and {@code
+ * PublishSequenceRecoveryTest.InitialKeepAlive} pin. Both rules are correct, and the distinction is
+ * whether the client might still want the message:
+ *
+ *
+ *
While a message is still recoverable — the client will, or might, Republish it — an
+ * acknowledgement destroys the Server's only copy of data the client still wants. §5.14.5.2:
+ * "the Server may delete the Message with this sequence number from its retransmission
+ * queue". Never acknowledge those.
+ *
Once the client has decided never to ask, the Server holding the message serves
+ * nobody. Acknowledge those.
+ *
+ *
+ *
The two tests below are that pair: one drives a gap the client abandons and asserts the
+ * abandoned sequence numbers are acknowledged, the other drives a gap the client does try to
+ * recover and asserts that a message it failed to recover is not acknowledged.
+ */
+public class AbandonedGapAcknowledgementTest {
+
+ /** How long to wait for something that must happen. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 5_000;
+
+ /**
+ * Long enough that nothing times out on its own, so a parked Publish request stays parked and any
+ * failure observed below is scripted rather than incidental.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ private TestServer testServer;
+ private OpcUaServer server;
+ private OpcUaClient client;
+ private ScriptableSubscriptionServiceSet scriptable;
+ private OpcUaSubscription subscription;
+ private UInteger subscriptionId;
+ private UInteger clientHandle;
+
+ /** Every {@code retransmitSequenceNumber} the client has asked the Server to Republish. */
+ private final List republishRequests = Collections.synchronizedList(new ArrayList<>());
+
+ private final AtomicInteger dataReceivedCount = new AtomicInteger();
+ private final AtomicInteger notificationDataLostCount = new AtomicInteger();
+
+ @BeforeEach
+ void startClientAndServerAndCreateSubscription() throws Exception {
+ testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ // Models a Server that has already evicted or cannot produce the requested message, and records
+ // that it was asked at all.
+ scriptable.setRepublishResponder(
+ request -> {
+ republishRequests.add(request.getRetransmitSequenceNumber().longValue());
+
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ });
+
+ server.startup().get();
+
+ client = TestClient.create(server, cfg -> cfg.setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS)));
+ client.connect();
+
+ subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription subscription,
+ List items,
+ List values) {
+
+ dataReceivedCount.incrementAndGet();
+ }
+
+ @Override
+ public void onNotificationDataLost(OpcUaSubscription subscription) {
+ notificationDataLostCount.incrementAndGet();
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // Client-side only: addMonitoredItem assigns the ClientHandle the notification fan-out looks
+ // notifications up by, and no Server-side item takes part in delivering a scripted one.
+ var item = OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+ clientHandle = item.getClientHandle().orElseThrow();
+ }
+
+ @AfterEach
+ void stopClientAndServer() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * The defect. NotificationMessage 1 is received, then 10 arrives with only 2 and 3 still in the
+ * Server's retransmission queue. The gap 2..9 is eight messages and the Server is advertising
+ * two, so it is not recoverable and {@code missingSequenceNumbers} gives it up as lost data
+ * without requesting any of it. The two the Server is still holding must be acknowledged, because
+ * nothing will ever ask for them.
+ */
+ @Test
+ void sequenceNumbersAbandonedWithAnUnrecoverableGapAreAcknowledged() throws Exception {
+ sendDataChange(1, uint(1));
+ assertTrue(awaitAcknowledged(1), "sequence 1 was never acknowledged");
+
+ // Sequences 2..9 were sent and lost; the Server still holds 2 and 3 and has evicted the rest.
+ sendDataChange(10, uint(2), uint(3), uint(10));
+
+ assertTrue(
+ awaitTrue(() -> dataReceivedCount.get() >= 2),
+ "the NotificationMessage that revealed the gap was never delivered");
+ assertTrue(awaitAcknowledged(10), "sequence 10 was never acknowledged");
+ assertTrue(
+ awaitTrue(() -> notificationDataLostCount.get() >= 1),
+ "the gap was not given up on as lost data, so the scenario under test did not happen and"
+ + " the assertions below prove nothing");
+ assertEquals(
+ List.of(),
+ List.copyOf(republishRequests),
+ "a gap given up on as lost data must not be requested via Republish; if it was, this is not"
+ + " the abandoned-gap path");
+
+ assertTrue(
+ awaitAcknowledged(2),
+ "sequence 2 was advertised as available for retransmission and the client has decided never"
+ + " to request it, so Part 4 §5.14.7.1 requires it to be acknowledged. Unacknowledged,"
+ + " it sits in the Server's retransmission queue — re-advertised in every"
+ + " PublishResponse — for the life of the Subscription");
+ assertTrue(
+ awaitAcknowledged(3),
+ "sequence 3 was advertised as available for retransmission and the client has decided never"
+ + " to request it, so Part 4 §5.14.7.1 requires it to be acknowledged");
+ }
+
+ /**
+ * The companion rule, which the test above must not be read as overturning: while a gap is small
+ * enough for the client to try to recover it, a message the recovery failed to bring back
+ * is still not acknowledged. The client never had it, and the Server's copy — if the failure was
+ * transient — is the only one there is.
+ *
+ *
NotificationMessage 1 is received, then 3 arrives with 2 and 3 advertised: a one-message gap
+ * inside a two-message retransmission queue, so it is recoverable and Republish(2) is attempted.
+ * The Republish fails, the data is reported lost, and 2 must remain unacknowledged.
+ */
+ @Test
+ void aSequenceNumberThatFailedToRepublishIsNotAcknowledged() throws Exception {
+ sendDataChange(1, uint(1));
+ assertTrue(awaitAcknowledged(1), "sequence 1 was never acknowledged");
+
+ // Sequence 2 was sent and lost, and the Server still holds it: a recoverable gap.
+ sendDataChange(3, uint(2), uint(3));
+
+ assertEquals(
+ List.of(2L),
+ awaitRepublishRequests(),
+ "the missing NotificationMessage 2 was recoverable and must be requested via Republish; if"
+ + " it was not, this is not the recoverable-gap path and the assertion below proves"
+ + " nothing");
+
+ assertTrue(awaitAcknowledged(3), "sequence 3 was never acknowledged");
+
+ assertFalse(
+ acknowledged(2),
+ "NotificationMessage 2 was requested via Republish and not recovered, so the client never"
+ + " had it: acknowledging it would let the Server delete the only copy of data the"
+ + " client asked for. Only a gap the client has decided never to request may be"
+ + " acknowledged");
+ }
+
+ // region fixture helpers
+
+ /**
+ * Enqueue a PublishResponse carrying a DataChangeNotification at {@code sequenceNumber}, so the
+ * message is a data message rather than a keep-alive and reaches {@code onDataReceived}.
+ */
+ private void sendDataChange(long sequenceNumber, UInteger... available) {
+ scriptable.enqueueDataChange(
+ subscriptionId,
+ sequenceNumber,
+ List.of(
+ new MonitoredItemNotification(
+ clientHandle, new DataValue(Variant.ofInt32((int) sequenceNumber)))),
+ available);
+ }
+
+ private boolean acknowledged(long sequenceNumber) {
+ return scriptable.getReceivedAcknowledgements().stream()
+ .anyMatch(
+ ack ->
+ ack.getSubscriptionId().equals(subscriptionId)
+ && ack.getSequenceNumber().longValue() == sequenceNumber);
+ }
+
+ private boolean awaitAcknowledged(long sequenceNumber) throws Exception {
+ return awaitTrue(() -> acknowledged(sequenceNumber));
+ }
+
+ /** The recorded Republish requests, once at least one has arrived or the timeout has elapsed. */
+ private List awaitRepublishRequests() throws Exception {
+ awaitTrue(() -> !republishRequests.isEmpty());
+
+ return List.copyOf(republishRequests);
+ }
+
+ /** Polls {@code condition} until it holds or {@link #AWAIT_TIMEOUT_MILLIS} elapses. */
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MILLIS);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/GapRepairSessionBindingTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/GapRepairSessionBindingTest.java
new file mode 100644
index 0000000000..eb6f8855dc
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/GapRepairSessionBindingTest.java
@@ -0,0 +1,678 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.DelegatingSessionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Which Session the reactive gap repair sends its Republish requests on.
+ *
+ *
When a PublishResponse reveals a gap in a Subscription's NotificationMessage sequence, the
+ * client recovers the missing messages with Republish (Part 4 §5.14.6) while that Subscription's
+ * processing queue is paused, which is what orders the recovered messages ahead of the one that
+ * revealed the gap. The repair is a chain of round trips, and a Session can disappear in the middle
+ * of it — the fault that took it away may well be what created the gap.
+ *
+ *
Resolving the Session again for each request in the chain is what makes that dangerous. A
+ * request issued while the Session is being re-established finds no Session and waits for the next
+ * one, and a repair that waits is a processing queue that stays paused. Two things are then stuck
+ * behind it. The Subscription's own gap never closes, so the NotificationMessage that revealed it
+ * is never delivered — the client is waiting for a Session in order to ask for data the Session it
+ * already had could have given it. And the reconnect recovery of Part 4 §6.7 is queued on that same
+ * paused queue, so the recovery of every Subscription on the client is incomplete, the
+ * Publish suspension gate never opens, and Subscriptions with nothing missing get no Publish
+ * traffic either.
+ *
+ *
The Session outage is bounded by the test, not by timing: the Server holds the reconnect's
+ * ActivateSession until the test releases it, so "while no Session is available" is a window with
+ * ends the test controls.
+ */
+public class GapRepairSessionBindingTest {
+
+ /** How long to wait for something that must happen. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * How long to wait for work a parked repair would be holding up. A repair that sends its
+ * remaining requests on the Session it already has gets through a loopback Republish round trip
+ * in single-digit milliseconds, so this is generous by three orders of magnitude — but a repair
+ * waiting for a Session that the test is holding back does not finish at all, so no larger value
+ * would change an outcome.
+ */
+ private static final long STALL_WINDOW_MILLIS = 5_000;
+
+ /**
+ * The Session FSM waits one second in {@code ReactivatingWait} before its first re-activation
+ * attempt and doubles the wait on every failure; this window allows for several attempts.
+ */
+ private static final long RECONNECT_TIMEOUT_MILLIS = 30_000;
+
+ /**
+ * Long enough that nothing times out on its own: no parked Publish request, no Republish, and no
+ * Session keep-alive. Every stall asserted against below is therefore the client's own doing.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ /** Upper bound on how long a gated request is held, so nothing hangs indefinitely. */
+ private static final long GATE_TIMEOUT_MILLIS = 30_000;
+
+ private static final long DISCONNECT_TIMEOUT_MILLIS = 5_000;
+
+ /** The last NotificationMessage each Subscription accounts for before the gap is created. */
+ private static final long FIRST_SEQUENCE_NUMBER = 1;
+
+ /** NotificationMessages 2, 3 and 4 are missing when 5 arrives. */
+ private static final long GAP_REVEALED_BY = 5;
+
+ private static final long FIRST_MISSING = 2;
+ private static final long SECOND_MISSING = 3;
+ private static final long LAST_MISSING = 4;
+
+ /**
+ * A repair that outlives the Session it began on must finish on the Session it has, not wait for
+ * the one being established. Everything it still needs is data the Server was already holding for
+ * it, and the Session that can ask for it is the one the gap was found on.
+ */
+ @Test
+ void gapRepairFinishesOnTheSessionInHandWhileTheNextOneIsBeingEstablished() throws Exception {
+ try (var fixture = new Fixture()) {
+ Sub repairing = fixture.repairing();
+
+ fixture.holdFirstRepublish();
+ fixture.holdReconnectActivateSession();
+
+ fixture.revealGap(repairing);
+ fixture.awaitFirstRepublishHeld();
+
+ fixture.faultSession();
+ fixture.awaitReconnectActivateSessionHeld();
+
+ fixture.releaseFirstRepublish();
+
+ assertTrue(
+ fixture.awaitRepublished(repairing, LAST_MISSING, STALL_WINDOW_MILLIS),
+ "the repair stopped after its first Republish was answered: the requests still to be made"
+ + " went looking for a Session, found the one they began on gone, and waited for the"
+ + " one being established. Republish requests received: "
+ + fixture.republishRequests());
+
+ assertEquals(
+ List.of(1, 2, 3, 4, 5),
+ fixture.deliveredValues(repairing),
+ "every recovered NotificationMessage, and then the one that revealed the gap, must reach"
+ + " the application while the repair's own Session is still the only one there is;"
+ + " nothing here needs the Session that is being established");
+
+ assertEquals(
+ 1,
+ fixture.sessionReactivatedCount(),
+ "precondition: the Server is still holding the reconnect's ActivateSession, so the"
+ + " assertions above must have been met with no new Session available at all");
+ }
+ }
+
+ /**
+ * The repair pauses one Subscription's processing queue, and the reconnect recovery of Part 4
+ * §6.7 is queued on that same queue. A repair that never finishes therefore holds a recovery that
+ * never finishes, and the Publish suspension gate — which waits for every Subscription's recovery
+ * — never opens for any of them. A Subscription with nothing missing stops receiving data because
+ * a different one lost a message.
+ *
+ *
The Server stops answering the repair's remaining Republish requests at the moment the
+ * reconnect completes, so a repair that has already finished by then is unaffected and a repair
+ * that resumes on the new Session is held. That is the difference the assertion measures.
+ */
+ @Test
+ void publishTrafficForOtherSubscriptionsIsNotHeldBehindAParkedGapRepair() throws Exception {
+ try (var fixture = new Fixture()) {
+ Sub repairing = fixture.repairing();
+ Sub healthy = fixture.healthy();
+
+ fixture.holdFirstRepublish();
+ fixture.holdReconnectActivateSession();
+
+ fixture.revealGap(repairing);
+ fixture.awaitFirstRepublishHeld();
+
+ fixture.faultSession();
+ fixture.awaitReconnectActivateSessionHeld();
+
+ fixture.releaseFirstRepublish();
+
+ // Not asserted: a repair bound to the Session it began on is done by now, and a repair
+ // waiting
+ // for the next Session is not. Either way, what follows only holds the requests a repair has
+ // not made yet.
+ fixture.awaitRepublished(repairing, LAST_MISSING, STALL_WINDOW_MILLIS);
+
+ fixture.holdRepublishesFor(repairing, SECOND_MISSING, LAST_MISSING);
+ fixture.releaseReconnectActivateSession();
+ fixture.awaitReactivation();
+
+ fixture.enqueueDataChange(healthy, FIRST_SEQUENCE_NUMBER + 1);
+
+ assertTrue(
+ fixture.awaitDeliveredValues(
+ healthy,
+ List.of((int) FIRST_SEQUENCE_NUMBER, (int) FIRST_SEQUENCE_NUMBER + 1),
+ STALL_WINDOW_MILLIS),
+ "a Subscription with nothing missing received no NotificationMessage after the reconnect:"
+ + " the other Subscription's gap repair resumed on the new Session and is holding its"
+ + " processing queue paused, so the reconnect recovery queued there cannot run, the"
+ + " Publish suspension gate never opens, and no PublishRequest is sent for any"
+ + " Subscription. Delivered: "
+ + fixture.deliveredValues(healthy));
+ }
+ }
+
+ /**
+ * The control for the test above: the identical reconnect, with no gap and therefore no repair in
+ * flight when the Session went away. It proves the fixture's second Subscription does receive
+ * NotificationMessages once a reconnect is over, so a failure above is about the parked repair
+ * rather than about a fixture that never delivers anything.
+ */
+ @Test
+ void publishTrafficForOtherSubscriptionsResumesAfterAReconnectWithNoRepairInFlight()
+ throws Exception {
+
+ try (var fixture = new Fixture()) {
+ Sub healthy = fixture.healthy();
+
+ fixture.holdReconnectActivateSession();
+
+ fixture.faultSession();
+ fixture.awaitReconnectActivateSessionHeld();
+
+ fixture.releaseReconnectActivateSession();
+ fixture.awaitReactivation();
+
+ fixture.enqueueDataChange(healthy, FIRST_SEQUENCE_NUMBER + 1);
+
+ assertTrue(
+ fixture.awaitDeliveredValues(
+ healthy,
+ List.of((int) FIRST_SEQUENCE_NUMBER, (int) FIRST_SEQUENCE_NUMBER + 1),
+ STALL_WINDOW_MILLIS),
+ "control: a Subscription with nothing missing must receive NotificationMessages again"
+ + " once the reconnect is over. Delivered: "
+ + fixture.deliveredValues(healthy));
+ }
+ }
+
+ // region fixture
+
+ /** One Subscription: its Server-assigned id and what its listener has observed. */
+ private record Sub(
+ UInteger subscriptionId, UInteger clientHandle, List deliveredValues) {}
+
+ /**
+ * A {@link DelegatingSessionServiceSet} that can hold one ActivateSession until the test releases
+ * it, which is how the tests above bound the window during which the client has no Session.
+ */
+ private static final class GatingSessionServiceSet extends DelegatingSessionServiceSet {
+
+ private final AtomicBoolean holdNext = new AtomicBoolean(false);
+
+ private final CountDownLatch heldActivateSession = new CountDownLatch(1);
+ private final CountDownLatch activateSessionGate = new CountDownLatch(1);
+
+ GatingSessionServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ @Override
+ public ActivateSessionResponse onActivateSession(
+ ServiceRequestContext context, ActivateSessionRequest request) throws UaException {
+
+ if (holdNext.compareAndSet(true, false)) {
+ heldActivateSession.countDown();
+
+ await(activateSessionGate);
+ }
+
+ return super.onActivateSession(context, request);
+ }
+ }
+
+ /**
+ * A running Server whose Publish and Republish responses are scripted and whose reconnect
+ * ActivateSession can be held, plus a connected client with two Subscriptions: one that will be
+ * made to lose NotificationMessages, and one that has nothing missing throughout.
+ *
+ *
The client is configured for two pending PublishRequests, which is what makes the pipeline
+ * state unambiguous once a gap has been revealed and the Session faulted: one request carries the
+ * PublishResponse that reveals the gap, the other carries the Session fault, and nothing is left
+ * parked at the Server that could answer a later scripted response without the client having sent
+ * a new request for it.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private static final long MAX_PENDING_PUBLISH_REQUESTS = 2;
+
+ /** Every Republish the Server has received, as {@code subscriptionId:sequenceNumber}. */
+ private final List republishRequests = Collections.synchronizedList(new ArrayList<>());
+
+ /** Counted down when a Republish the test asked to have held reaches the Server. */
+ private final CountDownLatch firstRepublishHeld = new CountDownLatch(1);
+
+ /** Releases the first held Republish. */
+ private final CountDownLatch firstRepublishGate = new CountDownLatch(1);
+
+ /** Releases every Republish held by {@link #holdRepublishesFor}. */
+ private final CountDownLatch repairGate = new CountDownLatch(1);
+
+ private final AtomicBoolean holdFirstRepublish = new AtomicBoolean(false);
+
+ /**
+ * Republish requests, as {@code subscriptionId:sequenceNumber}, held by {@link #repairGate}.
+ */
+ private final Set heldRepairRequests = ConcurrentHashMap.newKeySet();
+
+ /**
+ * The sequence numbers the Server is holding for retransmission, per Subscription. Anything
+ * else is answered Bad_MessageNotAvailable.
+ */
+ private final Map> retransmissionQueues = new ConcurrentHashMap<>();
+
+ private final CountDownLatch sessionInactive = new CountDownLatch(1);
+ private final CountDownLatch sessionReactivated = new CountDownLatch(1);
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final ScriptableSubscriptionServiceSet scriptable;
+ private final GatingSessionServiceSet sessionServiceSet;
+
+ private final Sub repairing;
+ private final Sub healthy;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ sessionServiceSet = new GatingSessionServiceSet(server);
+
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ server.addServiceSet(endpoint.getPath(), sessionServiceSet);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ cfg ->
+ cfg.setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a test
+ // are
+ // the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ .setMaxPendingPublishRequests(uint(MAX_PENDING_PUBLISH_REQUESTS)));
+ client.connect();
+
+ client.addSessionActivityListener(
+ new SessionActivityListener() {
+ @Override
+ public void onSessionInactive(UaSession session) {
+ sessionInactive.countDown();
+ }
+
+ @Override
+ public void onSessionActive(UaSession session) {
+ if (sessionInactive.getCount() == 0) {
+ sessionReactivated.countDown();
+ }
+ }
+ });
+
+ scriptable.setRepublishResponder(this::respondToRepublish);
+
+ repairing = createSubscription();
+ healthy = createSubscription();
+
+ // The Server is holding every NotificationMessage the gap will be made of, and nothing for
+ // the
+ // Subscription that has nothing missing.
+ retransmissionQueues.put(
+ repairing.subscriptionId(), Set.of(FIRST_MISSING, SECOND_MISSING, LAST_MISSING));
+ retransmissionQueues.put(healthy.subscriptionId(), Set.of());
+
+ deliverFirstNotification(repairing);
+ deliverFirstNotification(healthy);
+
+ assertTrue(
+ awaitTrue(
+ () -> scriptable.getParkedRequestCount() == MAX_PENDING_PUBLISH_REQUESTS,
+ AWAIT_TIMEOUT_MILLIS),
+ "the client did not refill its Publish pipeline");
+ }
+
+ Sub repairing() {
+ return repairing;
+ }
+
+ Sub healthy() {
+ return healthy;
+ }
+
+ private Sub createSubscription() throws UaException {
+ List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ var subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription s, List items, List values) {
+
+ for (DataValue value : values) {
+ deliveredValues.add((Integer) value.getValue().getValue());
+ }
+ }
+ });
+ subscription.create();
+
+ // The MonitoredItem only has to exist on the client: addMonitoredItem assigns the
+ // ClientHandle
+ // the notification fan-out looks scripted notifications up by, and no Server-side item
+ // participates in delivering one.
+ OpcUaMonitoredItem item =
+ OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+
+ return new Sub(
+ subscription.getSubscriptionId().orElseThrow(),
+ item.getClientHandle().orElseThrow(),
+ deliveredValues);
+ }
+
+ /**
+ * Deliver NotificationMessage {@value #FIRST_SEQUENCE_NUMBER}, leaving this Subscription's last
+ * accounted-for sequence number at it.
+ */
+ private void deliverFirstNotification(Sub sub) throws Exception {
+ enqueueDataChange(sub, FIRST_SEQUENCE_NUMBER);
+
+ assertTrue(
+ awaitTrue(() -> !deliveredValues(sub).isEmpty(), AWAIT_TIMEOUT_MILLIS),
+ "the first NotificationMessage was never delivered");
+ }
+
+ /**
+ * Script the next PublishResponse as a data message for {@code sub} carrying {@code
+ * sequenceNumber}, whose value identifies the NotificationMessage it came from.
+ */
+ void enqueueDataChange(Sub sub, long sequenceNumber) {
+ scriptable.enqueue(
+ request ->
+ CompletableFuture.completedFuture(
+ scriptable.buildPublishResponse(
+ request,
+ sub.subscriptionId(),
+ sequenceNumber,
+ notificationData(sub, sequenceNumber),
+ new UInteger[] {uint(sequenceNumber)},
+ false)));
+ }
+
+ /**
+ * Script a PublishResponse for {@code sub} carrying sequence number {@value #GAP_REVEALED_BY},
+ * leaving {@value #FIRST_MISSING} to {@value #LAST_MISSING} missing and advertising every one
+ * of them as available for retransmission, so the whole gap is recoverable.
+ */
+ void revealGap(Sub sub) {
+ scriptable.enqueue(
+ request ->
+ CompletableFuture.completedFuture(
+ scriptable.buildPublishResponse(
+ request,
+ sub.subscriptionId(),
+ GAP_REVEALED_BY,
+ notificationData(sub, GAP_REVEALED_BY),
+ new UInteger[] {
+ uint(FIRST_MISSING),
+ uint(SECOND_MISSING),
+ uint(LAST_MISSING),
+ uint(GAP_REVEALED_BY)
+ },
+ false)));
+ }
+
+ /** Hold the next Republish request the Server receives until the test releases it. */
+ void holdFirstRepublish() {
+ holdFirstRepublish.set(true);
+ }
+
+ void awaitFirstRepublishHeld() throws Exception {
+ assertTrue(
+ firstRepublishHeld.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "precondition: the gap was never detected, so no repair is in flight");
+ }
+
+ void releaseFirstRepublish() {
+ firstRepublishGate.countDown();
+ }
+
+ /**
+ * Stop answering these Republish requests, if they have not been made yet, until teardown. Used
+ * once the reconnect is about to complete, so that only a repair which resumes on the new
+ * Session is affected.
+ */
+ void holdRepublishesFor(Sub sub, long... sequenceNumbers) {
+ for (long sequenceNumber : sequenceNumbers) {
+ heldRepairRequests.add(republishKey(sub.subscriptionId(), sequenceNumber));
+ }
+ }
+
+ /** Hold the ActivateSession of the next reconnect until the test releases it. */
+ void holdReconnectActivateSession() {
+ sessionServiceSet.holdNext.set(true);
+ }
+
+ void awaitReconnectActivateSessionHeld() throws Exception {
+ assertTrue(
+ sessionInactive.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the scripted Bad_SessionIdInvalid Publish fault did not take the Session out of Active");
+ assertTrue(
+ sessionServiceSet.heldActivateSession.await(
+ RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the client never tried to activate a Session again");
+ }
+
+ void releaseReconnectActivateSession() {
+ sessionServiceSet.activateSessionGate.countDown();
+ }
+
+ void awaitReactivation() throws Exception {
+ assertTrue(
+ sessionReactivated.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the Session never became Active again");
+ }
+
+ /**
+ * Answer one parked PublishRequest with a Bad_SessionIdInvalid ServiceFault, which {@code
+ * SessionFsmFactory}'s SessionFaultListener classifies as a Session error and turns into a
+ * reconnect. The Server-side Session is untouched, so re-activation succeeds and both
+ * Subscriptions survive it.
+ */
+ void faultSession() {
+ scriptable.enqueueServiceFault(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ /**
+ * Answer a Republish the way a Server holding a bounded retransmission queue would: with the
+ * NotificationMessage if it is holding one with that sequence number for that Subscription, and
+ * Bad_MessageNotAvailable if it is not — which is also what terminates the Republish loop Part
+ * 4 §6.7 describes.
+ */
+ private RepublishResponse respondToRepublish(RepublishRequest request) throws UaException {
+ UInteger subscriptionId = request.getSubscriptionId();
+ long sequenceNumber = request.getRetransmitSequenceNumber().longValue();
+ String key = republishKey(subscriptionId, sequenceNumber);
+
+ republishRequests.add(key);
+
+ if (holdFirstRepublish.compareAndSet(true, false)) {
+ firstRepublishHeld.countDown();
+
+ await(firstRepublishGate);
+ } else if (heldRepairRequests.contains(key)) {
+ await(repairGate);
+ }
+
+ if (!retransmissionQueues.getOrDefault(subscriptionId, Set.of()).contains(sequenceNumber)) {
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ }
+
+ return scriptable.buildRepublishResponse(
+ request,
+ sequenceNumber,
+ notificationData(subscriptionOf(subscriptionId), sequenceNumber));
+ }
+
+ private Sub subscriptionOf(UInteger subscriptionId) {
+ return repairing.subscriptionId().equals(subscriptionId) ? repairing : healthy;
+ }
+
+ private ExtensionObject[] notificationData(Sub sub, long sequenceNumber) {
+ var notification =
+ new MonitoredItemNotification(
+ sub.clientHandle(), new DataValue(Variant.ofInt32((int) sequenceNumber)));
+
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(new MonitoredItemNotification[] {notification}, null))
+ };
+ }
+
+ List republishRequests() {
+ return List.copyOf(republishRequests);
+ }
+
+ boolean awaitRepublished(Sub sub, long sequenceNumber, long timeoutMillis) throws Exception {
+ String key = republishKey(sub.subscriptionId(), sequenceNumber);
+
+ return awaitTrue(() -> republishRequests.contains(key), timeoutMillis);
+ }
+
+ List deliveredValues(Sub sub) {
+ return List.copyOf(sub.deliveredValues());
+ }
+
+ boolean awaitDeliveredValues(Sub sub, List expected, long timeoutMillis)
+ throws Exception {
+
+ return awaitTrue(() -> deliveredValues(sub).equals(expected), timeoutMillis);
+ }
+
+ long sessionReactivatedCount() {
+ return sessionReactivated.getCount();
+ }
+
+ /** Polls {@code condition} until it holds or the timeout elapses. */
+ boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(10);
+ }
+
+ return condition.get();
+ }
+
+ @Override
+ public void close() throws Exception {
+ repairGate.countDown();
+ firstRepublishGate.countDown();
+ sessionServiceSet.activateSessionGate.countDown();
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(DISCONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ignored) {
+ // A client whose processing queue is held by a repair that cannot finish may not manage the
+ // disconnect it is asked for; shutting the Server down below is what releases it. Tolerated
+ // here so teardown does not mask the assertion that detected the stall.
+ } finally {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ private static String republishKey(UInteger subscriptionId, long sequenceNumber) {
+ return subscriptionId + ":" + sequenceNumber;
+ }
+
+ /**
+ * Suspend the calling Server dispatch thread until {@code gate} is released. Bounded, so a test
+ * that goes wrong fails rather than hangs; the Server dispatches every service request on its own
+ * executor, so only the thread handling this one request waits.
+ */
+ private static void await(CountDownLatch gate) throws UaException {
+ try {
+ if (!gate.await(GATE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
+ throw new UaException(StatusCodes.Bad_Timeout);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+
+ throw new UaException(StatusCodes.Bad_UnexpectedError, e);
+ }
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/MonitoredItemDeletionTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/MonitoredItemDeletionTest.java
new file mode 100644
index 0000000000..00c79c0e36
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/MonitoredItemDeletionTest.java
@@ -0,0 +1,507 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.DelegatingMonitoredItemServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.DeleteMonitoredItemsRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.DeleteMonitoredItemsResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies the bookkeeping {@link OpcUaSubscription} keeps for MonitoredItems that have been
+ * removed from the Subscription but not yet deleted from the Server.
+ *
+ *
Removing a MonitoredItem only queues it for deletion; the DeleteMonitoredItems service call
+ * happens later, in {@link OpcUaSubscription#deleteMonitoredItems()}. Two things must hold for that
+ * queue: a deletion that did not happen must stay queued, and a deletion that can no longer refer
+ * to anything on the Server must be discarded. Neither costs anything when everything works, and
+ * both are the difference between a leaked item and a wrongly deleted one when it doesn't.
+ */
+public class MonitoredItemDeletionTest {
+
+ /** The queue of pending deletions after a DeleteMonitoredItems service fault. */
+ @Nested
+ class ServiceFault {
+
+ /**
+ * A DeleteMonitoredItems service fault leaves the MonitoredItem alive on the Server, so it must
+ * stay queued for deletion: a service fault is precisely the transient failure a caller is
+ * expected to retry, and the retry has to send the deletion the failed attempt did not perform.
+ * Dropping it strands the item on the Server, publishing notifications that no longer route to
+ * any client-side item, with no way for the application to delete it short of deleting the
+ * whole Subscription.
+ */
+ @Test
+ void failedDeleteStaysQueuedForTheNextDelete() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem item = fixture.createItem(subscription);
+ UInteger monitoredItemId = item.getMonitoredItemId().orElseThrow();
+
+ subscription.removeMonitoredItem(item);
+
+ fixture.serviceSet.failNextDeleteMonitoredItems(1);
+
+ List failedResults =
+ subscription.deleteMonitoredItems();
+
+ assertEquals(1, failedResults.size());
+ assertEquals(item, failedResults.get(0).monitoredItem());
+ assertEquals(
+ StatusCodes.Bad_TooManyOperations,
+ failedResults.get(0).serviceResult().value(),
+ "control: the scripted service fault reached the caller");
+ assertTrue(
+ failedResults.get(0).operationResult().isEmpty(),
+ "control: a service fault means there is no operation-level result");
+
+ assertFalse(
+ subscription.isMonitoredItemsSynchronized(),
+ "the MonitoredItem is still alive on the Server after the failed delete, so the"
+ + " Subscription's MonitoredItems are not synchronized");
+
+ // The retry must send the deletion the first attempt failed to perform.
+ List retryResults =
+ subscription.deleteMonitoredItems();
+
+ assertEquals(
+ 1, retryResults.size(), "the failed deletion must be retried by the next delete");
+ assertEquals(item, retryResults.get(0).monitoredItem());
+ assertTrue(
+ retryResults.get(0).isGood(),
+ "the retried deletion should succeed: " + retryResults.get(0).serviceResult());
+
+ List> deleteRequests = fixture.serviceSet.getDeletedMonitoredItemIds();
+ assertEquals(
+ List.of(List.of(monitoredItemId), List.of(monitoredItemId)),
+ deleteRequests,
+ "the Server should have seen the deletion attempted twice");
+ assertTrue(subscription.isMonitoredItemsSynchronized());
+ }
+ }
+
+ /**
+ * Control for {@link #failedDeleteStaysQueuedForTheNextDelete()}: when the delete succeeds the
+ * item must leave the pending-deletion queue, so a later deleteMonitoredItems() is a no-op.
+ * Without this, "keep failed deletions queued" could be satisfied by never dequeuing anything.
+ */
+ @Test
+ void successfulDeleteLeavesNothingQueued() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem item = fixture.createItem(subscription);
+ UInteger monitoredItemId = item.getMonitoredItemId().orElseThrow();
+
+ subscription.removeMonitoredItem(item);
+
+ List results = subscription.deleteMonitoredItems();
+
+ assertEquals(1, results.size());
+ assertTrue(results.get(0).isGood());
+ assertTrue(subscription.isMonitoredItemsSynchronized());
+
+ assertEquals(List.of(), subscription.deleteMonitoredItems());
+ assertEquals(
+ List.of(List.of(monitoredItemId)),
+ fixture.serviceSet.getDeletedMonitoredItemIds(),
+ "a MonitoredItem that was deleted must not be deleted a second time");
+ }
+ }
+
+ /**
+ * A MonitoredItem whose deletion failed is still owned by the Subscription, so adding it back
+ * has to restore it. If the failed deletion is dropped instead, the item keeps a ClientHandle
+ * that no longer maps to anything, and {@code addMonitoredItem} silently ignores it: the
+ * application is left holding an item it can neither delete nor use.
+ */
+ @Test
+ void itemWhoseDeleteFailedCanBeAddedBack() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem item = fixture.createItem(subscription);
+
+ subscription.removeMonitoredItem(item);
+
+ fixture.serviceSet.failNextDeleteMonitoredItems(1);
+ assertTrue(
+ subscription.deleteMonitoredItems().get(0).serviceResult().isBad(),
+ "control: the delete attempt failed");
+
+ subscription.addMonitoredItem(item);
+
+ assertEquals(
+ List.of(item),
+ subscription.getMonitoredItems(),
+ "an item whose deletion failed is still alive on the Server and must be restored to"
+ + " the Subscription when it is added back");
+ assertEquals(OpcUaMonitoredItem.SyncState.SYNCHRONIZED, item.getSyncState());
+ }
+ }
+
+ /**
+ * Control for {@link #itemWhoseDeleteFailedCanBeAddedBack()}: adding back an item that is still
+ * queued for deletion cancels the pending deletion. This is the path the failed-delete case has
+ * to end up on, so it must work independently of any delete attempt.
+ */
+ @Test
+ void itemQueuedForDeletionCanBeAddedBack() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem item = fixture.createItem(subscription);
+
+ subscription.removeMonitoredItem(item);
+ subscription.addMonitoredItem(item);
+
+ assertEquals(List.of(item), subscription.getMonitoredItems());
+ assertEquals(List.of(), subscription.deleteMonitoredItems());
+ assertEquals(
+ List.of(),
+ fixture.serviceSet.getDeletedMonitoredItemIds(),
+ "an item that was added back must no longer be pending deletion");
+ }
+ }
+
+ /**
+ * Adding an item back while DeleteMonitoredItems is in flight makes it desired client state
+ * again, but cannot retract the request already at the Server. When that request succeeds, the
+ * item must remain mapped with a valid ClientHandle and become pending creation; otherwise the
+ * old map key points at an item whose handle was cleared, and the item can neither be recreated
+ * nor safely added again.
+ */
+ @Test
+ void itemAddedBackDuringSuccessfulDeleteRemainsMappedForRecreation() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem item = fixture.createItem(subscription);
+ UInteger originalClientHandle = item.getClientHandle().orElseThrow();
+
+ subscription.removeMonitoredItem(item);
+ fixture.serviceSet.gateNextDeleteMonitoredItems();
+
+ CompletableFuture> delete =
+ CompletableFuture.supplyAsync(subscription::deleteMonitoredItems);
+
+ fixture.serviceSet.awaitDeleteMonitoredItems();
+ subscription.addMonitoredItem(item);
+
+ assertEquals(
+ List.of(item),
+ subscription.getMonitoredItems(),
+ "control: the item was added back before the Server answered the deletion");
+
+ fixture.serviceSet.releaseDeleteMonitoredItems();
+
+ List deleteResults = delete.get(5, TimeUnit.SECONDS);
+
+ assertEquals(1, deleteResults.size());
+ assertTrue(deleteResults.get(0).isGood());
+ assertEquals(
+ originalClientHandle,
+ item.getClientHandle().orElseThrow(),
+ "the mapped item must retain the ClientHandle used as its map key");
+ assertEquals(List.of(item), subscription.getMonitoredItems());
+ assertEquals(
+ OpcUaMonitoredItem.SyncState.INITIAL,
+ item.getSyncState(),
+ "the successful deletion means the re-added item must be recreated on the Server");
+
+ List createResults =
+ subscription.createMonitoredItems();
+
+ assertEquals(1, createResults.size(), "the re-added item must be created exactly once");
+ assertTrue(createResults.get(0).isGood());
+ assertEquals(List.of(item), subscription.getMonitoredItems());
+ assertTrue(subscription.isMonitoredItemsSynchronized());
+ }
+ }
+ }
+
+ /** The queue of pending deletions across a Subscription's deletion and re-creation. */
+ @Nested
+ class SubscriptionRecreated {
+
+ /**
+ * MonitoredItemIds are scoped to a Subscription, and Milo's Server assigns them from 1 for
+ * every new Subscription. A deletion queued against a Subscription that no longer exists must
+ * therefore be discarded when the Subscription is reset: replaying that MonitoredItemId against
+ * the replacement Subscription does not fail, it deletes whichever item happens to hold the id
+ * now.
+ */
+ @Test
+ void staleMonitoredItemIdIsNotDeletedFromTheNewSubscription() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+ UInteger firstSubscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ OpcUaMonitoredItem itemA = fixture.createItem(subscription);
+ assertEquals(
+ uint(1),
+ itemA.getMonitoredItemId().orElseThrow(),
+ "control: the Server assigns MonitoredItemIds from 1 within a Subscription");
+
+ // Queue A for deletion, then destroy the Subscription it belongs to before the
+ // deletion is sent.
+ subscription.removeMonitoredItem(itemA);
+ subscription.delete();
+
+ subscription.create();
+ UInteger secondSubscriptionId = subscription.getSubscriptionId().orElseThrow();
+ assertNotEquals(firstSubscriptionId, secondSubscriptionId);
+
+ OpcUaMonitoredItem itemB = fixture.createItem(subscription);
+ assertEquals(
+ uint(1),
+ itemB.getMonitoredItemId().orElseThrow(),
+ "pins the collision: B holds the MonitoredItemId A held in the previous Subscription");
+
+ fixture.serviceSet.clearDeletedMonitoredItemIds();
+
+ List results = subscription.deleteMonitoredItems();
+
+ assertEquals(
+ List.of(),
+ fixture.serviceSet.getDeletedMonitoredItemIds(),
+ "a MonitoredItem belonging to a Subscription that no longer exists must not be"
+ + " deleted from its replacement");
+ assertEquals(
+ List.of(),
+ results.stream().map(r -> r.monitoredItem().getReadValueId().getNodeId()).toList(),
+ "there is nothing left to delete after the Subscription was deleted");
+
+ // B, the item that held the recycled MonitoredItemId, must still exist on the Server.
+ subscription.removeMonitoredItem(itemB);
+ List deleteB = subscription.deleteMonitoredItems();
+
+ assertEquals(1, deleteB.size());
+ assertTrue(
+ deleteB.get(0).isGood(),
+ "B should still exist on the Server, but deleting it returned "
+ + deleteB.get(0).operationResult().orElse(deleteB.get(0).serviceResult()));
+ }
+ }
+
+ /**
+ * A MonitoredItem queued for deletion when the Subscription is reset keeps its SyncState, so
+ * the Subscription reports itself as never synchronized: {@code isMonitoredItemsSynchronized()}
+ * stays {@code false} for a Subscription whose items are all created, which is a state no
+ * caller can act on.
+ */
+ @Test
+ void newSubscriptionIsSynchronizedAfterAnItemWasQueuedForDeletion() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ OpcUaMonitoredItem itemA = fixture.createItem(subscription);
+
+ subscription.removeMonitoredItem(itemA);
+ subscription.delete();
+ subscription.create();
+
+ assertTrue(
+ subscription.isMonitoredItemsSynchronized(),
+ "the new Subscription has no MonitoredItems, and the item queued for deletion no"
+ + " longer exists on the Server, so there is nothing left to synchronize");
+ }
+ }
+
+ /**
+ * Control for {@link #newSubscriptionIsSynchronizedAfterAnItemWasQueuedForDeletion()}: a
+ * delete/create cycle with no pending deletion leaves the Subscription synchronized once its
+ * items are created again.
+ */
+ @Test
+ void newSubscriptionIsSynchronizedAfterItemsAreCreatedAgain() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ fixture.createItem(subscription);
+
+ subscription.delete();
+ subscription.create();
+
+ assertFalse(
+ subscription.isMonitoredItemsSynchronized(),
+ "control: the item still has to be created on the new Subscription");
+ assertTrue(subscription.createMonitoredItems().get(0).isGood());
+ assertTrue(subscription.isMonitoredItemsSynchronized());
+ }
+ }
+ }
+
+ // region fixture
+
+ /**
+ * A {@link DelegatingMonitoredItemServiceSet} that records the MonitoredItemIds of every
+ * DeleteMonitoredItems request it receives and can fail the next {@code n} of them with a service
+ * fault.
+ */
+ private static final class RecordingMonitoredItemServiceSet
+ extends DelegatingMonitoredItemServiceSet {
+
+ private final List> deletedMonitoredItemIds =
+ Collections.synchronizedList(new ArrayList<>());
+
+ private final AtomicInteger deleteFailuresRemaining = new AtomicInteger(0);
+ private final AtomicBoolean gateNextDelete = new AtomicBoolean(false);
+ private final CountDownLatch deleteArrived = new CountDownLatch(1);
+ private final CountDownLatch deleteGate = new CountDownLatch(1);
+
+ RecordingMonitoredItemServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ /** The MonitoredItemIds of each DeleteMonitoredItems request, in the order received. */
+ List> getDeletedMonitoredItemIds() {
+ synchronized (deletedMonitoredItemIds) {
+ return List.copyOf(deletedMonitoredItemIds);
+ }
+ }
+
+ void clearDeletedMonitoredItemIds() {
+ deletedMonitoredItemIds.clear();
+ }
+
+ void failNextDeleteMonitoredItems(int count) {
+ deleteFailuresRemaining.set(count);
+ }
+
+ void gateNextDeleteMonitoredItems() {
+ gateNextDelete.set(true);
+ }
+
+ void awaitDeleteMonitoredItems() throws InterruptedException {
+ assertTrue(
+ deleteArrived.await(5, TimeUnit.SECONDS),
+ "the gated DeleteMonitoredItems request never reached the Server");
+ }
+
+ void releaseDeleteMonitoredItems() {
+ deleteGate.countDown();
+ }
+
+ @Override
+ public DeleteMonitoredItemsResponse onDeleteMonitoredItems(
+ ServiceRequestContext context, DeleteMonitoredItemsRequest request) throws UaException {
+
+ UInteger[] ids = request.getMonitoredItemIds();
+ deletedMonitoredItemIds.add(
+ ids == null ? List.of() : Arrays.stream(ids).collect(Collectors.toList()));
+
+ if (deleteFailuresRemaining.getAndDecrement() > 0) {
+ throw new UaException(
+ StatusCodes.Bad_TooManyOperations, "scripted DeleteMonitoredItems failure");
+ }
+
+ if (gateNextDelete.compareAndSet(true, false)) {
+ deleteArrived.countDown();
+
+ try {
+ if (!deleteGate.await(30, TimeUnit.SECONDS)) {
+ throw new UaException(
+ StatusCodes.Bad_Timeout, "the DeleteMonitoredItems gate was never opened");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new UaException(StatusCodes.Bad_UnexpectedError, e);
+ }
+ }
+
+ return super.onDeleteMonitoredItems(context, request);
+ }
+ }
+
+ /** A running Server whose MonitoredItem service calls are recorded, and a connected client. */
+ private static final class Fixture implements AutoCloseable {
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final RecordingMonitoredItemServiceSet serviceSet;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ serviceSet = new RecordingMonitoredItemServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), serviceSet);
+ }
+
+ server.startup().get();
+
+ client = TestClient.create(server, cfg -> {});
+ client.connect();
+ }
+
+ /** Add a MonitoredItem to {@code subscription} and create it on the Server. */
+ OpcUaMonitoredItem createItem(OpcUaSubscription subscription) {
+ var item = OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+
+ List results = subscription.createMonitoredItems();
+ assertEquals(1, results.size());
+ assertTrue(results.get(0).isGood(), "failed to create the MonitoredItem");
+
+ return item;
+ }
+
+ @Override
+ public void close() throws Exception {
+ serviceSet.releaseDeleteMonitoredItems();
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishAcknowledgementTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishAcknowledgementTest.java
new file mode 100644
index 0000000000..91a764f3ca
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishAcknowledgementTest.java
@@ -0,0 +1,530 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.SubscriptionAcknowledgement;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+/**
+ * What {@code PublishingManager} does with SubscriptionAcknowledgements once it has queued them.
+ *
+ *
An acknowledgement is the client's permission for the Server to forget a NotificationMessage:
+ * Part 4 §5.14.5.2 says of subscriptionAcknowledgements that "the Server may delete the Message
+ * with this sequence number from its retransmission queue", and §5.14.7.1 that "the Client should
+ * acknowledge all Messages in this list for which it will not request retransmission". So an
+ * acknowledgement must be sent for every NotificationMessage the client actually has, and must be
+ * sent exactly once it can be — never for a message the client does not have, and never
+ * abandoned, because an acknowledgement that is dropped leaves the Server holding a message
+ * forever (until its retransmission queue evicts it) and leaves availableSequenceNumbers growing.
+ *
+ *
{@code sendPublishRequest} drains the queued acknowledgements into the outgoing PublishRequest
+ * and clears them. That hands ownership of them to a single network request, and the two nested
+ * classes below cover the two ways the Server's answer to that request can contradict the client's
+ * assumption that they arrived:
+ *
+ *
+ *
the request fails outright, so the acknowledgements it carried never took effect;
+ *
the request succeeds but the response's per-acknowledgement results say the Server rejected
+ * one of them (Part 4 §5.14.5.2: "List of results for the acknowledgements... The size and
+ * order of the list matches the size and order of the subscriptionAcknowledgements request
+ * parameter").
+ *
+ */
+public class PublishAcknowledgementTest {
+
+ /** How long to wait for something that must happen. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * How long to wait for a client that has lost an acknowledgement to send it again, and for a
+ * client that has been told an acknowledgement failed to say so. A client that re-queues a lost
+ * acknowledgement puts it on the very next PublishRequest, which is sent within milliseconds of
+ * the failed one completing, so this is generous by three orders of magnitude.
+ */
+ private static final long RECOVERY_WINDOW_MILLIS = 5_000;
+
+ /**
+ * Long enough that nothing times out on its own, so a parked Publish request stays parked and any
+ * failure observed below is scripted rather than incidental.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ /**
+ * The client keeps one more PublishRequest in flight than it has Subscriptions, so a single
+ * Subscription means two.
+ */
+ private static final int PIPELINE_DEPTH = 2;
+
+ /** Distinctive text used to prove the log capture is observing this JVM's SLF4J output. */
+ private static final String CAPTURE_PROBE = "publish-acknowledgement-log-capture-probe";
+
+ /**
+ * A PublishRequest can fail — a timeout, a dropped connection, a transient ServiceFault — and the
+ * acknowledgements it was carrying fail with it. They were removed from the client's queue when
+ * the request was built, so unless they are put back the client has silently decided never to
+ * acknowledge those NotificationMessages: the Server keeps them in its retransmission queue until
+ * it evicts them, and re-advertises them in availableSequenceNumbers on every subsequent
+ * PublishResponse.
+ *
+ *
The failure modelled here is the strong one — the request is failed before the Server
+ * records what it carried, so the acknowledgement is genuinely lost in flight and the Server
+ * never saw it. That is the case where re-sending is unambiguously required; the weaker case (the
+ * Server processed the acknowledgement and the response was lost on the way back) merely costs a
+ * duplicate acknowledgement, which the Server answers with a per-acknowledgement result rather
+ * than a fault.
+ */
+ @Nested
+ class AcknowledgementLostWithItsPublishRequest {
+
+ @Test
+ void acknowledgementIsSentAgainAfterThePublishRequestCarryingItFails() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.loseNextPublishRequestCarryingAcknowledgement(1);
+
+ fixture.sendDataChange(1, uint(1));
+
+ assertTrue(fixture.awaitDelivered(1), "NotificationMessage 1 was never delivered");
+ assertTrue(
+ fixture.awaitAcknowledgementLost(),
+ "no PublishRequest carrying the acknowledgement for sequence 1 ever reached the"
+ + " Server, so the scenario under test did not happen");
+
+ // Keep the Publish pipeline turning so a re-queued acknowledgement has requests to ride on
+ // whether it is restored when the failure is handled or only when the next response is.
+ fixture.sendDataChange(2, uint(1), uint(2));
+ assertTrue(fixture.awaitDelivered(2), "NotificationMessage 2 was never delivered");
+
+ assertTrue(
+ fixture.awaitAcknowledged(1, RECOVERY_WINDOW_MILLIS),
+ "NotificationMessage 1 was received and delivered, but the only acknowledgement for it"
+ + " went out on a PublishRequest that failed and was never sent again: the Server"
+ + " is left holding a message the client already has, and re-advertising it in"
+ + " availableSequenceNumbers, until its retransmission queue evicts it");
+ }
+ }
+
+ /**
+ * The control that keeps the assertion above from passing vacuously: the identical script with
+ * nothing failed. If the client did not acknowledge sequence 1 here either, the test above
+ * would be measuring the fixture rather than the loss.
+ */
+ @Test
+ void acknowledgementIsSentWhenThePublishRequestCarryingItSucceeds() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.sendDataChange(1, uint(1));
+
+ assertTrue(fixture.awaitDelivered(1), "NotificationMessage 1 was never delivered");
+
+ fixture.sendDataChange(2, uint(1), uint(2));
+ assertTrue(fixture.awaitDelivered(2), "NotificationMessage 2 was never delivered");
+
+ assertTrue(
+ fixture.awaitAcknowledged(1, RECOVERY_WINDOW_MILLIS),
+ "control: with no induced failure the acknowledgement for sequence 1 must reach the"
+ + " Server");
+ }
+ }
+ }
+
+ /**
+ * Part 4 §5.14.5.2 gives the PublishResponse a results array: "List of results for the
+ * acknowledgements. The size and order of the list matches the size and order of the
+ * subscriptionAcknowledgements request parameter." It is how a Server reports
+ * Bad_SequenceNumberUnknown or Bad_SubscriptionIdInvalid for an individual acknowledgement while
+ * the Publish call itself succeeds.
+ *
+ *
{@code PublishingManager} never reads it, so an acknowledgement the Server refused is
+ * indistinguishable from one it accepted. §5.14.5.2 also says of availableSequenceNumbers that
+ * "this information is for diagnostic purpose and Clients should log differences to the expected
+ * sequence numbers" — the same expectation applies to a refused acknowledgement, and today
+ * nothing at all is emitted.
+ */
+ @Nested
+ class AcknowledgementResults {
+
+ /**
+ * There is no listener, status, or other API surface on which a refused acknowledgement could
+ * be observed, so this asserts on what the client logs. It therefore requires the diagnostic to
+ * be logged at a level enabled by this module's {@code simplelogger.properties} (INFO or
+ * above); WARN is the appropriate level for a Server refusing a client's acknowledgement.
+ */
+ @Test
+ void acknowledgementRefusedByTheServerIsReported() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.sendDataChange(1, uint(1));
+
+ assertTrue(fixture.awaitDelivered(1), "NotificationMessage 1 was never delivered");
+ assertTrue(
+ fixture.awaitAcknowledged(1, AWAIT_TIMEOUT_MILLIS),
+ "the client never acknowledged sequence 1, so there is no acknowledgement for the"
+ + " Server to refuse");
+
+ try (var stderr = new StderrCapture()) {
+ // Control for the capture itself: unless a WARN emitted through this JVM's SLF4J binding
+ // is visible here, the absence asserted below would prove nothing at all.
+ LoggerFactory.getLogger(PublishAcknowledgementTest.class).warn(CAPTURE_PROBE);
+
+ assertTrue(
+ awaitTrue(() -> stderr.text().contains(CAPTURE_PROBE), RECOVERY_WINDOW_MILLIS),
+ "control: WARN records are not reaching the captured System.err, so this test cannot"
+ + " observe anything the client does or does not log");
+
+ fixture.refuseAcknowledgements(StatusCodes.Bad_SequenceNumberUnknown);
+
+ assertTrue(
+ fixture.awaitDelivered(2),
+ "the PublishResponse refusing the acknowledgement was never processed");
+
+ assertTrue(
+ awaitTrue(
+ () -> stderr.text().contains("Bad_SequenceNumberUnknown"),
+ RECOVERY_WINDOW_MILLIS),
+ "the Server refused the acknowledgement for sequence 1 with"
+ + " Bad_SequenceNumberUnknown in the PublishResponse results array, and the"
+ + " client reported nothing: the results array is never read, so no"
+ + " acknowledgement failure is detectable by an application or an operator");
+ }
+ }
+ }
+ }
+
+ // region fixture
+
+ /**
+ * A running Server whose Publish and Republish responses are scripted, a connected client, and a
+ * Subscription with one client-side MonitoredItem whose values are recorded as they are
+ * delivered.
+ *
+ *
Construction stops before any NotificationMessage has been sent, so a test can arrange the
+ * fate of the first acknowledgement before the notification that produces it.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ /** Every value handed to {@code onDataReceived}, in delivery order. */
+ private final List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final LossyPublishServiceSet scriptable;
+ private final UInteger subscriptionId;
+ private final UInteger clientHandle;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new LossyPublishServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ cfg ->
+ cfg
+ // Long request timeout so parked Publish requests do not time out.
+ .setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a test
+ // are the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS)));
+ client.connect();
+
+ var subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription subscription,
+ List items,
+ List values) {
+
+ values.forEach(value -> deliveredValues.add((Integer) value.getValue().getValue()));
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // Client-side only: addMonitoredItem() assigns the ClientHandle the notification fan-out
+ // looks values up by, which is all a scripted notification needs.
+ var item = OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+ clientHandle = item.getClientHandle().orElseThrow();
+
+ assertTrue(
+ awaitTrue(
+ () -> scriptable.getParkedRequestCount() >= PIPELINE_DEPTH, AWAIT_TIMEOUT_MILLIS),
+ "the client did not fill its Publish pipeline");
+ }
+
+ /**
+ * Answer a parked Publish request with a data-change NotificationMessage carrying {@code
+ * sequenceNumber} as its sequence number and as the value of its MonitoredItem.
+ */
+ void sendDataChange(long sequenceNumber, UInteger... available) {
+ scriptable.enqueueDataChange(
+ subscriptionId, sequenceNumber, notifications((int) sequenceNumber), available);
+ }
+
+ /**
+ * Arrange for the next PublishRequest carrying an acknowledgement of {@code sequenceNumber} to
+ * fail before the Server records or answers it.
+ */
+ void loseNextPublishRequestCarryingAcknowledgement(long sequenceNumber) {
+ scriptable.loseNextRequestCarrying(subscriptionId, uint(sequenceNumber));
+ }
+
+ boolean awaitAcknowledgementLost() throws Exception {
+ return scriptable.awaitRequestLost(AWAIT_TIMEOUT_MILLIS);
+ }
+
+ /**
+ * Answer every PublishRequest still parked, refusing each acknowledgement it carries with
+ * {@code statusCode} in the positionally matching entry of the response's results array.
+ *
+ *
The request carrying an acknowledgement is answered with a data-change NotificationMessage
+ * so that its delivery is an observable barrier proving the response was processed; the others
+ * get a keep-alive that leaves the sequence accounting where it was.
+ */
+ void refuseAcknowledgements(long statusCode) {
+ for (int i = 0; i < PIPELINE_DEPTH; i++) {
+ scriptable.enqueue(request -> refusingResponse(request, statusCode));
+ }
+ }
+
+ private CompletableFuture refusingResponse(
+ PublishRequest request, long statusCode) {
+
+ SubscriptionAcknowledgement[] acks = request.getSubscriptionAcknowledgements();
+ int ackCount = acks != null ? acks.length : 0;
+
+ var results = new StatusCode[ackCount];
+ Arrays.fill(results, StatusCode.of(statusCode));
+
+ // Part 4 §5.14.1.1: a keep-alive carries "the sequence number of the next NotificationMessage
+ // that is to be sent", so a keep-alive numbered 2 accounts for nothing beyond sequence 1.
+ ExtensionObject[] notificationData = ackCount > 0 ? encodedNotifications(2) : null;
+ UInteger[] available = ackCount > 0 ? new UInteger[] {uint(2)} : new UInteger[0];
+
+ return CompletableFuture.completedFuture(
+ scriptable.buildPublishResponse(
+ request, subscriptionId, 2, notificationData, available, false, results));
+ }
+
+ boolean awaitDelivered(int value) throws Exception {
+ return awaitTrue(() -> deliveredValues.contains(value), AWAIT_TIMEOUT_MILLIS);
+ }
+
+ boolean awaitAcknowledged(long sequenceNumber, long timeoutMillis) throws Exception {
+ return awaitTrue(
+ () ->
+ scriptable.getReceivedAcknowledgements().stream()
+ .anyMatch(
+ ack ->
+ subscriptionId.equals(ack.getSubscriptionId())
+ && ack.getSequenceNumber().longValue() == sequenceNumber),
+ timeoutMillis);
+ }
+
+ /** A DataChangeNotification carrying {@code value} for this Fixture's MonitoredItem. */
+ private List notifications(int value) {
+ return List.of(
+ new MonitoredItemNotification(clientHandle, new DataValue(Variant.ofInt32(value))));
+ }
+
+ private ExtensionObject[] encodedNotifications(int value) {
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(
+ notifications(value).toArray(MonitoredItemNotification[]::new), null))
+ };
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ /**
+ * A {@link ScriptableSubscriptionServiceSet} that can drop one PublishRequest on the floor.
+ *
+ *
The armed request is failed before {@code super.onPublish} records its acknowledgements, so
+ * from the Server's point of view the request — and everything it was carrying — never arrived.
+ * That is what makes {@code getReceivedAcknowledgements()} a sound record of what the client
+ * actually managed to acknowledge.
+ */
+ private static final class LossyPublishServiceSet extends ScriptableSubscriptionServiceSet {
+
+ private final AtomicReference target = new AtomicReference<>();
+ private final CountDownLatch lost = new CountDownLatch(1);
+
+ LossyPublishServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ void loseNextRequestCarrying(UInteger subscriptionId, UInteger sequenceNumber) {
+ target.set(new SubscriptionAcknowledgement(subscriptionId, sequenceNumber));
+ }
+
+ boolean awaitRequestLost(long timeoutMillis) throws InterruptedException {
+ return lost.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public CompletableFuture onPublish(
+ ServiceRequestContext context, PublishRequest request) {
+
+ SubscriptionAcknowledgement armed = target.get();
+
+ if (armed != null && carries(request, armed) && target.compareAndSet(armed, null)) {
+ lost.countDown();
+
+ return CompletableFuture.failedFuture(new UaException(StatusCodes.Bad_Timeout));
+ }
+
+ return super.onPublish(context, request);
+ }
+
+ private static boolean carries(PublishRequest request, SubscriptionAcknowledgement armed) {
+ SubscriptionAcknowledgement[] acks = request.getSubscriptionAcknowledgements();
+
+ if (acks == null) {
+ return false;
+ }
+
+ return Arrays.stream(acks)
+ .anyMatch(
+ ack ->
+ armed.getSubscriptionId().equals(ack.getSubscriptionId())
+ && armed.getSequenceNumber().equals(ack.getSequenceNumber()));
+ }
+ }
+
+ /**
+ * Captures everything written to {@link System#err} while installed, passing it through to the
+ * original stream so the output a failing test needs is not swallowed.
+ *
+ *
slf4j-simple, the SLF4J binding on this module's test classpath, is configured with its
+ * default uncached {@code System.err} target and resolves {@code System.err} on every write, so
+ * installing this stream is enough to observe log records emitted afterwards.
+ */
+ private static final class StderrCapture implements AutoCloseable {
+
+ private final ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ private final PrintStream original = System.err;
+ private final PrintStream installed;
+
+ StderrCapture() {
+ installed = new PrintStream(new Tee(), true, StandardCharsets.UTF_8);
+
+ System.setErr(installed);
+ }
+
+ String text() {
+ synchronized (captured) {
+ return captured.toString(StandardCharsets.UTF_8);
+ }
+ }
+
+ @Override
+ public void close() {
+ System.setErr(original);
+ installed.flush();
+ }
+
+ private final class Tee extends OutputStream {
+
+ @Override
+ public void write(int b) {
+ synchronized (captured) {
+ captured.write(b);
+ }
+ original.write(b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ synchronized (captured) {
+ captured.write(b, off, len);
+ }
+ original.write(b, off, len);
+ }
+ }
+ }
+
+ /** Polls {@code condition} until it holds or {@code timeoutMillis} elapses. */
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis)
+ throws Exception {
+
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishCeilingRecoveryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishCeilingRecoveryTest.java
new file mode 100644
index 0000000000..ae4f2f8308
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishCeilingRecoveryTest.java
@@ -0,0 +1,823 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Recovery of the Publish pipeline ceiling {@code PublishingManager} learns from
+ * Bad_TooManyPublishRequests.
+ *
+ *
Part 4 §5.14.5.1: once a Server has answered that error a Client "shall not issue another
+ * Publish request before one of its outstanding Publish requests is returned", and the client also
+ * remembers how many the Server was in fact holding, so that the next returning request does not
+ * restore the very outstanding count that drew the fault. The clamp is right. Keeping it forever is
+ * not: a momentary server-side condition — a queue briefly full, a cap raised again a second later
+ * — costs a long-lived client a permanently shallower pipeline, because the only two events that
+ * clear the clamp today are a Subscription being added and a Session being activated, and neither
+ * need ever happen again for the rest of that client's life.
+ *
+ *
What is required instead, and what these tests pin:
+ *
+ *
+ *
sustained successful Publish activity lets the ceiling recover to the natural target,
+ * {@code min(subscriptionCount + 1, maxPendingPublishRequests)};
+ *
recovery is incremental — one request at a time, never straight back to the target;
+ *
a probe costs a cooldown counted in successful Publish round trips, not in elapsed time, so
+ * that it is the Server's own answers which pay for the next attempt;
+ *
a probe that draws Bad_TooManyPublishRequests again re-clamps the ceiling and
+ * lengthens the next cooldown geometrically, so that a Server which always refuses sees a
+ * number of probes growing only logarithmically in the number of responses it delivers;
+ *
at most one probe is in flight at a time, and the natural target is never exceeded.
+ *
+ *
+ *
The bounds asserted below deliberately leave the implementation room to choose its constants,
+ * but they do constrain them: the first probe must come no earlier than {@link
+ * #QUIET_PREFIX_DELIVERIES} + 1 successful round trips (so that the immediate one-replacement-per-
+ * return behaviour pinned by {@link PublishPipelineRefillTest}, whose window is a single returning
+ * request, is untouched), no later than a few tens of them, and against a Server that always
+ * refuses the schedule must fit within {@link #MAX_PROBES} probes over {@link
+ * #PROBE_WINDOW_DELIVERIES} delivered NotificationMessages, with the interval between probes never
+ * shrinking. A base cooldown of 8 successful round trips, doubling on each refused probe and capped
+ * in the hundreds, satisfies all of them with room to spare.
+ *
+ *
Every test drives a Server whose Publish requests are parked by {@link
+ * ScriptableSubscriptionServiceSet} — nothing completes one unless the test says so — and answers
+ * them one at a time, so the number of Publish requests the Server is left holding after each
+ * answer is the client's current ceiling, observed rather than inferred, and every wait is
+ * on a Publish request arriving rather than on time passing.
+ *
+ * @see PublishPipelineRefillTest for the immediate response to the fault, which this must not
+ * change.
+ */
+public class PublishCeilingRecoveryTest {
+
+ /**
+ * The natural target of the one-Subscription fixtures: {@code min(subscriptionCount + 1,
+ * maxPendingPublishRequests)} = min(1 + 1, 2). maxPendingPublishRequests is configured to the
+ * same value rather than left at its default so that the target is stated by the test.
+ */
+ private static final int SINGLE_SUBSCRIPTION_TARGET = 2;
+
+ /** Three Subscriptions, and a natural target of min(3 + 1, 4) = 4. */
+ private static final int DEEP_PIPELINE_SUBSCRIPTIONS = 3;
+
+ private static final int DEEP_PIPELINE_TARGET = 4;
+
+ /**
+ * The ceiling one rejection leaves behind when the whole one-Subscription pipeline was
+ * outstanding: {@code max(1, outstanding)} with the refused request accounted for, i.e. max(1, 2
+ * - 1).
+ */
+ private static final int SINGLE_SUBSCRIPTION_CLAMP = 1;
+
+ /**
+ * The ceiling two rejections leave behind when the whole three-Subscription pipeline was
+ * outstanding: the first is accounted for with three still outstanding, the second with two, and
+ * the ceiling is the smallest of them.
+ */
+ private static final int DEEP_PIPELINE_CLAMP = 2;
+
+ /**
+ * The number of Publish requests the capped Server will hold for a Session before answering
+ * Bad_TooManyPublishRequests. One is below every natural target used here, so the Server refuses
+ * whenever the client tries to deepen its pipeline at all.
+ */
+ private static final int SERVER_PUBLISH_CAP = 1;
+
+ /**
+ * The number of successful Publish round trips a recovery run is given to reach the natural
+ * target. Three raises at a doubling cooldown starting from 8 cost 8 + 16 + 32 = 56, so this
+ * leaves the implementation an order of magnitude of slack while still failing rather than
+ * hanging if the ceiling never moves.
+ */
+ private static final int MAX_RECOVERY_DELIVERIES = 256;
+
+ /**
+ * Successful Publish round trips the anti-hammering run delivers. Long enough that a cooldown
+ * which doubles from a base of a few produces several probes, so that the intervals between them
+ * can be compared and not merely counted.
+ */
+ private static final int PROBE_WINDOW_DELIVERIES = 256;
+
+ /**
+ * How long, after a Publish request has arrived to replace the one just answered, to keep waiting
+ * for another before answering the next.
+ *
+ *
Not a synchronization mechanism: the round trip itself is waited for, and this is only the
+ * window in which a second request — the probe that accompanies a replacement — is allowed
+ * to land. The client sends both from one loop, so the gap between them is tens of microseconds;
+ * this is three orders of magnitude more than that, and is what makes a probe meet a Server whose
+ * queue is still occupied rather than one that has just been emptied.
+ */
+ private static final long BURST_SETTLE_MILLIS = 5;
+
+ /**
+ * The most refused probes a Server that always answers Bad_TooManyPublishRequests may see over
+ * {@link #PROBE_WINDOW_DELIVERIES} delivered NotificationMessages.
+ *
+ *
This is the anti-hammering guarantee. Without a cooldown the client probes once per
+ * returning request, which is one refusal per delivered NotificationMessage — 256 of them. With a
+ * cooldown that does not grow it probes 256/cooldown times, still linear in the traffic. With a
+ * cooldown that doubles on each refusal it probes log2(256/base) times: at most 6 for any base of
+ * 4 or more and any cooldown cap of 64 or more. Ten leaves room for an implementation that counts
+ * its successes a little differently without leaving room for one that hammers.
+ */
+ private static final int MAX_PROBES = 10;
+
+ /**
+ * The fewest refused probes that same run must see.
+ *
+ *
One would be satisfied by a client that tried once and gave up, which is the clamp again
+ * under another name; the guarantee is that it keeps asking, only ever more rarely. Two also
+ * keeps the test honest about the Server: every probe against this Server is refused, so a run
+ * that recorded one refusal would mean a probe had slipped past the cap and the ceiling had been
+ * allowed to stand.
+ */
+ private static final int MIN_PROBES = 2;
+
+ /**
+ * Successful Publish round trips over which, immediately after the clamp, one returning request
+ * must still buy exactly one replacement and nothing more.
+ *
+ *
{@link PublishPipelineRefillTest} pins that for the first of them; this pins that the
+ * cooldown before the first probe is longer than a handful, so that the two cannot be confused
+ * for one another and a probe cannot appear inside that test's window.
+ */
+ private static final int QUIET_PREFIX_DELIVERIES = 4;
+
+ /**
+ * Keep-alives all carry sequence number 1: Part 4 §5.14.1.1 makes a keep-alive's sequence number
+ * "the sequence number of the next NotificationMessage that is to be sent", so repeating it says
+ * the Server has still sent nothing, leaves the client's sequence accounting exactly where it
+ * started, and produces neither an acknowledgement nor a Republish. Every one of them is
+ * nevertheless a successful Publish round trip: a request answered, a NotificationMessage
+ * delivered, a permit released and a replacement request sent.
+ */
+ private static final long KEEP_ALIVE_SEQUENCE_NUMBER = 1L;
+
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /** How long to watch for a Publish request that must not be sent. */
+ private static final long QUIET_WINDOW_MILLIS = 1_000;
+
+ /**
+ * Long enough that nothing times out on its own: the PublishRequests these tests park at the
+ * Server stay parked until the test decides how they end.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ /**
+ * A transient rejection — the Server refuses while its queue is briefly full, then stops refusing
+ * — must not cost the client its pipeline depth for good.
+ */
+ @Nested
+ class RecoveryAfterATransientRejection {
+
+ /**
+ * Control: the same three-Subscription fixture with a Server that never refuses. It proves the
+ * fixture can observe a four-deep pipeline at all, so {@link
+ * #theCeilingClimbsBackOneRequestAtATimeRatherThanInOneJump} fails because the ceiling did not
+ * recover and not because four outstanding requests are unobservable here.
+ */
+ @Test
+ void theWholePipelineIsOutstandingWhenTheServerNeverRefuses() throws Exception {
+ try (Fixture fixture = Fixture.withThreeSubscriptions()) {
+ assertTrue(
+ fixture.scriptable.awaitParked(DEEP_PIPELINE_TARGET, AWAIT_TIMEOUT_MILLIS),
+ "the Publish pipeline never filled to its natural target");
+ }
+ }
+
+ /**
+ * The property the clamp costs today: a client that took one Bad_TooManyPublishRequests keeps a
+ * pipeline one request shallower than the Server would now accept, for as long as it lives.
+ */
+ @Test
+ void theCeilingRecoversToTheNaturalTargetOnceTheServerStopsRefusing() throws Exception {
+ try (Fixture fixture = Fixture.withOneSubscription(SERVER_PUBLISH_CAP)) {
+ // Filling a two-deep pipeline against a Server that holds one costs exactly one rejection:
+ // the first request is held, the second refused, and the refused one is not replaced.
+ fixture.assertPipelineSettlesAt(SINGLE_SUBSCRIPTION_CLAMP);
+ assertEquals(
+ 1,
+ fixture.scriptable.getRejectionCount(),
+ "the clamp under test is the one a single Bad_TooManyPublishRequests installs");
+
+ // The transient condition is over; from here the Server accepts everything the client cares
+ // to send.
+ fixture.scriptable.setCap(CappingSubscriptionServiceSet.UNCAPPED);
+
+ List steps =
+ fixture.deliverUntilPipelineReaches(
+ SINGLE_SUBSCRIPTION_CLAMP, SINGLE_SUBSCRIPTION_TARGET, MAX_RECOVERY_DELIVERIES);
+
+ assertEquals(
+ SINGLE_SUBSCRIPTION_TARGET,
+ steps.isEmpty() ? SINGLE_SUBSCRIPTION_CLAMP : steps.get(steps.size() - 1),
+ "the ceiling learned from one Bad_TooManyPublishRequests never recovered: after "
+ + MAX_RECOVERY_DELIVERIES
+ + " successful Publish round trips against a Server that refuses nothing, the"
+ + " client still keeps a shallower pipeline than min(subscriptionCount + 1,"
+ + " maxPendingPublishRequests). Nothing in the traffic itself can lift the clamp,"
+ + " so it lasts until a Subscription is added or a Session is activated — for a"
+ + " long-lived client, forever");
+ }
+ }
+
+ /**
+ * Recovery must be a probe, not a jump. Restoring the whole deficit at once would send the
+ * Server exactly the burst that drew the fault, which is what the clamp exists to prevent;
+ * raising the ceiling by one asks the question with a single request.
+ *
+ *
Two rejections against a four-deep pipeline leave a ceiling of two, so an incremental
+ * recovery is observable as the pipeline going three deep before it goes four deep.
+ */
+ @Test
+ void theCeilingClimbsBackOneRequestAtATimeRatherThanInOneJump() throws Exception {
+ try (Fixture fixture = Fixture.withThreeSubscriptions()) {
+ assertTrue(
+ fixture.scriptable.awaitParked(DEEP_PIPELINE_TARGET, AWAIT_TIMEOUT_MILLIS),
+ "the Publish pipeline never filled to its natural target");
+
+ // Two of the four outstanding requests are refused. Their failures are accounted for
+ // independently — each decrements the outstanding count once — so the ceiling ends up at
+ // the smaller of the two counts they saw, whichever order they are handled in.
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_TooManyPublishRequests);
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_TooManyPublishRequests);
+
+ fixture.assertPipelineSettlesAt(DEEP_PIPELINE_CLAMP);
+
+ List steps =
+ fixture.deliverUntilPipelineReaches(
+ DEEP_PIPELINE_CLAMP, DEEP_PIPELINE_TARGET, MAX_RECOVERY_DELIVERIES);
+
+ assertEquals(
+ List.of(DEEP_PIPELINE_CLAMP + 1, DEEP_PIPELINE_TARGET),
+ steps,
+ "the ceiling did not climb back one request at a time: the numbers of Publish requests"
+ + " the Server was left holding, in the order they were first observed, were "
+ + steps
+ + ", starting from a clamp of "
+ + DEEP_PIPELINE_CLAMP
+ + " and aiming at a natural target of "
+ + DEEP_PIPELINE_TARGET);
+ }
+ }
+ }
+
+ /**
+ * The other half of leniency, and the one that decides whether it is safe: a Server that keeps
+ * refusing must not be asked over and over.
+ */
+ @Nested
+ class AntiHammering {
+
+ /**
+ * A Server whose cap really is below what the client wants answers every probe with
+ * Bad_TooManyPublishRequests. The client must keep asking — otherwise a transient condition is
+ * indistinguishable from a permanent one and the clamp is permanent again — but the asking must
+ * cost the Server almost nothing: with a cooldown that doubles on each refusal, the number of
+ * probes grows logarithmically in the number of responses the Server delivers, so doubling the
+ * traffic adds a probe or two rather than doubling the probes.
+ */
+ @Test
+ void aServerThatAlwaysRefusesSeesOnlyABoundedAndSlowlyGrowingNumberOfProbes() throws Exception {
+ try (Fixture fixture = Fixture.withOneSubscription(SERVER_PUBLISH_CAP)) {
+ fixture.assertPipelineSettlesAt(SINGLE_SUBSCRIPTION_CLAMP);
+
+ assertEquals(
+ 1,
+ fixture.scriptable.getRejectionCount(),
+ "filling the pipeline against the capped Server cost one refusal");
+
+ List refusedAt = fixture.deliverAndRecordRefusals(PROBE_WINDOW_DELIVERIES);
+
+ assertTrue(
+ refusedAt.size() >= MIN_PROBES,
+ "the client stopped probing the ceiling: over "
+ + PROBE_WINDOW_DELIVERIES
+ + " successful Publish round trips the Server was asked only "
+ + refusedAt.size()
+ + " time(s) whether it would now queue another PublishRequest, so a condition that"
+ + " had long since passed would go on costing the client pipeline depth for as long"
+ + " as it lived. The round trips the refusals were observed at were "
+ + refusedAt);
+
+ assertTrue(
+ refusedAt.size() <= MAX_PROBES,
+ "the client hammered the Server: "
+ + refusedAt.size()
+ + " refused probes over "
+ + PROBE_WINDOW_DELIVERIES
+ + " delivered NotificationMessages, more than the "
+ + MAX_PROBES
+ + " a cooldown that grows geometrically on each refusal allows. A Server that"
+ + " always refuses must see a number of probes that grows only logarithmically in"
+ + " the number of responses it delivers. The round trips they were observed at were"
+ + " "
+ + refusedAt);
+
+ // The intervals between probes, the first measured from the clamp. Geometric backoff makes
+ // them grow, and the assertion is that the window ends with a longer wait than it began
+ // with. Strict step-by-step monotonicity is deliberately not asserted: a refusal and the
+ // delivery it is attributed to are two independently timed events, so which round trip a
+ // refusal is recorded against can jitter by one either way, and an inversion between
+ // adjacent intervals says nothing about the cooldown. The bound on the probe count above is
+ // what pins the guarantee; this pins its direction.
+ List intervals = intervals(refusedAt);
+
+ if (intervals.size() >= 2) {
+ assertTrue(
+ intervals.get(intervals.size() - 1) > intervals.get(0),
+ "the cooldown did not lengthen across the window: the intervals between probes, in"
+ + " successful Publish round trips, were "
+ + intervals
+ + ". Each refusal must make the next wait longer, or a Server that always refuses"
+ + " is asked at a constant rate forever");
+ }
+ }
+ }
+ }
+
+ /**
+ * The immediate response to the fault, which recovery must leave exactly as it was.
+ *
+ *
Part 4 §5.14.5.1 buys one replacement per returning request, and {@link
+ * PublishPipelineRefillTest} pins that for the first request to return after the fault. A probe
+ * is a later and separate event; a cooldown short enough to fire inside that window would turn
+ * this change into a regression of that one.
+ */
+ @Nested
+ class ImmediateResponseToTheFault {
+
+ /**
+ * Passes both before and after recovery is implemented — deliberately. It is the guard that
+ * keeps the cooldown clear of the window {@link PublishPipelineRefillTest} measures.
+ */
+ @Test
+ void theFirstFewReturningRequestsStillBuyExactlyOneReplacementEach() throws Exception {
+ try (Fixture fixture = Fixture.withOneSubscription(SERVER_PUBLISH_CAP)) {
+ fixture.assertPipelineSettlesAt(SINGLE_SUBSCRIPTION_CLAMP);
+
+ // Nothing the Server does from here can refuse a request, so an extra request going out is
+ // the client's own decision and is visible as one arriving at the Server.
+ fixture.scriptable.setCap(CappingSubscriptionServiceSet.UNCAPPED);
+
+ int arrivalsBefore = fixture.scriptable.getArrivalCount();
+
+ int highWater = fixture.deliverKeepAlives(QUIET_PREFIX_DELIVERIES);
+
+ // Let anything already in flight arrive before the count is compared, so that an extra
+ // request cannot be missed by having been sent a moment too late.
+ assertFalse(
+ fixture.scriptable.awaitArrivals(
+ arrivalsBefore + QUIET_PREFIX_DELIVERIES + 1, QUIET_WINDOW_MILLIS),
+ "more Publish requests were sent than the number of requests that returned");
+
+ assertEquals(
+ QUIET_PREFIX_DELIVERIES,
+ fixture.scriptable.getArrivalCount() - arrivalsBefore,
+ "one returning request bought more than one replacement within the first "
+ + QUIET_PREFIX_DELIVERIES
+ + " successful Publish round trips after the clamp, which is the window Part 4"
+ + " §5.14.5.1's one-replacement-per-return rule is measured in");
+
+ assertEquals(
+ SINGLE_SUBSCRIPTION_CLAMP,
+ highWater,
+ "the clamp was lifted within the first "
+ + QUIET_PREFIX_DELIVERIES
+ + " successful Publish round trips after the fault; the cooldown before a probe"
+ + " must be longer than that");
+ }
+ }
+ }
+
+ // region helpers
+
+ /**
+ * @param points the round trip counts at which something was observed, in order.
+ * @return the intervals between them, the first measured from the start of the run.
+ */
+ private static List intervals(List points) {
+ var intervals = new ArrayList(points.size());
+ int previous = 0;
+
+ for (int point : points) {
+ intervals.add(point - previous);
+ previous = point;
+ }
+
+ return intervals;
+ }
+
+ /**
+ * A {@link ScriptableSubscriptionServiceSet} that also models a Server which will not queue more
+ * than a fixed number of PublishRequests for a Session: one that would take its queue past the
+ * cap is answered Bad_TooManyPublishRequests, which is what Part 4 §5.14.5.1 has a Server do when
+ * a Client has more requests queued than it supports. The cap is settable, so a test can model a
+ * condition that passes.
+ *
+ *
The queue is the set of requests the script is holding, which is every request that has
+ * arrived and not been answered — this harness answers nothing on its own. A Server queues a
+ * PublishRequest from the moment it arrives until it has something to send in answer, so a test
+ * that answers one request at a time and waits for the client's reply to land before answering
+ * the next holds the queue exactly as a Server with data to send at a steady rate would.
+ */
+ private static final class CappingSubscriptionServiceSet
+ extends ScriptableSubscriptionServiceSet {
+
+ /** A Server that refuses nothing. */
+ static final int UNCAPPED = Integer.MAX_VALUE;
+
+ /**
+ * Guards the counters below and, held across the decision, makes "measure the queue, then join
+ * it" atomic: two requests arriving together must not both find room in a queue with one place
+ * left.
+ */
+ private final ReentrantLock lock = new ReentrantLock();
+
+ /** Signalled whenever a Publish request arrives, refused or not. */
+ private final Condition arrived = lock.newCondition();
+
+ /** Publish requests received, refused ones included. Guarded by {@link #lock}. */
+ private int arrivals = 0;
+
+ /** Publish requests refused for exceeding the cap. Guarded by {@link #lock}. */
+ private int rejections = 0;
+
+ private volatile int cap;
+
+ CappingSubscriptionServiceSet(OpcUaServer server, int cap) {
+ super(server);
+
+ this.cap = cap;
+ }
+
+ void setCap(int cap) {
+ this.cap = cap;
+ }
+
+ int getArrivalCount() {
+ lock.lock();
+ try {
+ return arrivals;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ int getRejectionCount() {
+ lock.lock();
+ try {
+ return rejections;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** Wait until at least {@code count} Publish requests have arrived, refused ones included. */
+ boolean awaitArrivals(int count, long timeoutMillis) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ lock.lock();
+ try {
+ while (arrivals < count) {
+ long remaining = deadline - System.nanoTime();
+ if (remaining <= 0) {
+ return false;
+ }
+ arrived.awaitNanos(remaining);
+ }
+
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Wait until at least {@code count} Publish requests are parked, i.e. until the client has that
+ * many outstanding for the script to answer.
+ */
+ boolean awaitParked(int count, long timeoutMillis) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ lock.lock();
+ try {
+ while (getParkedRequestCount() < count) {
+ long remaining = deadline - System.nanoTime();
+ if (remaining <= 0) {
+ return false;
+ }
+ arrived.awaitNanos(remaining);
+ }
+
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public CompletableFuture onPublish(
+ ServiceRequestContext context, PublishRequest request) {
+
+ lock.lock();
+ try {
+ arrivals++;
+ arrived.signalAll();
+
+ if (getParkedRequestCount() >= cap) {
+ rejections++;
+
+ return CompletableFuture.failedFuture(
+ new UaException(StatusCodes.Bad_TooManyPublishRequests));
+ }
+
+ return super.onPublish(context, request);
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
+ /**
+ * A running Server whose Publish requests are all parked by {@link CappingSubscriptionServiceSet}
+ * — nothing completes one unless the test says so, and nothing is refused unless its cap says so
+ * — plus a connected client with its Subscriptions already created.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final CappingSubscriptionServiceSet scriptable;
+
+ private final List subscriptions = new ArrayList<>();
+
+ /** min(subscriptionCount + 1, maxPendingPublishRequests) for this fixture's client. */
+ private final int naturalTarget;
+
+ static Fixture withOneSubscription(int cap) throws Exception {
+ return new Fixture(1, SINGLE_SUBSCRIPTION_TARGET, cap);
+ }
+
+ /**
+ * A fixture whose four-deep pipeline fills against a Server that refuses nothing, so the tests
+ * that need a deep clamp can install one themselves, request by request.
+ */
+ static Fixture withThreeSubscriptions() throws Exception {
+ return new Fixture(
+ DEEP_PIPELINE_SUBSCRIPTIONS,
+ DEEP_PIPELINE_TARGET,
+ CappingSubscriptionServiceSet.UNCAPPED);
+ }
+
+ private Fixture(int subscriptionCount, int naturalTarget, int cap) throws Exception {
+ this.naturalTarget = naturalTarget;
+
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new CappingSubscriptionServiceSet(server, cap);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ cfg ->
+ cfg
+ // Long request timeout so parked Publish requests do not time out.
+ .setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a
+ // test are the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ // States the natural target in the test rather than inheriting the config
+ // default: min(subscriptionCount + 1, naturalTarget) == naturalTarget.
+ .setMaxPendingPublishRequests(uint(naturalTarget)));
+
+ client.connect();
+
+ for (int i = 0; i < subscriptionCount; i++) {
+ var subscription = new OpcUaSubscription(client);
+ subscription.create();
+
+ subscriptions.add(subscription);
+ }
+ }
+
+ /**
+ * The Subscription every keep-alive is addressed to. The others exist only to raise the natural
+ * target; a Subscription that receives nothing has nothing to say about the pipeline's depth,
+ * and its watchdog timer merely notifies a listener it has none of.
+ */
+ UInteger notifiedSubscriptionId() {
+ return subscriptions.get(0).getSubscriptionId().orElseThrow();
+ }
+
+ /**
+ * Assert the pipeline has settled at {@code expected} outstanding Publish requests: the traffic
+ * has stopped, and what the Server is left holding when it does is the ceiling the client is
+ * working to.
+ *
+ *
Quiescence is waited for rather than merely sampled, because the client's opening burst is
+ * one request per unit of its natural target and the tail of that burst is traffic which has
+ * not stopped yet rather than traffic which never will.
+ */
+ void assertPipelineSettlesAt(int expected) throws Exception {
+ assertTrue(
+ scriptable.awaitArrivals(naturalTarget, AWAIT_TIMEOUT_MILLIS),
+ "the client never sent the Publish requests its natural target calls for");
+
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MILLIS);
+
+ int arrivals;
+ do {
+ arrivals = scriptable.getArrivalCount();
+ } while (scriptable.awaitArrivals(arrivals + 1, QUIET_WINDOW_MILLIS)
+ && System.nanoTime() < deadline);
+
+ assertEquals(
+ arrivals,
+ scriptable.getArrivalCount(),
+ "the Publish pipeline never settled: the client kept sending requests without being"
+ + " answered. Recovery must be driven by successful Publish round trips, not by time"
+ + " passing");
+
+ assertEquals(
+ expected,
+ scriptable.getParkedRequestCount(),
+ "the Publish pipeline settled at a depth the test did not set up");
+ }
+
+ /**
+ * Answer one parked Publish request with a keep-alive and wait until the client has dealt with
+ * it, i.e. until every Publish request it sends in reply has arrived.
+ *
+ *
A request is answered only once one is parked, never by leaving a responder ready for a
+ * request that has not arrived yet: a responder waiting in the script would answer the next
+ * arrival immediately, and the Server's queue would never hold the two requests a probe
+ * consists of at the same time.
+ *
+ *
The same reason is why the reply is waited out rather than merely waited for. A client
+ * probing its ceiling sends two requests from one loop, microseconds apart; answering the first
+ * before the second arrives would empty the queue in between and let the probe through a cap it
+ * should have hit. {@link #BURST_SETTLE_MILLIS} is orders of magnitude more than that gap, and
+ * is spent only on the reply that never comes — the second request of a burst that was not a
+ * probe.
+ */
+ private void deliverOneKeepAlive() throws Exception {
+ assertTrue(
+ scriptable.awaitParked(1, AWAIT_TIMEOUT_MILLIS),
+ "the client has no Publish request outstanding: the pipeline has stalled");
+
+ int arrivals = scriptable.getArrivalCount();
+
+ scriptable.enqueueKeepAlive(notifiedSubscriptionId(), KEEP_ALIVE_SEQUENCE_NUMBER);
+
+ assertTrue(
+ scriptable.awaitArrivals(arrivals + 1, AWAIT_TIMEOUT_MILLIS),
+ "the answered Publish request was never replaced: the pipeline has stalled");
+
+ scriptable.awaitArrivals(arrivals + 2, BURST_SETTLE_MILLIS);
+ }
+
+ /**
+ * Deliver {@code count} keep-alives, one at a time.
+ *
+ * @return the greatest number of Publish requests the Server was left holding after any of
+ * them, i.e. the highest ceiling the client reached.
+ */
+ int deliverKeepAlives(int count) throws Exception {
+ int highWater = 0;
+
+ for (int i = 0; i < count; i++) {
+ deliverOneKeepAlive();
+
+ highWater = Math.max(highWater, sampleOutstanding());
+ }
+
+ return highWater;
+ }
+
+ /**
+ * Deliver {@code count} keep-alives, one at a time, watching for the Server refusing one.
+ *
+ * @return the number of successful Publish round trips that had been delivered when each
+ * refused request was observed, in order. Each entry is a probe: an extra PublishRequest
+ * the client sent to find out whether the Server would now queue one more.
+ */
+ List deliverAndRecordRefusals(int count) throws Exception {
+ var refusedAt = new ArrayList();
+ int rejections = scriptable.getRejectionCount();
+
+ for (int delivered = 1; delivered <= count; delivered++) {
+ deliverOneKeepAlive();
+
+ int total = scriptable.getRejectionCount();
+
+ for (int i = rejections; i < total; i++) {
+ refusedAt.add(delivered);
+ }
+
+ rejections = total;
+ }
+
+ return refusedAt;
+ }
+
+ /**
+ * Deliver keep-alives, one at a time, until the Server is left holding {@code target} Publish
+ * requests or {@code maxDeliveries} have been delivered.
+ *
+ * @return every increase in the number of Publish requests the Server was left holding, in the
+ * order they were first observed: the path the ceiling took from {@code clamp} upwards.
+ */
+ List deliverUntilPipelineReaches(int clamp, int target, int maxDeliveries)
+ throws Exception {
+
+ var steps = new ArrayList();
+ int outstanding = clamp;
+
+ for (int i = 0; i < maxDeliveries && outstanding < target; i++) {
+ deliverOneKeepAlive();
+
+ int sampled = sampleOutstanding();
+
+ if (sampled > outstanding) {
+ outstanding = sampled;
+ steps.add(sampled);
+ }
+ }
+
+ return steps;
+ }
+
+ /**
+ * @return the number of Publish requests the Server is holding, which between two answers is
+ * the number the client is keeping outstanding.
+ */
+ private int sampleOutstanding() {
+ int outstanding = scriptable.getParkedRequestCount();
+
+ assertTrue(
+ outstanding <= naturalTarget,
+ "the client kept "
+ + outstanding
+ + " Publish requests outstanding, more than the natural target of "
+ + naturalTarget);
+
+ return outstanding;
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.setCap(CappingSubscriptionServiceSet.UNCAPPED);
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishFirstActivationRecoveryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishFirstActivationRecoveryTest.java
new file mode 100644
index 0000000000..0281c80eed
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishFirstActivationRecoveryTest.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the Part 4 §6.7 Republish drain is allowed to do on the client's first Session
+ * activation.
+ *
+ *
The drain exists to collect the NotificationMessages a Server generated while a Session was
+ * unusable. On the first activation there is no such window: the Session and every Subscription
+ * registered against it were created moments earlier, on that Session. Running the drain anyway
+ * spends a Republish round trip per Subscription to be told Bad_MessageNotAvailable, and because
+ * the drain holds Publish traffic suspended until it ends, it also delays every Subscription's
+ * first PublishRequest by that much.
+ *
+ *
The window this guards is reachable rather than theoretical: {@code connect()} returns when
+ * the Session future completes, and that happens in a task submitted before the {@code
+ * onSessionActive} fan-out — so a Subscription created immediately afterwards can register itself
+ * before the callbacks run, and be included in a recovery snapshot that has nothing to recover.
+ *
+ *
Note on what these tests do and do not prove. They are a guard, not a reproduction: entering
+ * that window requires the calling thread to beat the fan-out task, which it loses on an idle
+ * machine, so this class passes against the code from before the drain was made conditional. What
+ * demonstrated the defect was the whole integration-tests module in one JVM fork on two CPUs, where
+ * the window opens often enough to produce a spurious Republish in {@code
+ * PublishSequenceRecoveryTest} and {@code PublishResponseOrderingTest}, a reset publish ceiling in
+ * {@code PublishCeilingRecoveryTest}, and — because a drain that is held holds the publish gate
+ * with it — a Publish that is never sent in {@code SubscriptionWatchdogRecoveryStarvationTest}.
+ * These assertions pin the intended behaviour so that a future change making recovery unconditional
+ * again fails here deterministically instead of as a flake somewhere else.
+ */
+public class PublishFirstActivationRecoveryTest {
+
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * How long to watch for a Republish that must not happen. Generous relative to the round trip it
+ * would take against a loopback Server.
+ */
+ private static final long QUIET_PERIOD_MILLIS = 2_000;
+
+ /**
+ * A Subscription created in the window between {@code connect()} returning and the activation
+ * callbacks running must not be dragged through a drain that cannot find anything.
+ */
+ @Test
+ void noRepublishIsRequestedOnTheFirstSessionActivation() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ // The client pipelines PublishRequests as soon as publishing is allowed, so their arrival is
+ // the signal that the gate opened and any recovery is over.
+ assertTrue(
+ fixture.awaitPublishRequests(2),
+ "no PublishRequest was sent, so the publish gate never opened: either recovery is still"
+ + " running or it never finished");
+
+ Thread.sleep(QUIET_PERIOD_MILLIS);
+
+ assertEquals(
+ List.of(),
+ fixture.republishes(),
+ "the Subscription was created on the Session that has just become Active for the first"
+ + " time, so the Server cannot be holding a NotificationMessage the client has not"
+ + " collected and the §6.7 drain must not ask for one");
+ }
+ }
+
+ /**
+ * The control that keeps the assertion above from being vacuous: once a Session has actually been
+ * lost, the drain is exactly what §6.7 requires and must run.
+ */
+ @Test
+ void republishIsRequestedAfterTheSessionIsReactivated() throws Exception {
+ try (var fixture = new Fixture()) {
+ var subscription = new OpcUaSubscription(fixture.client);
+ subscription.create();
+
+ assertTrue(fixture.awaitPublishRequests(2), "the publish gate never opened");
+ assertEquals(List.of(), fixture.republishes(), "premise: no drain on the first activation");
+
+ // Bad_SessionIdInvalid is classified as a Session error and turned into a reconnect. The
+ // Server-side Session is untouched, so re-activation succeeds and the Subscription survives.
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_SessionIdInvalid);
+
+ assertTrue(
+ fixture.sessionInactive.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the scripted Bad_SessionIdInvalid Publish fault did not take the Session out of Active");
+ assertTrue(
+ fixture.sessionReactivated.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the Session never became Active again");
+
+ assertTrue(
+ fixture.awaitRepublish(),
+ "the Session was lost and re-activated, so Part 4 §6.7 requires the client to Republish"
+ + " from the next expected sequence number before resuming Publish handling, and it"
+ + " asked for nothing");
+ }
+ }
+
+ private static class Fixture implements AutoCloseable {
+
+ final CountDownLatch sessionInactive = new CountDownLatch(1);
+ final CountDownLatch sessionReactivated = new CountDownLatch(1);
+
+ private final List republishes = new CopyOnWriteArrayList<>();
+ private final CountDownLatch republishRequested = new CountDownLatch(1);
+
+ private final OpcUaServer server;
+ final OpcUaClient client;
+ final ScriptableSubscriptionServiceSet scriptable;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ cfg ->
+ // Long enough that a parked PublishRequest does not time out, and no Session
+ // keep-alive traffic competes with the requests these tests script.
+ cfg.setRequestTimeout(uint(60_000)).setKeepAliveInterval(uint(60_000)));
+
+ scriptable.setRepublishResponder(
+ request -> {
+ republishes.add(request.getRetransmitSequenceNumber().longValue());
+ republishRequested.countDown();
+
+ // The termination condition of the §6.7 loop: the Server holds nothing to retransmit.
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ });
+
+ client.connect();
+
+ client.addSessionActivityListener(
+ new SessionActivityListener() {
+ @Override
+ public void onSessionInactive(UaSession session) {
+ sessionInactive.countDown();
+ }
+
+ @Override
+ public void onSessionActive(UaSession session) {
+ if (sessionInactive.getCount() == 0) {
+ sessionReactivated.countDown();
+ }
+ }
+ });
+ }
+
+ List republishes() {
+ return List.copyOf(republishes);
+ }
+
+ boolean awaitRepublish() throws InterruptedException {
+ return republishRequested.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ }
+
+ boolean awaitPublishRequests(int count) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MILLIS);
+
+ while (System.nanoTime() < deadline) {
+ if (scriptable.getPublishRequestCount() >= count) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return scriptable.getPublishRequestCount() >= count;
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPermitLeakTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPermitLeakTest.java
new file mode 100644
index 0000000000..15af8ca3b0
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPermitLeakTest.java
@@ -0,0 +1,378 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType;
+import org.eclipse.milo.opcua.stack.core.types.UaResponseMessageType;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DiagnosticInfo;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
+import org.eclipse.milo.opcua.stack.transport.client.tcp.OpcTcpClientTransport;
+import org.eclipse.milo.opcua.stack.transport.client.tcp.OpcTcpClientTransportConfig;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The pending-publish permits {@code PublishingManager} uses to keep a bounded number of
+ * PublishRequests in flight, and the answers that must not cost it one.
+ *
+ *
A permit is taken before a PublishRequest is sent and released when its answer has been dealt
+ * with; the release is also what sends the replacement request. A permit that is never released is
+ * therefore not merely a leak: the pipeline it belonged to permanently shrinks by one, and a client
+ * that loses its last permit stops asking the Server for notifications altogether, silently and for
+ * good — no error is reported, and the Subscription stays alive on the Server.
+ *
+ *
Every test here configures maxPendingPublishRequests = 1, so that the client's single
+ * Subscription targets min(1 + 1, 1) = 1 outstanding request. A leaked permit is then the whole
+ * pipeline, and the symptom is exactly the one the application would see: no further
+ * PublishRequest, ever.
+ *
+ * @see PublishPipelineRefillTest for the refill rules the permits feed.
+ */
+public class PublishPermitLeakTest {
+
+ /** min(subscriptionCount + 1, maxPendingPublishRequests) = min(2, 1) with one Subscription. */
+ private static final int PIPELINE_TARGET = 1;
+
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * Long enough that nothing times out on its own: the PublishRequests this test parks at the
+ * Server must stay parked until the test decides how they end.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ /**
+ * Part 4 §5.14.5.2 gives a PublishRequest a PublishResponse, and the client dispatches on the
+ * type it gets: anything else goes down the failure path. That path had no failure to inspect —
+ * the request completed successfully, just not with a PublishResponse — so extracting a
+ * StatusCode from the (null) exception threw, and the permit the request held was never released.
+ *
+ *
The answer is fabricated by the transport rather than by the Server, because the type of a
+ * Publish answer is fixed by {@code SubscriptionServiceSet.onPublish}: a Server cannot express
+ * one through the service set, and a ServiceFault — the one wrong-typed message a Server can send
+ * — is turned into an exception by the client's channel before the SDK ever sees it. What is
+ * under test is the client's accounting of the answer, in which the Server's copy of the request
+ * plays no part, so the intercepted request is not sent at all.
+ */
+ @Nested
+ class AnswerThatIsNotAPublishResponse {
+
+ /**
+ * Control: the same interception answering with a service failure instead, i.e. the same path
+ * with an exception to extract a StatusCode from. It proves the fixture observes the pipeline
+ * being refilled after an intercepted answer at all, so the test below fails because the answer
+ * carried no exception and not because a refill is unobservable here.
+ */
+ @Test
+ void publishRequestsResumeAfterAFailedPublishRequest() throws Exception {
+ try (var fixture = new Fixture(PublishAnswer.FAILURE)) {
+ fixture.createSubscription();
+
+ assertTrue(fixture.awaitInterceptedRequest(), "the client never sent a PublishRequest");
+
+ assertTrue(
+ fixture.awaitPublishRequestAtServer(),
+ "the failed PublishRequest was not replaced: its permit was never released");
+ }
+ }
+
+ @Test
+ void publishRequestsResumeAfterAnAnswerThatIsNotAPublishResponse() throws Exception {
+ try (var fixture = new Fixture(PublishAnswer.WRONG_TYPE)) {
+ fixture.createSubscription();
+
+ assertTrue(fixture.awaitInterceptedRequest(), "the client never sent a PublishRequest");
+
+ assertTrue(
+ fixture.awaitPublishRequestAtServer(),
+ "the client sent no further PublishRequest after being answered with a message that"
+ + " was not a PublishResponse: the answer completed the request successfully, so"
+ + " there was no exception to take a StatusCode from, and the pending-publish"
+ + " permit the request held was lost along with the whole pipeline");
+ }
+ }
+ }
+
+ /**
+ * A NotificationMessage is handed to the Subscription's delivery queue and the permit is released
+ * when the application callbacks have run, which is the backpressure the Server is held to. A
+ * delivery queue that cannot accept the task — {@code TaskQueue.submit()} returns {@code null}
+ * once it is shut down, or when its max queue size would be exceeded — has no callbacks to wait
+ * for, so nothing will ever release the permit on the message's behalf.
+ *
+ *
{@code recoverSubscription} already treated the same rejection from a Subscription's
+ * processing queue as work that will not happen and completed in its place; the delivery queue's
+ * rejection was simply dropped.
+ */
+ @Nested
+ class DeliveryQueueThatCannotAcceptTheMessage {
+
+ /**
+ * Control: the identical script with a delivery queue that can accept the message. It proves
+ * the fixture observes the pipeline being refilled after a PublishResponse at all, so the test
+ * below fails because the message could not be queued and not because a refill is unobservable
+ * here.
+ */
+ @Test
+ void publishRequestsResumeAfterADeliveredNotificationMessage() throws Exception {
+ try (var fixture = new Fixture(PublishAnswer.NONE)) {
+ OpcUaSubscription subscription = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueKeepAlive(subscription.getSubscriptionId().orElseThrow(), 1);
+
+ assertTrue(
+ fixture.awaitPublishRequestsAtServer(before + 1),
+ "the answered PublishRequest was not replaced once its NotificationMessage had been"
+ + " delivered");
+ }
+ }
+
+ @Test
+ void publishRequestsResumeWhenTheDeliveryQueueCannotAcceptTheNotificationMessage()
+ throws Exception {
+
+ try (var fixture = new Fixture(PublishAnswer.NONE)) {
+ OpcUaSubscription subscription = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ // Shut down before the response arrives, so the rejection is a property of the fixture
+ // rather than a race with the delivery.
+ subscription.getDeliveryQueue().shutdown(false);
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueKeepAlive(subscription.getSubscriptionId().orElseThrow(), 1);
+
+ assertTrue(
+ fixture.awaitPublishRequestsAtServer(before + 1),
+ "the client sent no further PublishRequest after a NotificationMessage its delivery"
+ + " queue could not accept: nothing was waiting to deliver it, so nothing released"
+ + " the pending-publish permit the message's PublishRequest held, and the pipeline"
+ + " is empty for good");
+ }
+ }
+ }
+
+ // region helpers
+
+ /** What the transport does with the client's first PublishRequest. */
+ private enum PublishAnswer {
+
+ /** Nothing: every PublishRequest is sent to the Server. */
+ NONE,
+
+ /** Answers it, without sending it, with a response that is not a PublishResponse. */
+ WRONG_TYPE,
+
+ /** Answers it, without sending it, with a service failure. */
+ FAILURE
+ }
+
+ /**
+ * An {@link OpcTcpClientTransport} that answers the client's first PublishRequest itself.
+ *
+ *
The interception is at the transport rather than at the Server because the answer being
+ * scripted is one no Server can express through a {@code SubscriptionServiceSet}, whose {@code
+ * onPublish} returns a {@code PublishResponse} and nothing else.
+ */
+ private static final class MisansweringTransport extends OpcTcpClientTransport {
+
+ private final AtomicInteger intercepted = new AtomicInteger(0);
+
+ private final PublishAnswer answer;
+
+ MisansweringTransport(OpcTcpClientTransportConfig config, PublishAnswer answer) {
+ super(config);
+
+ this.answer = answer;
+ }
+
+ int interceptedCount() {
+ return intercepted.get();
+ }
+
+ @Override
+ public CompletableFuture sendRequestMessage(
+ UaRequestMessageType requestMessage) {
+
+ if (answer != PublishAnswer.NONE
+ && requestMessage instanceof PublishRequest publishRequest
+ && intercepted.incrementAndGet() == 1) {
+
+ var future = new CompletableFuture();
+
+ // Completed on the transport's executor, which is where AbstractUascClientTransport
+ // completes any response that is not a PublishResponse: only PublishResponses go through
+ // the serial PublishResponse queue.
+ getConfig()
+ .getExecutor()
+ .execute(
+ () -> {
+ if (answer == PublishAnswer.FAILURE) {
+ future.completeExceptionally(new UaException(StatusCodes.Bad_InternalError));
+ } else {
+ future.complete(notAPublishResponse(publishRequest));
+ }
+ });
+
+ return future;
+ }
+
+ return super.sendRequestMessage(requestMessage);
+ }
+
+ /**
+ * A well-formed response of the wrong type, as a Server that mixed up its handlers would send.
+ */
+ private static UaResponseMessageType notAPublishResponse(PublishRequest request) {
+ var responseHeader =
+ new ResponseHeader(
+ DateTime.now(),
+ request.getRequestHeader().getRequestHandle(),
+ StatusCode.GOOD,
+ DiagnosticInfo.NULL_VALUE,
+ null,
+ null);
+
+ return new ReadResponse(responseHeader, null, null);
+ }
+ }
+
+ /**
+ * A running Server whose Publish requests are all parked by {@link
+ * ScriptableSubscriptionServiceSet} — nothing completes one unless the test says so — plus a
+ * client whose transport answers its first PublishRequest according to {@code answer}.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final ScriptableSubscriptionServiceSet scriptable;
+ private final MisansweringTransport transport;
+
+ Fixture(PublishAnswer answer) throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ var transportHolder = new MisansweringTransport[1];
+
+ client =
+ TestClient.createWithTransport(
+ server,
+ transportConfig -> {
+ transportHolder[0] = new MisansweringTransport(transportConfig, answer);
+ return transportHolder[0];
+ },
+ cfg ->
+ cfg
+ // Long request timeout so parked Publish requests do not time out.
+ .setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a test
+ // are the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ .setMaxPendingPublishRequests(uint(PIPELINE_TARGET)));
+
+ transport = transportHolder[0];
+
+ client.connect();
+ }
+
+ OpcUaSubscription createSubscription() throws UaException {
+ var subscription = new OpcUaSubscription(client);
+ subscription.create();
+
+ return subscription;
+ }
+
+ /** Wait until the transport has answered a PublishRequest on the Server's behalf. */
+ boolean awaitInterceptedRequest() throws Exception {
+ return awaitTrue(() -> transport.interceptedCount() >= 1, AWAIT_TIMEOUT_MILLIS);
+ }
+
+ /**
+ * Wait until the client has its whole pipeline outstanding, parked at the Server exactly as a
+ * Server holding queued Publish requests for a Session would hold it.
+ */
+ boolean awaitFullPipeline() throws Exception {
+ return awaitTrue(
+ () -> scriptable.getParkedRequestCount() >= PIPELINE_TARGET, AWAIT_TIMEOUT_MILLIS);
+ }
+
+ boolean awaitPublishRequestAtServer() throws Exception {
+ return awaitPublishRequestsAtServer(1);
+ }
+
+ boolean awaitPublishRequestsAtServer(int count) throws Exception {
+ return awaitTrue(() -> scriptable.getPublishRequestCount() >= count, AWAIT_TIMEOUT_MILLIS);
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ /** Polls {@code condition} until it holds or {@code timeoutMillis} elapses. */
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis)
+ throws Exception {
+
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPipelineRefillTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPipelineRefillTest.java
new file mode 100644
index 0000000000..b930a31e73
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishPipelineRefillTest.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClientConfigBuilder;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * When the Publish pipeline is refilled after a fault.
+ *
+ *
{@code PublishingManager} keeps {@code min(subscriptionCount + 1, maxPendingPublishRequests)}
+ * Publish requests outstanding per Session and refills the pipeline whenever one of them is dealt
+ * with. Two of the fault paths get the refill wrong, and both are exercised here through {@link
+ * ScriptableSubscriptionServiceSet}, which parks every Publish request until the test decides how
+ * it ends — so the timing belongs to the test rather than to a Server timer.
+ */
+public class PublishPipelineRefillTest {
+
+ /** One Subscription targets min(1 + 1, UInteger.MAX_VALUE) = 2 outstanding Publish requests. */
+ private static final int PIPELINE_TARGET = 2;
+
+ /**
+ * The target of a client configured for a single pending PublishRequest: min(1 + 1, 1) = 1, so
+ * exactly one Publish request is ever outstanding.
+ */
+ private static final int SINGLE_REQUEST_PIPELINE_TARGET = 1;
+
+ private static final long PIPELINE_FILL_TIMEOUT_MILLIS = 10_000;
+
+ private static final long REFILL_TIMEOUT_MILLIS = 10_000;
+
+ /** How long to watch for a Publish request that must not be sent. */
+ private static final long QUIET_WINDOW_MILLIS = 2_000;
+
+ private static final long DELIVERY_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * Part 4 §5.14.8.1: when the last Subscription of a Session is deleted, "all Publish requests
+ * still queued for that Session are de-queued and shall be returned with Bad_NoSubscription".
+ * Milo's own Server does exactly that in {@code SubscriptionManager}.
+ *
+ *
An application that deletes its last Subscription and immediately creates another therefore
+ * takes that whole burst of Bad_NoSubscription failures after its Subscription set has
+ * become non-empty again. {@code addSubscription()} cannot start the new Subscription's Publish
+ * traffic, because the permits taken by the de-queued requests are still held when it runs, so
+ * the refill has to come from the failure handler — and the failure handler suppresses it
+ * unconditionally for Bad_NoSubscription.
+ */
+ @Nested
+ class DeletingTheLastSubscription {
+
+ /**
+ * Control: the same delete/create/fail sequence, but with a status code the failure handler
+ * does not suppress the refill for. It proves the fixture can observe a refill at all, so the
+ * two tests below fail because of what Bad_NoSubscription does and not because a refill is
+ * unobservable here.
+ */
+ @Test
+ void publishRequestsResumeWhenTheDeQueuedRequestsFailWithAnotherStatusCode() throws Exception {
+ try (var fixture = new Fixture()) {
+ OpcUaSubscription first = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ first.delete();
+ fixture.createSubscription();
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.failParkedRequests(StatusCodes.Bad_InternalError);
+
+ assertTrue(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, REFILL_TIMEOUT_MILLIS),
+ "no Publish request was sent for the newly created Subscription");
+ }
+ }
+
+ @Test
+ void publishRequestsResumeAfterTheLastSubscriptionIsDeletedAndAnotherIsCreated()
+ throws Exception {
+
+ try (var fixture = new Fixture()) {
+ OpcUaSubscription first = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ first.delete();
+ fixture.createSubscription();
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+
+ assertTrue(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, REFILL_TIMEOUT_MILLIS),
+ "no Publish request was sent for the newly created Subscription: the de-queued requests"
+ + " released their permits without refilling the pipeline, and nothing else will");
+ }
+ }
+
+ /**
+ * The same defect stated as what the application sees. A Subscription that was created
+ * successfully, and that the Server is happily publishing for, never delivers anything.
+ */
+ @Test
+ void recreatedSubscriptionReceivesNotificationsAfterTheLastOneWasDeleted() throws Exception {
+ try (var fixture = new Fixture()) {
+ OpcUaSubscription first = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ first.delete();
+
+ OpcUaSubscription second = fixture.createSubscription();
+
+ var keepAliveReceived = new CountDownLatch(1);
+ second.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onKeepAliveReceived(OpcUaSubscription subscription) {
+ keepAliveReceived.countDown();
+ }
+ });
+
+ fixture.scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+
+ // Held by the harness until a Publish request arrives to carry it.
+ fixture.scriptable.enqueueKeepAlive(second.getSubscriptionId().orElseThrow(), 1);
+
+ assertTrue(
+ keepAliveReceived.await(DELIVERY_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the re-created Subscription never received a NotificationMessage: no Publish request"
+ + " was ever sent for it");
+ }
+ }
+ }
+
+ /**
+ * Part 4 §5.14.5.1: a Client "shall not issue another Publish request before one of its
+ * outstanding Publish requests is returned" once the Server has answered
+ * Bad_TooManyPublishRequests — one returned request buys one replacement.
+ *
+ *
The immediate half of that is implemented: the failed request is not replaced on the spot.
+ * But the target is left at subscriptionCount + 1, so the deficit the fault opened is still
+ * there, and the next PublishResponse that is delivered refills all of it at once. One returned
+ * request causes two to be issued, restoring exactly the outstanding count that drew the fault.
+ *
+ *
The same clause also requires a Server to accept at least subscriptionCount + 1 queued
+ * Publish requests, which is precisely what the Client aims for, so a conformant Server never
+ * answers Bad_TooManyPublishRequests here. This is about what the Client does when a
+ * non-conformant one does.
+ */
+ @Nested
+ class TooManyPublishRequests {
+
+ /**
+ * Control: the immediate suppression, which already works. It also establishes that the quiet
+ * window below is long enough for the failure to have been accounted for.
+ */
+ @Test
+ void noPublishRequestIsSentImmediatelyAfterBadTooManyPublishRequests() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_TooManyPublishRequests);
+
+ assertFalse(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, QUIET_WINDOW_MILLIS),
+ "a Publish request was sent straight back at a Server that had just refused one for"
+ + " holding too many");
+ }
+ }
+
+ @Test
+ void atMostOnePublishRequestReplacesTheOneThatReturnedAfterTheFault() throws Exception {
+ try (var fixture = new Fixture()) {
+ OpcUaSubscription subscription = fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_TooManyPublishRequests);
+
+ // The suppression asserted by the control test, repeated here because it is also the
+ // barrier: it is what makes the failure accounted for before the response below is
+ // delivered, so the number of requests that follow is a property of the client and not of
+ // which of the two completions won.
+ assertFalse(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > PIPELINE_TARGET,
+ QUIET_WINDOW_MILLIS),
+ "a Publish request was sent straight back at a Server that had just refused one for"
+ + " holding too many");
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueKeepAlive(subscription.getSubscriptionId().orElseThrow(), 1);
+
+ assertTrue(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, REFILL_TIMEOUT_MILLIS),
+ "the returned Publish request was not replaced at all");
+
+ assertFalse(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before + 1,
+ QUIET_WINDOW_MILLIS),
+ "two Publish requests were issued in response to one returning, restoring the very"
+ + " outstanding count that had just drawn Bad_TooManyPublishRequests");
+ }
+ }
+ }
+
+ /**
+ * The other half of Part 4 §5.14.5.1: a Client "shall not issue another Publish request before
+ * one of its outstanding Publish requests is returned". The refused request is itself one
+ * being returned, so when it was the only one outstanding that condition is met the moment the
+ * fault has been accounted for — and nothing else is left in flight whose return could ever meet
+ * it again.
+ *
+ *
"Exactly one outstanding" is reached by configuring maxPendingPublishRequests = 1: the
+ * client targets min(subscriptionCount + 1, maxPendingPublishRequests), which with one
+ * Subscription is min(2, 1) = 1. The ceiling the fault installs is max(1, outstanding) = 1, i.e.
+ * the target the client already had, so a pipeline that stays empty here cannot be blamed on the
+ * clamp.
+ *
+ *
This does not contradict {@link TooManyPublishRequests}: there two requests are outstanding,
+ * the fault returns one of them, one is still in flight, and the deficit is filled by exactly one
+ * replacement when that one comes back. The rule is one replacement per returned request, and the
+ * refused request is a returned request.
+ */
+ @Nested
+ class TooManyPublishRequestsReturningTheOnlyOutstandingRequest {
+
+ /**
+ * Control: the identical one-deep pipeline faulted with a status code the failure handler
+ * refills for unconditionally. It proves the fixture can observe a one-deep pipeline being
+ * refilled at all, so the test below fails because of what Bad_TooManyPublishRequests does and
+ * not because a refill is unobservable here.
+ */
+ @Test
+ void publishRequestsResumeWhenTheOnlyOutstandingRequestFailsWithAnotherStatusCode()
+ throws Exception {
+
+ try (Fixture fixture = Fixture.withSinglePendingPublishRequest()) {
+ fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_InternalError);
+
+ assertTrue(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, REFILL_TIMEOUT_MILLIS),
+ "the only outstanding Publish request failed and was not replaced");
+ }
+ }
+
+ @Test
+ void publishRequestsResumeWhenBadTooManyPublishRequestsReturnsTheLastOne() throws Exception {
+
+ try (Fixture fixture = Fixture.withSinglePendingPublishRequest()) {
+ fixture.createSubscription();
+ assertTrue(fixture.awaitFullPipeline(), "the Publish pipeline never filled");
+
+ int before = fixture.scriptable.getPublishRequestCount();
+
+ fixture.scriptable.enqueueServiceFault(StatusCodes.Bad_TooManyPublishRequests);
+
+ assertTrue(
+ awaitTrue(
+ () -> fixture.scriptable.getPublishRequestCount() > before, REFILL_TIMEOUT_MILLIS),
+ "the Publish pipeline was left empty: the request the Server refused was the only one"
+ + " outstanding, so returning it met Part 4 §5.14.5.1's condition for issuing"
+ + " another, and no other request is left in flight whose return could restart the"
+ + " pipeline. Publish traffic is halted until something unrelated — a Subscription"
+ + " added, a Session activated — happens to restart it");
+ }
+ }
+ }
+
+ private static boolean awaitTrue(BooleanSupplierThrowing condition, long timeoutMillis)
+ throws Exception {
+
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface BooleanSupplierThrowing {
+ boolean get() throws Exception;
+ }
+
+ /**
+ * A running Server whose Publish requests are all parked by {@link
+ * ScriptableSubscriptionServiceSet} — nothing completes one unless the test says so — plus a
+ * connected client.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final ScriptableSubscriptionServiceSet scriptable;
+
+ /** The number of Publish requests this fixture's client keeps outstanding. */
+ private final int pipelineTarget;
+
+ Fixture() throws Exception {
+ this(PIPELINE_TARGET, cfg -> {});
+ }
+
+ /**
+ * A fixture whose client keeps a single PublishRequest outstanding, so a fault that answers it
+ * leaves nothing in flight.
+ */
+ static Fixture withSinglePendingPublishRequest() throws Exception {
+ return new Fixture(
+ SINGLE_REQUEST_PIPELINE_TARGET,
+ cfg -> cfg.setMaxPendingPublishRequests(uint(SINGLE_REQUEST_PIPELINE_TARGET)));
+ }
+
+ private Fixture(int pipelineTarget, Consumer configCustomizer)
+ throws Exception {
+
+ this.pipelineTarget = pipelineTarget;
+
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ // Long request timeout so parked Publish requests do not time out during the test.
+ client =
+ TestClient.create(
+ server,
+ cfg -> {
+ cfg.setRequestTimeout(uint(60_000));
+ configCustomizer.accept(cfg);
+ });
+ client.connect();
+ }
+
+ OpcUaSubscription createSubscription() throws UaException {
+ var subscription = new OpcUaSubscription(client);
+ subscription.create();
+ return subscription;
+ }
+
+ /**
+ * Wait until the client has its whole pipeline outstanding, all of it parked by the harness.
+ * The Server therefore holds the requests exactly as a Server holding queued Publish requests
+ * for a Session would.
+ */
+ boolean awaitFullPipeline() throws Exception {
+ return awaitTrue(
+ () -> scriptable.getParkedRequestCount() >= pipelineTarget, PIPELINE_FILL_TIMEOUT_MILLIS);
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishReconnectRecoveryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishReconnectRecoveryTest.java
new file mode 100644
index 0000000000..e5afe91497
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishReconnectRecoveryTest.java
@@ -0,0 +1,943 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.DelegatingSessionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the client does with a Subscription the first time its Session becomes {@code Active} again
+ * after a connection or Session fault.
+ *
+ *
Part 4 §6.7 "Re-establishing connections": "After re-establishing the connection the Client
+ * shall call Republish in a loop, starting with the next expected sequence number and incrementing
+ * the sequence number until the Server returns the status Bad_MessageNotAvailable." The loop comes
+ * before normal Publish handling resumes, and the reason is mechanical: the Server's
+ * retransmission queue is finite, and Part 4 §5.14.1.1 says that "in the case of a retransmission
+ * queue overflow, the oldest sent NotificationMessage gets deleted". Every NotificationMessage the
+ * Server sends in answer to a resumed Publish request is therefore capable of evicting one the
+ * client has not collected yet.
+ *
+ *
Milo already performs the rest of §6.7 — it re-establishes the SecureChannel, re-activates or
+ * re-creates the Session, and calls TransferSubscriptions with {@code sendInitialValues} — and it
+ * repairs a sequence gap reactively, once a PublishResponse has revealed one. What it does
+ * not do is drain the retransmission queue before letting that first PublishResponse arrive: {@code
+ * PublishingManager}'s {@code SessionActivityListener} goes straight to {@code
+ * maybeSendPublishRequests()}. The tests below drive both reconnect paths the Session FSM has and
+ * assert on the order in which requests reach the Server, and on what the application ends up
+ * seeing when the retransmission queue overflows in the window the missing order opens.
+ *
+ *
They also cover the second half of the same clause set. A successful TransferSubscriptions
+ * returns, per Part 4 §5.14.7.1, the sequence numbers of the NotificationMessages the Server is
+ * still holding for the transferred Subscription — "The Client should acknowledge all Messages in
+ * this list for which it will not request retransmission" — which is exactly the input a Republish
+ * drain needs. {@code SessionFsmFactory} reads only the TransferResult's StatusCode.
+ */
+public class PublishReconnectRecoveryTest {
+
+ /** Log entry written when a PublishRequest reaches the Server. */
+ private static final String PUBLISH = "Publish";
+
+ /** Prefix of the log entry written when a RepublishRequest reaches the Server. */
+ private static final String REPUBLISH = "Republish:";
+
+ /**
+ * The sequence number of the last NotificationMessage the client accounts for before the Session
+ * fault, and therefore the sequence number §6.7's Republish loop has to start from.
+ */
+ private static final long LAST_SEQUENCE_NUMBER_BEFORE_FAULT = 2;
+
+ private static final long NEXT_EXPECTED_SEQUENCE_NUMBER = LAST_SEQUENCE_NUMBER_BEFORE_FAULT + 1;
+
+ /**
+ * The two NotificationMessages the Server generates while the client is not connected, i.e. the
+ * ones §6.7's Republish loop exists to collect.
+ */
+ private static final long[] GENERATED_WHILE_DISCONNECTED = {3, 4};
+
+ /** The sequence number of the first NotificationMessage the Server sends after the reconnect. */
+ private static final long FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT = 5;
+
+ /**
+ * A retransmission queue with room for exactly the two NotificationMessages generated while the
+ * client was disconnected. Sending one more overflows it and deletes the oldest, which is the
+ * loss §6.7's ordering requirement is there to prevent. Real queues are larger — Part 4 §5.14.5.1
+ * makes the Server hold "at least two times the number of Publish requests per Session" — but
+ * every queue has a size, and the number here only decides how many new NotificationMessages it
+ * takes to lose an old one.
+ */
+ private static final int OVERFLOWING_RETRANSMISSION_QUEUE = 2;
+
+ /** A retransmission queue with room to spare, so nothing is evicted during the test. */
+ private static final int ROOMY_RETRANSMISSION_QUEUE = 4;
+
+ /** How long to wait for something that must happen. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * How long to wait for a reconnect. The Session FSM waits one second in {@code ReactivatingWait}
+ * before its first re-activation attempt and another second in {@code CreatingWait} before
+ * creating a replacement Session, and doubles each wait on every failure.
+ */
+ private static final long RECONNECT_TIMEOUT_MILLIS = 30_000;
+
+ /**
+ * Long enough that nothing times out on its own: no parked Publish request, no Republish, and no
+ * Session keep-alive. Every stall asserted against below is therefore the client's own doing.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ /**
+ * The same-session reconnect path: {@code Active -> ReactivatingWait -> Reactivating ->
+ * Initializing -> Active}. No TransferSubscriptions happens on it, so the only thing that can
+ * tell the client what the Server still holds is the Republish loop itself.
+ */
+ @Nested
+ class ReactivatedSession {
+
+ /**
+ * Part 4 §6.7: "After re-establishing the connection the Client shall call Republish in a loop,
+ * starting with the next expected sequence number and incrementing the sequence number until
+ * the Server returns the status Bad_MessageNotAvailable."
+ *
+ *
The very first request the client sends for a Subscription once its Session is {@code
+ * Active} again must therefore be a Republish for the next expected sequence number, not a
+ * Publish.
+ */
+ @Test
+ void firstRequestAfterReactivationIsRepublishFromTheNextExpectedSequenceNumber()
+ throws Exception {
+
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitRequestLogEntries(1),
+ "no request at all reached the Server after the Session was re-activated");
+
+ assertEquals(
+ REPUBLISH + NEXT_EXPECTED_SEQUENCE_NUMBER,
+ fixture.requestLog().get(0),
+ "the first request sent after re-activation must be the Republish that §6.7 requires;"
+ + " resuming Publish first lets the Server answer with a NotificationMessage that"
+ + " overwrites one still sitting in its retransmission queue");
+ }
+ }
+
+ /**
+ * The control that keeps the assertion above from passing vacuously: in this fixture the
+ * re-activated Session really does resume Publish traffic and deliver notifications, so a
+ * failure above is about what the client sends first, not about the client sending
+ * nothing at all.
+ */
+ @Test
+ void publishResumesAfterReactivation() throws Exception {
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(NEXT_EXPECTED_SEQUENCE_NUMBER);
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitDeliveredValues(List.of(1, 2, (int) NEXT_EXPECTED_SEQUENCE_NUMBER)),
+ "control: the NotificationMessage scripted for the first Publish after re-activation"
+ + " was never delivered, actual deliveries: "
+ + fixture.deliveredValues());
+ }
+ }
+ }
+
+ /**
+ * What the ordering requirement is worth, measured at the application. The Server generates two
+ * NotificationMessages while the client is away and holds them in a retransmission queue that has
+ * no room for a third. Whether the client collects them depends entirely on whether it asks
+ * before or after it lets the Server publish something new.
+ */
+ @Nested
+ class NotificationMessagesGeneratedWhileTheSessionWasDown {
+
+ /**
+ * The two NotificationMessages the Server generated while the client was away are in its
+ * retransmission queue when the Session becomes {@code Active} again, so §6.7's Republish loop
+ * would collect both. Resuming Publish first spends the queue's last slot on a new
+ * NotificationMessage instead, and the oldest of the two is deleted before the client ever asks
+ * for it — reported to the application only as {@code onNotificationDataLost}.
+ */
+ @Test
+ void areRecoveredEvenWhenTheNextPublishResponseOverflowsTheRetransmissionQueue()
+ throws Exception {
+
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ fixture.awaitDeliveredValues(List.of(1, 2, 3, 4, 5));
+
+ assertAll(
+ () ->
+ assertEquals(
+ List.of(1, 2, 3, 4, 5),
+ fixture.deliveredValues(),
+ "every NotificationMessage the Server generated while the client was away was"
+ + " still in its retransmission queue when the Session became Active"
+ + " again, so all of them must reach the application exactly once and in"
+ + " sequence order"),
+ () ->
+ assertEquals(
+ 0,
+ fixture.notificationDataLostCount(),
+ "no NotificationMessage was unrecoverable at the moment the Session became"
+ + " Active again; reporting data lost means the client let the Server"
+ + " overwrite one before asking for it"));
+ }
+ }
+
+ /**
+ * The control that isolates the retransmission queue overflow as the cause: the identical
+ * script, against a Server whose retransmission queue has room for the new NotificationMessage
+ * as well, loses nothing today. The reactive gap repair recovers both missing messages because
+ * the Server still happens to hold them when the first PublishResponse reveals the gap — which
+ * is luck, not a guarantee, and is exactly what §6.7's ordering removes the need for.
+ */
+ @Test
+ void areRecoveredWhenTheRetransmissionQueueDoesNotOverflow() throws Exception {
+ try (var fixture = new Fixture(ROOMY_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ fixture.awaitDeliveredValues(List.of(1, 2, 3, 4, 5));
+
+ assertAll(
+ () ->
+ assertEquals(
+ List.of(1, 2, 3, 4, 5),
+ fixture.deliveredValues(),
+ "control: with a retransmission queue that does not overflow, every"
+ + " NotificationMessage must reach the application"),
+ () ->
+ assertEquals(
+ 0,
+ fixture.notificationDataLostCount(),
+ "control: nothing was ever evicted, so nothing can be reported lost"));
+ }
+ }
+ }
+
+ /**
+ * The new-session reconnect path: re-activation is refused, so the client creates a replacement
+ * Session and runs {@code Active -> ... -> Transferring -> Initializing -> Active}. Here the
+ * Server does tell the client what it is holding — Part 4 §5.14.7.1 gives each successful
+ * TransferResult the "sequence numbers of the NotificationMessages available for retransmission"
+ * — and the client throws that list away.
+ */
+ @Nested
+ class TransferredSubscription {
+
+ /**
+ * §6.7's Republish loop applies to this path too, and the TransferResult has just named the
+ * sequence numbers it should ask for. Every one of them must be requested before the client
+ * lets the Server answer a Publish request, because answering one is what overflows the
+ * retransmission queue holding them.
+ */
+ @Test
+ void everySequenceNumberTheTransferAdvertisedIsRepublishedBeforePublishResumes()
+ throws Exception {
+
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+ fixture.advertiseOnTransfer(GENERATED_WHILE_DISCONNECTED);
+ fixture.refuseNextReactivation();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitRequestLogContains(PUBLISH),
+ "Publish never resumed after the Subscription was transferred to the new Session");
+ assertTrue(
+ fixture.transferCount() >= 1,
+ "precondition: the reconnect did not go through TransferSubscriptions, so this test"
+ + " is not exercising the transfer path at all");
+
+ List log = fixture.requestLog();
+ int firstPublish = log.indexOf(PUBLISH);
+
+ assertAll(
+ () ->
+ assertEquals(
+ REPUBLISH + GENERATED_WHILE_DISCONNECTED[0],
+ log.get(0),
+ "the first request sent after the transfer must be the Republish §6.7"
+ + " requires, for the oldest NotificationMessage the TransferResult said"
+ + " the Server still holds; request log: "
+ + log),
+ () ->
+ assertTrue(
+ isBefore(log, REPUBLISH + GENERATED_WHILE_DISCONNECTED[1], firstPublish),
+ "the TransferResult advertised sequence number "
+ + GENERATED_WHILE_DISCONNECTED[1]
+ + " as available for retransmission, so it must be requested before the"
+ + " first Publish; request log: "
+ + log));
+ }
+ }
+
+ /**
+ * The control for the test above: it proves the fixture really does drive the client onto the
+ * new-session path, that TransferSubscriptions is reached, and that Publish traffic resumes
+ * afterwards — so a failure above is about ordering rather than about a reconnect that never
+ * happened.
+ */
+ @Test
+ void isTransferredToAReplacementSessionAndPublishResumes() throws Exception {
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+ fixture.advertiseOnTransfer(GENERATED_WHILE_DISCONNECTED);
+ fixture.refuseNextReactivation();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitTrue(
+ () ->
+ fixture.deliveredValues().contains((int) FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT),
+ AWAIT_TIMEOUT_MILLIS),
+ "control: the NotificationMessage scripted for the first Publish after the transfer"
+ + " was never delivered, actual deliveries: "
+ + fixture.deliveredValues());
+
+ assertTrue(
+ fixture.transferCount() >= 1,
+ "control: the reconnect did not go through TransferSubscriptions");
+ }
+ }
+ }
+
+ /**
+ * Gating Publish on a recovery step trades a loss window for a permanent stall if any branch of
+ * that step can fail without releasing the gate. These are the guards for that: whatever the
+ * Republish loop runs into, Publish traffic has to resume.
+ */
+ @Nested
+ class RecoveryFailureMustNotStallPublish {
+
+ /** A Republish that fails with a service error, rather than Bad_MessageNotAvailable. */
+ @Test
+ void publishResumesWhenEveryRepublishFailsWithAServiceError() throws Exception {
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+ fixture.failEveryRepublishWith(StatusCodes.Bad_UnexpectedError);
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitTrue(
+ () ->
+ fixture.deliveredValues().contains((int) FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT),
+ AWAIT_TIMEOUT_MILLIS),
+ "Publish did not resume after every Republish failed: recovery that cannot complete"
+ + " must still hand the pipeline back, actual deliveries: "
+ + fixture.deliveredValues());
+ }
+ }
+
+ /**
+ * The Subscription no longer exists on the Server. Part 4 §6.7: "If the Republish returns
+ * Bad_SubscriptionIdInvalid, then the Client needs to create a new Subscription" — which is a
+ * statement about the Subscription, not a reason for the client's Publish pipeline to stop.
+ */
+ @Test
+ void publishResumesWhenRepublishReportsTheSubscriptionIsGone() throws Exception {
+ try (var fixture = new Fixture(OVERFLOWING_RETRANSMISSION_QUEUE)) {
+ fixture.deliverInitialNotifications();
+ fixture.generateWhileDisconnected(GENERATED_WHILE_DISCONNECTED);
+ fixture.failEveryRepublishWith(StatusCodes.Bad_SubscriptionIdInvalid);
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.enqueueDataChange(FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT);
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitTrue(
+ () ->
+ fixture.deliveredValues().contains((int) FIRST_SEQUENCE_NUMBER_AFTER_RECONNECT),
+ AWAIT_TIMEOUT_MILLIS),
+ "Publish did not resume after Republish reported Bad_SubscriptionIdInvalid, actual"
+ + " deliveries: "
+ + fixture.deliveredValues());
+ }
+ }
+ }
+
+ /**
+ * @return {@code true} if {@code entry} appears in {@code log} before index {@code limit}.
+ */
+ private static boolean isBefore(List log, String entry, int limit) {
+ int index = log.indexOf(entry);
+
+ return index >= 0 && index < limit;
+ }
+
+ // region fixture
+
+ /**
+ * The order in which Publish and Republish requests reach the Server.
+ *
+ *
Recording starts only when the log is {@linkplain #arm() armed}, which a test does once its
+ * Publish pipeline is quiescent — one request parked at the Server and no responder scripted for
+ * it. From that moment nothing can reach the Server until the scripted Session fault has been
+ * answered and the Session has become {@code Active} again, so the first entry recorded is
+ * exactly the first request the client sends after the reconnect.
+ */
+ private static final class RequestLog {
+
+ private final List entries = Collections.synchronizedList(new ArrayList<>());
+
+ private volatile boolean armed = false;
+
+ void arm() {
+ entries.clear();
+ armed = true;
+ }
+
+ void record(String entry) {
+ if (armed) {
+ entries.add(entry);
+ }
+ }
+
+ List entries() {
+ return List.copyOf(entries);
+ }
+ }
+
+ /**
+ * A Server-side retransmission queue of bounded size.
+ *
+ *
Part 4 §5.14.1.1: "In the case of a retransmission queue overflow, the oldest sent
+ * NotificationMessage gets deleted." A NotificationMessage is available for Republish while it is
+ * in the queue, and the queue's contents are what a PublishResponse advertises in
+ * availableSequenceNumbers.
+ */
+ private static final class RetransmissionQueue {
+
+ private final Deque queue = new ArrayDeque<>();
+
+ private final int capacity;
+
+ RetransmissionQueue(int capacity) {
+ this.capacity = capacity;
+ }
+
+ /** Record that the Server has sent the NotificationMessage with this sequence number. */
+ synchronized void send(long sequenceNumber) {
+ queue.addLast(sequenceNumber);
+
+ while (queue.size() > capacity) {
+ queue.removeFirst();
+ }
+ }
+
+ synchronized boolean holds(long sequenceNumber) {
+ return queue.contains(sequenceNumber);
+ }
+
+ synchronized UInteger[] available() {
+ return queue.stream().map(sequenceNumber -> uint(sequenceNumber)).toArray(UInteger[]::new);
+ }
+ }
+
+ /**
+ * A {@link ScriptableSubscriptionServiceSet} that records the arrival of every PublishRequest and
+ * can answer TransferSubscriptions with a scripted availableSequenceNumbers list.
+ */
+ private static final class LoggingSubscriptionServiceSet
+ extends ScriptableSubscriptionServiceSet {
+
+ private final AtomicInteger transferCount = new AtomicInteger(0);
+
+ /**
+ * {@code null} until a test scripts the transfer, at which point the Server delegate is no
+ * longer consulted.
+ */
+ private volatile UInteger[] transferAvailableSequenceNumbers = null;
+
+ private final RequestLog requestLog;
+
+ LoggingSubscriptionServiceSet(OpcUaServer server, RequestLog requestLog) {
+ super(server);
+
+ this.requestLog = requestLog;
+ }
+
+ @Override
+ public CompletableFuture onPublish(
+ ServiceRequestContext context, PublishRequest request) {
+
+ requestLog.record(PUBLISH);
+
+ return super.onPublish(context, request);
+ }
+
+ @Override
+ public TransferSubscriptionsResponse onTransferSubscriptions(
+ ServiceRequestContext context, TransferSubscriptionsRequest request) throws UaException {
+
+ UInteger[] available = transferAvailableSequenceNumbers;
+
+ if (available == null) {
+ return super.onTransferSubscriptions(context, request);
+ }
+
+ transferCount.incrementAndGet();
+
+ UInteger[] subscriptionIds = request.getSubscriptionIds();
+ int count = subscriptionIds != null ? subscriptionIds.length : 0;
+
+ var results = new TransferResult[count];
+ for (int i = 0; i < count; i++) {
+ results[i] = new TransferResult(StatusCode.GOOD, available);
+ }
+
+ var responseHeader =
+ new ResponseHeader(
+ DateTime.now(),
+ request.getRequestHeader().getRequestHandle(),
+ StatusCode.GOOD,
+ null,
+ null,
+ null);
+
+ return new TransferSubscriptionsResponse(responseHeader, results, null);
+ }
+ }
+
+ /**
+ * A {@link DelegatingSessionServiceSet} that can refuse a single ActivateSession with a
+ * ServiceFault, which is what drives the Session FSM off the re-activation path and onto the
+ * create-a-new-Session-and-transfer path.
+ */
+ private static final class RefusingSessionServiceSet extends DelegatingSessionServiceSet {
+
+ private final AtomicBoolean refuseNext = new AtomicBoolean(false);
+
+ RefusingSessionServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ @Override
+ public ActivateSessionResponse onActivateSession(
+ ServiceRequestContext context, ActivateSessionRequest request) throws UaException {
+
+ if (refuseNext.compareAndSet(true, false)) {
+ throw new UaException(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ return super.onActivateSession(context, request);
+ }
+ }
+
+ /**
+ * A running Server whose Publish, Republish and TransferSubscriptions responses are scripted, and
+ * a connected client with one Subscription carrying one MonitoredItem.
+ *
+ *
The client is configured for a single pending PublishRequest. That makes the pipeline state
+ * unambiguous at the moment the Session fault is scripted: exactly one request is parked at the
+ * Server, the fault answers it, and nothing is left outstanding to muddle what the client sends
+ * once the Session is {@code Active} again.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private final RequestLog requestLog = new RequestLog();
+
+ private final RetransmissionQueue retransmissionQueue;
+
+ private final List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ private final AtomicInteger notificationDataLostCount = new AtomicInteger(0);
+
+ private final CountDownLatch sessionInactive = new CountDownLatch(1);
+ private final CountDownLatch sessionReactivated = new CountDownLatch(1);
+
+ /** Non-zero while every Republish is scripted to fail with that StatusCode. */
+ private volatile long republishFailure = 0L;
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final LoggingSubscriptionServiceSet scriptable;
+ private final RefusingSessionServiceSet sessionServiceSet;
+
+ private final OpcUaSubscription subscription;
+ private final UInteger subscriptionId;
+ private final UInteger clientHandle;
+
+ Fixture(int retransmissionQueueCapacity) throws Exception {
+ retransmissionQueue = new RetransmissionQueue(retransmissionQueueCapacity);
+
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new LoggingSubscriptionServiceSet(server, requestLog);
+ sessionServiceSet = new RefusingSessionServiceSet(server);
+
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ server.addServiceSet(endpoint.getPath(), sessionServiceSet);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ cfg ->
+ cfg.setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a test
+ // are the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ .setMaxPendingPublishRequests(uint(1)));
+ client.connect();
+
+ client.addSessionActivityListener(
+ new SessionActivityListener() {
+ @Override
+ public void onSessionInactive(UaSession session) {
+ sessionInactive.countDown();
+ }
+
+ @Override
+ public void onSessionActive(UaSession session) {
+ if (sessionInactive.getCount() == 0) {
+ sessionReactivated.countDown();
+ }
+ }
+ });
+
+ scriptable.setRepublishResponder(this::respondToRepublish);
+
+ subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription s, List items, List values) {
+
+ for (DataValue value : values) {
+ deliveredValues.add((Integer) value.getValue().getValue());
+ }
+ }
+
+ @Override
+ public void onNotificationDataLost(OpcUaSubscription s) {
+ notificationDataLostCount.incrementAndGet();
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // The MonitoredItem only has to exist on the client: addMonitoredItem assigns the
+ // ClientHandle the notification fan-out looks scripted notifications up by, and no
+ // Server-side item participates in delivering one.
+ OpcUaMonitoredItem item =
+ OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+
+ clientHandle = item.getClientHandle().orElseThrow();
+ }
+
+ /**
+ * Deliver NotificationMessages 1 and 2, leaving the client's last accounted-for sequence number
+ * at {@value #LAST_SEQUENCE_NUMBER_BEFORE_FAULT} and its Publish pipeline quiescent: one
+ * request parked at the Server with no responder scripted for it.
+ */
+ void deliverInitialNotifications() throws Exception {
+ enqueueDataChange(1);
+ assertTrue(
+ awaitTrue(() -> deliveredValues().size() >= 1, AWAIT_TIMEOUT_MILLIS),
+ "the first NotificationMessage was never delivered");
+
+ enqueueDataChange(LAST_SEQUENCE_NUMBER_BEFORE_FAULT);
+ assertTrue(
+ awaitTrue(() -> deliveredValues().size() >= 2, AWAIT_TIMEOUT_MILLIS),
+ "the second NotificationMessage was never delivered");
+
+ assertTrue(
+ awaitTrue(() -> scriptable.getParkedRequestCount() == 1, AWAIT_TIMEOUT_MILLIS),
+ "the client did not refill its Publish pipeline");
+
+ assertEquals(
+ List.of(1, (int) LAST_SEQUENCE_NUMBER_BEFORE_FAULT),
+ deliveredValues(),
+ "precondition: the client must have accounted for NotificationMessages 1 and 2 before"
+ + " the Session fault");
+ assertEquals(
+ 0,
+ notificationDataLostCount(),
+ "precondition: nothing may be lost before the Session fault");
+ }
+
+ /**
+ * Record that the Server generated these NotificationMessages, and is therefore holding them in
+ * its retransmission queue, while the client was not connected.
+ */
+ void generateWhileDisconnected(long... sequenceNumbers) {
+ for (long sequenceNumber : sequenceNumbers) {
+ retransmissionQueue.send(sequenceNumber);
+ }
+ }
+
+ /** Answer TransferSubscriptions with these availableSequenceNumbers (Part 4 §5.14.7.1). */
+ void advertiseOnTransfer(long... sequenceNumbers) {
+ var available = new UInteger[sequenceNumbers.length];
+ for (int i = 0; i < sequenceNumbers.length; i++) {
+ available[i] = uint(sequenceNumbers[i]);
+ }
+
+ scriptable.transferAvailableSequenceNumbers = available;
+ }
+
+ /**
+ * Refuse the next ActivateSession with a ServiceFault, which sends the Session FSM to {@code
+ * CreatingWait} and from there onto the create-a-new-Session-and-transfer path.
+ */
+ void refuseNextReactivation() {
+ sessionServiceSet.refuseNext.set(true);
+ }
+
+ /** Script every Republish to fail with {@code statusCode} rather than model the queue. */
+ void failEveryRepublishWith(long statusCode) {
+ republishFailure = statusCode;
+ }
+
+ void armRequestLog() {
+ requestLog.arm();
+ }
+
+ /**
+ * Answer the one parked PublishRequest with a Bad_SessionIdInvalid ServiceFault, which {@code
+ * SessionFsmFactory}'s SessionFaultListener classifies as a Session error and turns into a
+ * reconnect. The Server-side Session is untouched, so re-activation succeeds unless {@link
+ * #refuseNextReactivation()} was called.
+ */
+ void faultSession() {
+ scriptable.enqueueServiceFault(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ /**
+ * Script the next PublishResponse as a data message carrying {@code sequenceNumber}, whose
+ * value identifies the NotificationMessage it came from.
+ *
+ *
Sending it puts the sequence number in the Server's retransmission queue, evicting the
+ * oldest entry if the queue is full, and the queue's contents become the response's
+ * availableSequenceNumbers. That is the causal link the ordering requirement is about: the
+ * eviction happens when the Server answers a Publish request, so whether a Republish finds a
+ * NotificationMessage depends on whether it was sent before or after Publish resumed.
+ *
+ *
Called immediately after {@link #faultSession()}, when the Server has no parked
+ * PublishRequest left, so this responder waits in the script rather than being applied at once.
+ * The Session FSM waits a whole second in {@code ReactivatingWait} before it even attempts to
+ * reconnect, so the client cannot have sent its next PublishRequest by then.
+ */
+ void enqueueDataChange(long sequenceNumber) {
+ scriptable.enqueue(
+ request -> {
+ retransmissionQueue.send(sequenceNumber);
+
+ return CompletableFuture.completedFuture(
+ scriptable.buildPublishResponse(
+ request,
+ subscriptionId,
+ sequenceNumber,
+ notificationData(sequenceNumber),
+ retransmissionQueue.available(),
+ false));
+ });
+ }
+
+ /**
+ * Answer a Republish the way a Server holding a bounded retransmission queue would: with the
+ * NotificationMessage if it is still in the queue, and Bad_MessageNotAvailable if it is not —
+ * which is also what terminates the Republish loop Part 4 §6.7 describes.
+ */
+ private RepublishResponse respondToRepublish(RepublishRequest request) throws UaException {
+ long sequenceNumber = request.getRetransmitSequenceNumber().longValue();
+
+ requestLog.record(REPUBLISH + sequenceNumber);
+
+ long failure = republishFailure;
+ if (failure != 0L) {
+ throw new UaException(failure);
+ }
+
+ if (!retransmissionQueue.holds(sequenceNumber)) {
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ }
+
+ return scriptable.buildRepublishResponse(
+ request, sequenceNumber, notificationData(sequenceNumber));
+ }
+
+ private ExtensionObject[] notificationData(long sequenceNumber) {
+ var notification =
+ new MonitoredItemNotification(
+ clientHandle, new DataValue(Variant.ofInt32((int) sequenceNumber)));
+
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(new MonitoredItemNotification[] {notification}, null))
+ };
+ }
+
+ /** Wait for the Session to leave {@code Active} and come back to it. */
+ void awaitReactivation() throws Exception {
+ assertTrue(
+ sessionInactive.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the scripted Bad_SessionIdInvalid Publish fault did not take the Session out of Active");
+ assertTrue(
+ sessionReactivated.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the Session never became Active again");
+ }
+
+ List requestLog() {
+ return requestLog.entries();
+ }
+
+ boolean awaitRequestLogEntries(int count) throws Exception {
+ return awaitTrue(() -> requestLog.entries().size() >= count, AWAIT_TIMEOUT_MILLIS);
+ }
+
+ boolean awaitRequestLogContains(String entry) throws Exception {
+ return awaitTrue(() -> requestLog.entries().contains(entry), AWAIT_TIMEOUT_MILLIS);
+ }
+
+ List deliveredValues() {
+ return List.copyOf(deliveredValues);
+ }
+
+ boolean awaitDeliveredValues(List expected) throws Exception {
+ return awaitTrue(() -> deliveredValues().equals(expected), AWAIT_TIMEOUT_MILLIS);
+ }
+
+ int notificationDataLostCount() {
+ return notificationDataLostCount.get();
+ }
+
+ int transferCount() {
+ return scriptable.transferCount.get();
+ }
+
+ /** Polls {@code condition} until it holds or the timeout elapses. */
+ boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(10);
+ }
+
+ return condition.get();
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishResponseOrderingTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishResponseOrderingTest.java
new file mode 100644
index 0000000000..fa2be7ea2a
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishResponseOrderingTest.java
@@ -0,0 +1,557 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Ordering and idempotence of NotificationMessage accounting in {@code
+ * PublishingManager.processPublishResponse}.
+ *
+ *
Part 4 §5.14.1.1 numbers NotificationMessages so a Client can tell a gap from an ordinary
+ * delivery; {@code lastSequenceNumber} is the client's record of the last one accounted for and is
+ * the sole input to gap detection and therefore to Republish recovery. Two independent things have
+ * to hold for that record to stay meaningful:
+ *
+ *
+ *
PublishResponses must be processed in the order the Server sent them. The transport
+ * guarantees ordered delivery: {@code AbstractUascClientTransport.handleResponse}
+ * completes PublishResponse futures on a serial {@code ExecutionQueue} for exactly this
+ * reason. {@code PublishingManager} then hands its completion handler to {@code
+ * whenCompleteAsync(..., executor)}, and that executor is a genuinely multi-threaded pool, so
+ * two handlers dispatched in order can run in either order.
+ *
Processing must be monotonic. A NotificationMessage that has already been accounted for
+ * must not move {@code lastSequenceNumber} backwards or be delivered a second time, whatever
+ * the reason it arrived late.
+ *
+ *
+ *
Either failure produces the same corruption: {@code lastSequenceNumber} regresses, the next
+ * NotificationMessage looks like the far end of a gap, and the client issues a blocking Republish
+ * for a message it already has — repeatedly, once per message, for the rest of the Subscription's
+ * life. The nested classes below drive each cause and assert on the same two observable
+ * consequences: which Republish requests reach the Server, and how many times each
+ * NotificationMessage is delivered to the application.
+ */
+public class PublishResponseOrderingTest {
+
+ /** How long to wait for a condition that must become true. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /** How long to wait for the two Publish completions to be inverted before giving up on it. */
+ private static final long REORDER_TIMEOUT_MILLIS = 3_000;
+
+ /**
+ * The Server sent 2 and then 3, and both were delivered to the client in that order. Whichever
+ * order the client's own executor happens to run the two completion handlers in, nothing was
+ * lost, so nothing may be requested via Republish.
+ */
+ @Nested
+ class ReorderedInsideTheClient {
+
+ @Test
+ void reorderedPublishResponsesDoNotTriggerRepublish() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.driveReorderedResponses();
+
+ assertEquals(
+ List.of(),
+ List.copyOf(fixture.republishRequests),
+ "no NotificationMessage was lost — the Server sent 2, 3, 4 and the transport delivered"
+ + " them in that order — so the client must not ask the Server to retransmit"
+ + " anything");
+ }
+ }
+
+ /**
+ * The other half of the same corruption. Processing the later response first republishes the
+ * earlier one and delivers it, and processing the earlier one afterwards delivers it again: the
+ * application sees the same NotificationMessage twice.
+ */
+ @Test
+ void reorderedPublishResponsesAreEachDeliveredExactlyOnce() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.driveReorderedResponses();
+
+ assertEquals(
+ List.of(1, 2, 3, 4),
+ List.copyOf(fixture.deliveredValues),
+ "each NotificationMessage must be delivered to the application exactly once, in the"
+ + " order the Server sent it");
+ }
+ }
+ }
+
+ /**
+ * The same stale input, produced without touching the client's threading: the Server is scripted
+ * to send NotificationMessage 3 before 2. Sequence 3 legitimately looks like a gap, so Republish(
+ * 2) is correct and expected; what must not happen is that the subsequent, already-accounted-for
+ * sequence 2 rolls {@code lastSequenceNumber} back to 2 and turns the perfectly ordinary sequence
+ * 4 into another gap.
+ *
+ *
This pins the monotonicity requirement independently of the reordering above: preserving the
+ * order of the completion handlers fixes the cause, but does not by itself make processing safe
+ * against a message that has already been accounted for.
+ */
+ @Nested
+ class StaleNotificationMessage {
+
+ @Test
+ void alreadyAccountedNotificationMessageDoesNotCauseAFurtherRepublish() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.driveStaleResponse();
+
+ assertEquals(
+ List.of(2L),
+ List.copyOf(fixture.republishRequests),
+ "sequence 2 was missing when 3 arrived and is correctly recovered; sequence 3 was"
+ + " received and accounted for, so the arrival of the stale sequence 2 must not"
+ + " make 3 look missing again");
+ }
+ }
+
+ @Test
+ void alreadyAccountedNotificationMessageIsNotDeliveredAgain() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.driveStaleResponse();
+
+ assertEquals(
+ List.of(1, 2, 3, 4),
+ List.copyOf(fixture.deliveredValues),
+ "sequence 2 was already delivered when it was recovered via Republish; delivering the"
+ + " stale copy as well hands the application a duplicate");
+ }
+ }
+ }
+
+ /**
+ * Positive control for every "was Republish called for X?" assertion above: a genuine gap is
+ * recorded by the fixture's Republish responder. If this fails, the fixture is broken rather than
+ * the sequence accounting.
+ */
+ @Test
+ void genuineGapIsRecordedByTheRepublishResponder() throws Exception {
+ try (var fixture = new Fixture()) {
+ // Sequence 2 is lost; sequence 3 reveals the gap.
+ fixture.scriptable.enqueueDataChange(
+ fixture.subscriptionId, 3, fixture.notifications(3), uint(2), uint(3));
+
+ assertTrue(
+ fixture.awaitTrue(() -> fixture.deliveredValues.contains(3)),
+ "sequence 3 was never delivered");
+
+ assertEquals(
+ List.of(2L),
+ List.copyOf(fixture.republishRequests),
+ "the missing NotificationMessage 2 must be requested via Republish");
+ }
+ }
+
+ /**
+ * Control for the fixture's {@link ReorderingExecutor}: it must actually invert two tasks that a
+ * single ordered source dispatched in order. Without this, {@link ReorderedInsideTheClient} could
+ * pass simply because no reordering ever took place.
+ */
+ @Test
+ void reorderingExecutorInvertsTwoDispatchesFromASingleOrderedSource() throws Exception {
+ var executor = new ReorderingExecutor();
+
+ try {
+ List order = Collections.synchronizedList(new ArrayList<>());
+ var bothRan = new CountDownLatch(2);
+
+ executor.armReorder();
+
+ // The shape of the transport's serial publish-response queue: one task dispatching, in order,
+ // the dependent stages of two futures it completes.
+ executor.execute(
+ () -> {
+ executor.execute(
+ () -> {
+ order.add("first");
+ bothRan.countDown();
+ });
+ executor.execute(
+ () -> {
+ order.add("second");
+ bothRan.countDown();
+ });
+ });
+
+ assertTrue(
+ executor.awaitReorder(REORDER_TIMEOUT_MILLIS),
+ "the executor did not invert the two dispatches");
+ assertTrue(bothRan.await(5, TimeUnit.SECONDS), "not every dispatched task ran");
+
+ assertEquals(
+ List.of("second", "first"),
+ List.copyOf(order),
+ "the task dispatched first must run after the task dispatched second");
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * A running Server whose Publish and Republish responses are scripted, plus a client driven by a
+ * {@link ReorderingExecutor}, a Subscription with one client-side MonitoredItem, and a listener
+ * recording every value delivered.
+ *
+ *
Construction leaves the client in a known state: NotificationMessage 1 has been received and
+ * delivered, so {@code lastSequenceNumber} is 1, and the Publish pipeline is full again.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ /** Every {@code retransmitSequenceNumber} the client has asked the Server to Republish. */
+ private final List republishRequests = Collections.synchronizedList(new ArrayList<>());
+
+ /** Every value handed to {@code onDataReceived}, in delivery order. */
+ private final List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ private final ReorderingExecutor executor = new ReorderingExecutor();
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final ScriptableSubscriptionServiceSet scriptable;
+ private final OpcUaSubscription subscription;
+ private final UInteger subscriptionId;
+ private final UInteger clientHandle;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ transportConfig -> transportConfig.setExecutor(executor),
+ cfg ->
+ cfg
+ // Long request timeout so parked Publish requests do not time out.
+ .setRequestTimeout(uint(60_000))
+ // No Session keep-alive traffic: the only responses in flight during the test
+ // are the scripted ones.
+ .setKeepAliveInterval(uint(60_000)));
+ client.connect();
+
+ subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription subscription,
+ List items,
+ List values) {
+
+ values.forEach(value -> deliveredValues.add((Integer) value.getValue().getValue()));
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // Client-side only: addMonitoredItem() assigns the ClientHandle the notification fan-out
+ // looks values up by, which is all a scripted notification needs.
+ var item = OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+ clientHandle = item.getClientHandle().orElseThrow();
+
+ scriptable.setRepublishResponder(
+ request -> {
+ long sequenceNumber = request.getRetransmitSequenceNumber().longValue();
+ republishRequests.add(sequenceNumber);
+
+ return scriptable.buildRepublishResponse(
+ request, sequenceNumber, encodedNotifications((int) sequenceNumber));
+ });
+
+ establishFirstNotificationMessage();
+ }
+
+ /**
+ * Drive {@code lastSequenceNumber} to 1 and wait until the Publish pipeline has refilled, so
+ * that both of the responses the scenarios script below land on requests that are already
+ * parked at the Server.
+ */
+ private void establishFirstNotificationMessage() throws Exception {
+ scriptable.enqueueDataChange(subscriptionId, 1, notifications(1), uint(1));
+
+ assertTrue(
+ awaitTrue(() -> deliveredValues.contains(1)), "the first NotificationMessage was lost");
+ assertTrue(
+ awaitTrue(() -> scriptable.getParkedRequestCount() >= 2),
+ "the client did not refill its Publish pipeline");
+ }
+
+ /**
+ * NotificationMessages 2 and 3 are sent, in order, onto the two parked Publish requests; the
+ * client's executor is armed to run their completion handlers in the opposite order. Sequence 4
+ * follows once both have been processed, and its delivery is the barrier that proves everything
+ * before it has been delivered too: the processing queue and the delivery queue are both FIFO.
+ */
+ void driveReorderedResponses() throws Exception {
+ int publishRequestsBefore = scriptable.getPublishRequestCount();
+
+ executor.armReorder();
+
+ scriptable.enqueueDataChange(subscriptionId, 2, notifications(2), uint(2));
+ scriptable.enqueueDataChange(subscriptionId, 3, notifications(3), uint(3));
+
+ // Bounded: an implementation that does not dispatch the two completions through this executor
+ // simply proceeds in order, which is the behavior being asserted anyway.
+ executor.awaitReorder(REORDER_TIMEOUT_MILLIS);
+
+ awaitResponsesProcessed(publishRequestsBefore, 2);
+
+ scriptable.enqueueDataChange(subscriptionId, 4, notifications(4), uint(4));
+
+ assertTrue(awaitTrue(() -> deliveredValues.contains(4)), "sequence 4 was never delivered");
+ }
+
+ /**
+ * The Server sends NotificationMessage 3 before 2, then 4. Sequence 3 is a genuine gap and 2 is
+ * recovered from it; the copy of 2 that arrives afterwards has already been accounted for.
+ */
+ void driveStaleResponse() throws Exception {
+ int publishRequestsBefore = scriptable.getPublishRequestCount();
+
+ scriptable.enqueueDataChange(subscriptionId, 3, notifications(3), uint(2), uint(3));
+
+ assertTrue(awaitTrue(() -> deliveredValues.contains(3)), "sequence 3 was never delivered");
+
+ scriptable.enqueueDataChange(subscriptionId, 2, notifications(2), uint(2));
+
+ awaitResponsesProcessed(publishRequestsBefore, 2);
+
+ scriptable.enqueueDataChange(subscriptionId, 4, notifications(4), uint(4));
+
+ assertTrue(awaitTrue(() -> deliveredValues.contains(4)), "sequence 4 was never delivered");
+ }
+
+ /**
+ * Wait until {@code count} PublishResponses have been fully processed and delivered.
+ *
+ *
The client holds a fixed number of Publish requests in flight and only replaces one once
+ * the notifications it carried have been delivered — that is the SDK's backpressure mechanism —
+ * so {@code count} further Publish requests arriving at the Server is exactly that signal.
+ *
+ * @param publishRequestsBefore the Publish request count before the responses were scripted.
+ * @param count the number of PublishResponses to wait for.
+ */
+ private void awaitResponsesProcessed(int publishRequestsBefore, int count) throws Exception {
+ assertTrue(
+ awaitTrue(() -> scriptable.getPublishRequestCount() >= publishRequestsBefore + count),
+ "the client did not finish processing " + count + " PublishResponses");
+ }
+
+ /** A DataChangeNotification carrying {@code value} for this Fixture's MonitoredItem. */
+ List notifications(int value) {
+ return List.of(
+ new MonitoredItemNotification(clientHandle, new DataValue(Variant.ofInt32(value))));
+ }
+
+ private ExtensionObject[] encodedNotifications(int value) {
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(
+ notifications(value).toArray(MonitoredItemNotification[]::new), null))
+ };
+ }
+
+ /** Polls {@code condition} until it holds or the (generous) timeout elapses. */
+ boolean awaitTrue(ThrowingBooleanSupplier condition) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MILLIS);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return condition.get();
+ }
+
+ @Override
+ public void close() throws Exception {
+ executor.releaseHeld();
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ try {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ /**
+ * The client's {@link ExecutorService}, with one test affordance: once {@link #armReorder()} has
+ * been called, the next task submitted from inside a task that was itself submitted from
+ * outside this executor is held; the following such task is run to completion; only then is the
+ * held task released.
+ *
+ *
That "one level in" position is exactly where the PublishResponse completion handlers land.
+ * {@code AbstractUascClientTransport} completes PublishResponse futures from a serial {@code
+ * ExecutionQueue} whose drain task runs on this executor (submitted from a Netty I/O thread, so
+ * it is at the outer level), and {@code PublishingManager}'s {@code whenCompleteAsync(...,
+ * executor)} dispatches the handler back onto this executor from there. Inverting those two
+ * dispatches is a scheduling decision an unordered pool is entitled to make; the pool the client
+ * uses by default — {@code Stack.sharedExecutor()}, whose shape this class copies — makes it by
+ * luck rather than on request.
+ *
+ *
The hold is bounded by {@link #awaitReorder(long)} and by {@link #releaseHeld()}, so an
+ * implementation that never dispatches a second such task is not deadlocked by the fixture.
+ */
+ private static final class ReorderingExecutor extends ThreadPoolExecutor {
+
+ /** Nesting level of the task the current thread is running; absent when it is not one. */
+ private static final ThreadLocal DEPTH = new ThreadLocal<>();
+
+ private enum State {
+ PASS_THROUGH,
+ ARMED,
+ HOLDING
+ }
+
+ private final Object lock = new Object();
+ private final CountDownLatch reordered = new CountDownLatch(1);
+
+ private State state = State.PASS_THROUGH;
+ private Runnable held;
+
+ ReorderingExecutor() {
+ // The shape of Stack.sharedExecutor(): an unbounded cached pool, so nothing but this class's
+ // own bookkeeping orders the tasks that run on it.
+ super(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<>());
+ }
+
+ void armReorder() {
+ synchronized (lock) {
+ state = State.ARMED;
+ }
+ }
+
+ /**
+ * Wait for the inversion to complete, then release the held task if a second one never arrived.
+ *
+ * @param timeoutMillis how long to wait for the inversion.
+ * @return {@code true} if two tasks were actually inverted.
+ * @throws InterruptedException if interrupted while waiting.
+ */
+ boolean awaitReorder(long timeoutMillis) throws InterruptedException {
+ boolean applied = reordered.await(timeoutMillis, TimeUnit.MILLISECONDS);
+
+ releaseHeld();
+
+ return applied;
+ }
+
+ /** Release the held task, if any, without waiting for a second one. */
+ void releaseHeld() {
+ Runnable toRelease;
+
+ synchronized (lock) {
+ toRelease = held;
+ held = null;
+ state = State.PASS_THROUGH;
+ }
+
+ if (toRelease != null) {
+ super.execute(toRelease);
+ }
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ Integer parentDepth = DEPTH.get();
+ int depth = parentDepth == null ? 0 : parentDepth + 1;
+
+ Runnable task =
+ () -> {
+ DEPTH.set(depth);
+ try {
+ command.run();
+ } finally {
+ DEPTH.remove();
+ }
+ };
+
+ if (depth == 1) {
+ synchronized (lock) {
+ if (state == State.ARMED) {
+ held = task;
+ state = State.HOLDING;
+ return;
+ } else if (state == State.HOLDING) {
+ Runnable first = held;
+ held = null;
+ state = State.PASS_THROUGH;
+
+ super.execute(
+ () -> {
+ try {
+ task.run();
+ } finally {
+ super.execute(first);
+ reordered.countDown();
+ }
+ });
+ return;
+ }
+ }
+ }
+
+ super.execute(task);
+ }
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishScriptHarnessTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishScriptHarnessTest.java
new file mode 100644
index 0000000000..e54cba82c8
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishScriptHarnessTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises {@link ScriptableSubscriptionServiceSet} end-to-end against the real client stack.
+ *
+ *
This is a "does the harness work" smoke test, not a bug reproduction. It proves the seam can
+ * (a) deliver a scripted keep-alive to the client's {@code SubscriptionListener} and (b) capture
+ * the acknowledgements the client sends back after processing a scripted notification. The
+ * deterministic bug-reproduction tests built on this harness are enumerated in {@code
+ * docs/plans/publishing-manager-reliability-repair.md}.
+ */
+public class PublishScriptHarnessTest {
+
+ @Test
+ void scriptedKeepAliveIsDeliveredAndNotificationIsAcknowledged() throws Exception {
+ TestServer testServer = TestServer.create();
+ OpcUaServer server = testServer.getServer();
+
+ var scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ // Long request timeout so parked Publish requests do not time out during the test.
+ OpcUaClient client = TestClient.create(server, cfg -> cfg.setRequestTimeout(uint(60_000)));
+ client.connect();
+
+ try {
+ var keepAliveLatch = new CountDownLatch(1);
+
+ var subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onKeepAliveReceived(OpcUaSubscription subscription) {
+ keepAliveLatch.countDown();
+ }
+ });
+ subscription.create();
+
+ UInteger subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // (a) A scripted keep-alive (sequence 1) reaches the listener.
+ scriptable.enqueueKeepAlive(subscriptionId, 1);
+ assertTrue(
+ keepAliveLatch.await(5, TimeUnit.SECONDS),
+ "keep-alive was not delivered to the listener");
+
+ // (b) A scripted notification (sequence 2) advertising available sequence number 2 causes the
+ // client to acknowledge sequence 2 on a subsequent Publish request.
+ scriptable.enqueueDataChange(subscriptionId, 2, List.of(), uint(2));
+
+ boolean acknowledged =
+ awaitTrue(
+ () ->
+ scriptable.getReceivedAcknowledgements().stream()
+ .anyMatch(
+ ack ->
+ ack.getSubscriptionId().equals(subscriptionId)
+ && ack.getSequenceNumber().longValue() == 2L),
+ 5000);
+
+ assertTrue(acknowledged, "client did not acknowledge sequence 2");
+ } finally {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(2, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(2, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ private static boolean awaitTrue(BooleanSupplierThrowing condition, long timeoutMillis)
+ throws Exception {
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface BooleanSupplierThrowing {
+ boolean get() throws Exception;
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRecoveryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRecoveryTest.java
new file mode 100644
index 0000000000..46d9e08bcc
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRecoveryTest.java
@@ -0,0 +1,477 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet.RepublishResponder;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Sequence-number accounting in {@code PublishingManager.processPublishResponse}.
+ *
+ *
Part 4 §5.14.1.1 defines the NotificationMessage sequence number: "The value 0 is never used
+ * for the sequence number. The first NotificationMessage sent on a Subscription has a sequence
+ * number of 1. If the sequence number rolls over, it rolls over to 1." The same clause defines a
+ * keep-alive as a message that "does not contain any Notifications and ... contains the sequence
+ * number of the next NotificationMessage that is to be sent" — so a keep-alive carrying sequence
+ * {@code n} is positive evidence that {@code n} has not been sent yet, and is not
+ * evidence that {@code n - 1} was received.
+ *
+ *
{@code lastSequenceNumber} is the client's record of the last NotificationMessage actually
+ * accounted for; it is the sole input to gap detection and therefore to Republish recovery. Every
+ * test here drives real PublishResponses through the real client stack via {@link
+ * ScriptableSubscriptionServiceSet} and observes the Republish requests, {@code
+ * onNotificationDataLost} callbacks, and SubscriptionAcknowledgements that result.
+ */
+public class PublishSequenceRecoveryTest {
+
+ /** The largest legal sequence number; the successor of this value is 1, never 0 (§5.14.1.1). */
+ private static final long MAX_SEQUENCE_NUMBER = 0xFFFF_FFFFL;
+
+ private TestServer testServer;
+ private OpcUaServer server;
+ private OpcUaClient client;
+ private ScriptableSubscriptionServiceSet scriptable;
+ private OpcUaSubscription subscription;
+ private UInteger subscriptionId;
+
+ /** Every {@code retransmitSequenceNumber} the client has asked the Server to Republish. */
+ private final List republishRequests = Collections.synchronizedList(new ArrayList<>());
+
+ private final AtomicInteger keepAliveCount = new AtomicInteger();
+ private final AtomicInteger dataReceivedCount = new AtomicInteger();
+ private final AtomicInteger notificationDataLostCount = new AtomicInteger();
+
+ @BeforeEach
+ void startClientAndServerAndCreateSubscription() throws Exception {
+ testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ // Long request timeout so parked Publish requests do not time out during the test.
+ client = TestClient.create(server, cfg -> cfg.setRequestTimeout(uint(60_000)));
+ client.connect();
+
+ subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onKeepAliveReceived(OpcUaSubscription subscription) {
+ keepAliveCount.incrementAndGet();
+ }
+
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription subscription,
+ List items,
+ List values) {
+ dataReceivedCount.incrementAndGet();
+ }
+
+ @Override
+ public void onNotificationDataLost(OpcUaSubscription subscription) {
+ notificationDataLostCount.incrementAndGet();
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+ }
+
+ @AfterEach
+ void stopClientAndServer() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * Positive control for every "was Republish called for X?" assertion below: an ordinary gap
+ * between two data messages is detected today, proving the recording Republish responder
+ * and the Republish observation are wired up. If this test fails, the fixture is broken rather
+ * than the sequence accounting.
+ */
+ @Test
+ void gapBetweenDataMessagesIsRepublished() throws Exception {
+ scriptable.setRepublishResponder(recordingRepublishResponder());
+
+ // Sequence 1 is received normally, sequence 2 is lost, sequence 3 arrives.
+ scriptable.enqueueDataChange(subscriptionId, 1, dataNotifications(), uint(1));
+ assertTrue(awaitAcknowledgement(1), "sequence 1 was never acknowledged");
+
+ scriptable.enqueueDataChange(subscriptionId, 3, dataNotifications(), uint(1), uint(3));
+ assertTrue(awaitAtLeast(dataReceivedCount, 2), "sequence 3 was never delivered");
+
+ assertEquals(
+ List.of(2L),
+ List.copyOf(republishRequests),
+ "the missing NotificationMessage 2 must be requested via Republish");
+ }
+
+ /**
+ * Finding 1: an initial keep-alive carrying sequence 1 is recorded as if NotificationMessage 1
+ * had been received.
+ *
+ *
Part 4 §5.14.1.1: a keep-alive carries "the sequence number of the next NotificationMessage
+ * that is to be sent", so an initial keep-alive with sequence 1 means the first
+ * NotificationMessage has not yet been sent. Recording it as received means the loss of
+ * the real NotificationMessage 1 is never detected, and the client goes on to acknowledge it —
+ * which purges it from the Server's retransmission queue and makes it unrecoverable.
+ *
+ *
These tests deliberately re-create the interop scenario from #1401 (keep-alive 1 followed by
+ * first data 2). That commit added the {@code receivedSequenceNumber == 1} disjunct to suppress a
+ * Republish(1) it considered doomed; per §5.14.1.1 NotificationMessage 1 is genuinely missing in
+ * that trace and Republish(1) is the correct behavior.
+ */
+ @Nested
+ class InitialKeepAlive {
+
+ /**
+ * Keep-alive 1, then data 2: NotificationMessage 1 was sent and lost, so the client must try to
+ * recover it.
+ */
+ @Test
+ void notificationMessageLostAfterAnInitialKeepAliveIsRepublished() throws Exception {
+ scriptable.setRepublishResponder(recordingRepublishResponder());
+
+ scriptable.enqueueKeepAlive(subscriptionId, 1);
+ assertTrue(awaitAtLeast(keepAliveCount, 1), "the initial keep-alive was never delivered");
+
+ // Sequence 1 was sent by the Server (it is still available for Republish) but never reached
+ // the client; sequence 2 is the next message to arrive.
+ scriptable.enqueueDataChange(subscriptionId, 2, dataNotifications(), uint(1), uint(2));
+ assertTrue(awaitAtLeast(dataReceivedCount, 1), "sequence 2 was never delivered");
+
+ assertEquals(
+ List.of(1L),
+ List.copyOf(republishRequests),
+ "NotificationMessage 1 was lost and must be requested via Republish; an initial"
+ + " keep-alive carrying sequence 1 is not evidence that message 1 was received");
+ }
+
+ /**
+ * The aggravating half of finding 1: because the acknowledgement set is refilled verbatim from
+ * {@code availableSequenceNumbers}, the client acknowledges sequence 1 — a NotificationMessage
+ * it never received and never recovered. The Server then purges it from the retransmission
+ * queue and the data is gone for good.
+ *
+ *
The Republish attempt is scripted to fail with {@code Bad_MessageNotAvailable}, so nothing
+ * in this trace ever recovers sequence 1: acknowledging it is unconditionally wrong.
+ */
+ @Test
+ void unreceivedNotificationMessageIsNotAcknowledged() throws Exception {
+ scriptable.setRepublishResponder(recordingRepublishResponder());
+
+ scriptable.enqueueKeepAlive(subscriptionId, 1);
+ assertTrue(awaitAtLeast(keepAliveCount, 1), "the initial keep-alive was never delivered");
+
+ scriptable.enqueueDataChange(subscriptionId, 2, dataNotifications(), uint(1), uint(2));
+
+ // Sequences 1 and 2 are both advertised as available, so both are acknowledged in the same
+ // PublishRequest: observing the acknowledgement of 2 is enough to decide about 1.
+ assertTrue(awaitAcknowledgement(2), "sequence 2 was never acknowledged");
+
+ assertFalse(
+ acknowledged(1),
+ "the client acknowledged NotificationMessage 1, which it never received and never"
+ + " recovered; the Server will purge it from the retransmission queue");
+ }
+ }
+
+ /**
+ * Finding 2: a gap that ends at a keep-alive advances {@code lastSequenceNumber} to the first
+ * missing sequence instead of to the last one, so the next keep-alive re-detects the tail of the
+ * same gap.
+ *
+ *
The gap loop bounds are correct, but the recovered range is not recorded: {@code
+ * lastSequenceNumber = expectedSequenceNumber} sets it to the first missing sequence, and
+ * the trailing {@code == 1 || !isKeepAlive} guard declines to correct it because this is a
+ * keep-alive. Each subsequent keep-alive therefore repeats the recovery for the remaining tail.
+ * Meanwhile the client has already acknowledged the whole range, so the Server has purged it and
+ * the repeat cycles fail — producing spurious {@code onNotificationDataLost} callbacks (and, if
+ * the acknowledgements have not landed yet, duplicate delivery to the application).
+ */
+ @Nested
+ class GapEndingInAKeepAlive {
+
+ /** The number of keep-alives carrying sequence 10 that the Server sends after the gap. */
+ private static final int KEEP_ALIVE_ROUNDS = 4;
+
+ private final List expectedAttempts = List.of(6L, 7L, 8L, 9L);
+
+ /**
+ * Every missing NotificationMessage must be requested exactly once. Repeating the request is
+ * not merely wasteful: the client has already acknowledged 6-9, so the Server no longer holds
+ * them and every repeat is a blocking round-trip that can only fail.
+ */
+ @Test
+ void eachMissingNotificationMessageIsRepublishedExactlyOnce() throws Exception {
+ driveGapEndingInKeepAlives();
+
+ assertEquals(
+ expectedAttempts,
+ List.copyOf(republishRequests),
+ "each NotificationMessage missing before the keep-alive must be requested exactly once");
+ }
+
+ /**
+ * {@code onNotificationDataLost} is the SDK's "your data is gone" signal. Recovering 6-9
+ * successfully and then re-requesting them from a Server that has (correctly) purged them
+ * raises that alarm for data the application already received.
+ */
+ @Test
+ void fullyRecoveredGapDoesNotReportNotificationDataLost() throws Exception {
+ driveGapEndingInKeepAlives();
+
+ assertEquals(
+ 0,
+ notificationDataLostCount.get(),
+ "every missing NotificationMessage was recovered, so no data was lost");
+ }
+
+ /**
+ * Drives {@code lastSequenceNumber} to 5, then delivers {@link #KEEP_ALIVE_ROUNDS} keep-alives
+ * carrying sequence 10 with 6-9 advertised as available.
+ *
+ *
The Republish responder models the Server's retransmission queue: each sequence can be
+ * republished once, after which it is gone (the client's own acknowledgements purge it).
+ */
+ private void driveGapEndingInKeepAlives() throws Exception {
+ // Establish lastSequenceNumber = 5 with five in-order data messages, staying in lockstep with
+ // the responder queue by waiting for each acknowledgement before enqueueing the next.
+ for (long sequenceNumber = 1; sequenceNumber <= 5; sequenceNumber++) {
+ scriptable.enqueueDataChange(
+ subscriptionId, sequenceNumber, dataNotifications(), uint(sequenceNumber));
+
+ assertTrue(
+ awaitAcknowledgement(sequenceNumber),
+ "sequence " + sequenceNumber + " was never acknowledged");
+ }
+
+ assertEquals(
+ List.of(),
+ List.copyOf(republishRequests),
+ "control: five in-order data messages must not trigger any Republish");
+
+ scriptable.setRepublishResponder(retransmissionQueueResponder(Set.of(6L, 7L, 8L, 9L)));
+
+ // Sequences 6-9 were sent and lost; the keep-alive announces that 10 is next.
+ for (int round = 1; round <= KEEP_ALIVE_ROUNDS; round++) {
+ scriptable.enqueueKeepAlive(subscriptionId, 10, uint(6), uint(7), uint(8), uint(9));
+
+ assertTrue(
+ awaitAtLeast(keepAliveCount, round),
+ "keep-alive round " + round + " was not delivered");
+ }
+ }
+ }
+
+ /**
+ * Finding 12: the sequence arithmetic is plain unwrapped {@code long} math, so it breaks at the
+ * UInt32 rollover boundary defined by Part 4 §5.14.1.1 ("If the sequence number rolls over, it
+ * rolls over to 1").
+ *
+ *
White-box seeding. There is no wire path to a near-maximum {@code
+ * lastSequenceNumber}: it only ever advances one message at a time, and any forward jump large
+ * enough to reach the boundary would spin the (unbounded) Republish loop billions of times. These
+ * tests therefore seed {@code lastSequenceNumber} reflectively and then drive real
+ * PublishResponses through the real client stack, so everything after the seed — gap detection,
+ * Republish recovery, delivery — is the production code path.
+ */
+ @Nested
+ class SequenceNumberRollover {
+
+ /**
+ * The message before the rollover is lost. {@code expected = last + 1} is the last legal
+ * sequence number, the wrapped sequence 1 compares as "behind" it, and the gap is silently
+ * swallowed.
+ */
+ @Test
+ void notificationMessageLostAtTheRolloverBoundaryIsRepublished() throws Exception {
+ scriptable.setRepublishResponder(recordingRepublishResponder());
+ seedLastSequenceNumber(MAX_SEQUENCE_NUMBER - 1);
+
+ // MAX_SEQUENCE_NUMBER was sent and lost; the sequence rolls over to 1.
+ scriptable.enqueueDataChange(
+ subscriptionId, 1, dataNotifications(), uint(MAX_SEQUENCE_NUMBER), uint(1));
+ assertTrue(awaitAtLeast(dataReceivedCount, 1), "the wrapped sequence 1 was never delivered");
+
+ assertEquals(
+ List.of(MAX_SEQUENCE_NUMBER),
+ List.copyOf(republishRequests),
+ "the NotificationMessage lost at the rollover boundary must be requested via Republish");
+ }
+
+ /**
+ * A keep-alive arriving immediately after the rollover. {@code expected = last + 1} is
+ * 4294967296 — a value no NotificationMessage can carry — so no received sequence number can
+ * ever again compare as "ahead" and gap detection is dead from here on.
+ */
+ @Test
+ void keepAliveAfterRolloverDetectsTheMissingNotificationMessage() throws Exception {
+ scriptable.setRepublishResponder(recordingRepublishResponder());
+ seedLastSequenceNumber(MAX_SEQUENCE_NUMBER);
+
+ // The wrapped sequence 1 was sent and lost; the keep-alive announces that 2 is next.
+ scriptable.enqueueKeepAlive(subscriptionId, 2, uint(1));
+ assertTrue(awaitAtLeast(keepAliveCount, 1), "the keep-alive was never delivered");
+
+ assertEquals(
+ List.of(1L),
+ List.copyOf(republishRequests),
+ "the wrapped NotificationMessage 1 was lost and must be requested via Republish");
+ }
+ }
+
+ // region fixture helpers
+
+ /** Records the requested sequence number and reports that the Server no longer holds it. */
+ private RepublishResponder recordingRepublishResponder() {
+ return request -> {
+ republishRequests.add(request.getRetransmitSequenceNumber().longValue());
+
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ };
+ }
+
+ /**
+ * Models a Server retransmission queue: each sequence in {@code held} can be republished once,
+ * after which it is gone — the same effect the client's own acknowledgements have.
+ */
+ private RepublishResponder retransmissionQueueResponder(Set held) {
+ Set remaining = Collections.synchronizedSet(new HashSet<>(held));
+
+ return request -> {
+ long sequenceNumber = request.getRetransmitSequenceNumber().longValue();
+ republishRequests.add(sequenceNumber);
+
+ if (remaining.remove(sequenceNumber)) {
+ return scriptable.buildRepublishResponse(
+ request, sequenceNumber, encodedDataNotifications());
+ } else {
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ }
+ };
+ }
+
+ /**
+ * A single MonitoredItemNotification. The ClientHandle is not registered with the Subscription,
+ * which is irrelevant here: what matters is that the NotificationMessage carries notification
+ * data, making it a data message rather than a keep-alive, and that delivery reaches {@code
+ * onDataReceived}.
+ */
+ private static List dataNotifications() {
+ return List.of(new MonitoredItemNotification(uint(1), new DataValue(new Variant(0))));
+ }
+
+ private ExtensionObject[] encodedDataNotifications() {
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(
+ dataNotifications().toArray(MonitoredItemNotification[]::new), null))
+ };
+ }
+
+ /**
+ * Seeds the {@code lastSequenceNumber} the {@code PublishingManager} tracks for this
+ * Subscription. Called before any PublishResponse has been processed, so nothing races with it.
+ */
+ private void seedLastSequenceNumber(long sequenceNumber) throws Exception {
+ PublishingManager publishingManager = client.getPublishingManager();
+
+ Field subscriptionDetailsField =
+ PublishingManager.class.getDeclaredField("subscriptionDetails");
+ subscriptionDetailsField.setAccessible(true);
+
+ Map, ?> subscriptionDetails = (Map, ?>) subscriptionDetailsField.get(publishingManager);
+ Object details = subscriptionDetails.get(subscriptionId);
+ assertNotNull(details, "the Subscription is not registered with the PublishingManager");
+
+ Field lastSequenceNumberField = details.getClass().getDeclaredField("lastSequenceNumber");
+ lastSequenceNumberField.setAccessible(true);
+ lastSequenceNumberField.setLong(details, sequenceNumber);
+ }
+
+ private boolean acknowledged(long sequenceNumber) {
+ return scriptable.getReceivedAcknowledgements().stream()
+ .anyMatch(
+ ack ->
+ ack.getSubscriptionId().equals(subscriptionId)
+ && ack.getSequenceNumber().longValue() == sequenceNumber);
+ }
+
+ private boolean awaitAcknowledgement(long sequenceNumber) throws Exception {
+ return awaitTrue(() -> acknowledged(sequenceNumber));
+ }
+
+ private static boolean awaitAtLeast(AtomicInteger counter, int count) throws Exception {
+ return awaitTrue(() -> counter.get() >= count);
+ }
+
+ /** Polls {@code condition} until it holds or the (generous) timeout elapses. */
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRegressionTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRegressionTest.java
new file mode 100644
index 0000000000..2cc221f820
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSequenceRegressionTest.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What {@code PublishingManager.processPublishResponse} does with a NotificationMessage whose
+ * sequence number is behind the one it expects next.
+ *
+ *
Two different things can produce such a message, and they need opposite handling:
+ *
+ *
+ *
A duplicate: a retransmission, or a copy of a message the client already recovered
+ * via Republish. Accounting must only ever move forwards, so it has to be discarded — see
+ * {@link PublishSequenceRecoveryTest} for the accounting it would otherwise corrupt.
+ *
The Server's numbering having regressed: Part 4 §5.14.1.1 numbers
+ * NotificationMessages per Subscription starting at 1, so a Server that restarts and restores
+ * a Subscription — or that otherwise renumbers one — begins sending sequence numbers far
+ * below the ones the client has already accounted for. Discarding those never ends: the
+ * numbering does not "catch up" to where it was, so every NotificationMessage for the rest of
+ * the Subscription's life is dropped without a trace.
+ *
+ *
+ *
The two are told apart by distance. A message no further behind than the larger of the
+ * retransmission queue the Server is advertising and {@code
+ * PublishingManager.DEFAULT_MAX_RECOVERABLE_GAP} ({@value #DUPLICATE_WINDOW}) is within
+ * retransmission range and is treated as a duplicate; anything further behind than that cannot be a
+ * duplicate of anything the Server still holds, and is delivered and resynchronized to.
+ *
+ *
This is the boundary 93bedb32c introduced. Before it, everything behind the expected
+ * sequence number was discarded, which for a genuine renumbering meant silently and indefinitely.
+ * The tests below drive real PublishResponses through the real client stack via {@link
+ * ScriptableSubscriptionServiceSet} and observe which NotificationMessages reach the application.
+ */
+public class PublishSequenceRegressionTest {
+
+ /**
+ * {@code PublishingManager.DEFAULT_MAX_RECOVERABLE_GAP}: the floor on how far behind the expected
+ * sequence number a NotificationMessage may be and still be taken for a duplicate.
+ */
+ private static final int DUPLICATE_WINDOW = 64;
+
+ /** How long to wait for a NotificationMessage that must be delivered. */
+ private static final long DELIVERY_WINDOW_MILLIS = 5_000;
+
+ /**
+ * Long enough that nothing times out on its own, so a parked Publish request stays parked and any
+ * failure observed below is scripted rather than incidental.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ private TestServer testServer;
+ private OpcUaServer server;
+ private OpcUaClient client;
+ private ScriptableSubscriptionServiceSet scriptable;
+ private OpcUaSubscription subscription;
+ private UInteger subscriptionId;
+ private UInteger clientHandle;
+
+ /**
+ * The Int32 payload of every DataChangeNotification the application has been handed, in delivery
+ * order. Each scripted NotificationMessage carries its own sequence number as its value, so this
+ * says exactly which messages were delivered and in which order — a count alone could not
+ * distinguish "the regressed message was delivered" from "a later one was".
+ */
+ private final List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ /** Every {@code retransmitSequenceNumber} the client has asked the Server to Republish. */
+ private final List republishRequests = Collections.synchronizedList(new ArrayList<>());
+
+ private final AtomicInteger notificationDataLostCount = new AtomicInteger();
+
+ @BeforeEach
+ void startClientAndServerAndCreateSubscription() throws Exception {
+ testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new ScriptableSubscriptionServiceSet(server);
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ // Records the requested sequence number and reports that the Server no longer holds it: no test
+ // here expects a Republish, so any request one of them provokes is recorded and asserted about.
+ scriptable.setRepublishResponder(
+ request -> {
+ republishRequests.add(request.getRetransmitSequenceNumber().longValue());
+
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ });
+
+ server.startup().get();
+
+ client = TestClient.create(server, cfg -> cfg.setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS)));
+ client.connect();
+
+ subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription subscription,
+ List items,
+ List values) {
+
+ values.forEach(value -> deliveredValues.add((Integer) value.value().value()));
+ }
+
+ @Override
+ public void onNotificationDataLost(OpcUaSubscription subscription) {
+ notificationDataLostCount.incrementAndGet();
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // Client-side only: addMonitoredItem assigns the ClientHandle the notification fan-out looks
+ // notifications up by, and no Server-side item takes part in delivering a scripted one.
+ var item = OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+ clientHandle = item.getClientHandle().orElseThrow();
+ }
+
+ @AfterEach
+ void stopClientAndServer() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * The defect. A NotificationMessage far behind the expected sequence number is the Server's
+ * numbering having regressed, so it must be delivered and resynchronized to — and the messages
+ * that follow it in the new numbering must be delivered as well, which is the part that
+ * makes the difference between one dropped message and a Subscription that never delivers
+ * anything again.
+ *
+ *
Nothing here should provoke a Republish: the received sequence number is not ahead of the
+ * expected one, so there is no gap to repair, and a resynchronization that reported a gap would
+ * ask the Server to retransmit billions of NotificationMessages.
+ */
+ @Test
+ void regressedNumberingIsDeliveredAndTheNewNumberingContinues() throws Exception {
+ seedLastSequenceNumber(1000);
+
+ // 1000 behind the expected 1001, so far outside the duplicate window that no retransmission
+ // queue could hold the messages in between: the Server has started numbering again from 1.
+ sendDataChange(1, uint(1));
+
+ assertTrue(
+ awaitDelivered(1),
+ "the NotificationMessage carrying sequence 1 was never delivered. It is 1000 behind the"
+ + " expected 1001 — far beyond the "
+ + DUPLICATE_WINDOW
+ + " that could be a duplicate — so it is the Server's numbering having regressed, and"
+ + " discarding it drops every NotificationMessage until the numbering catches back up,"
+ + " which for a renumbered Subscription is never");
+
+ sendDataChange(2, uint(1), uint(2));
+ sendDataChange(3, uint(1), uint(2), uint(3));
+
+ assertTrue(
+ awaitTrue(() -> deliveredValues.size() >= 3),
+ "the NotificationMessages that followed the regressed one were not delivered: the"
+ + " accounting was not resynchronized to it, so they are all behind"
+ + " lastSequenceNumber too");
+
+ assertEquals(
+ List.of(1, 2, 3),
+ List.copyOf(deliveredValues),
+ "every NotificationMessage of the Server's new numbering must be delivered exactly once and"
+ + " in order");
+
+ assertEquals(
+ List.of(),
+ List.copyOf(republishRequests),
+ "resynchronizing to a regressed sequence number must not report a gap: the received"
+ + " sequence number is behind the expected one, not ahead of it");
+
+ assertEquals(
+ 0,
+ notificationDataLostCount.get(),
+ "no NotificationMessage was lost: the Server renumbered, it did not drop anything");
+ }
+
+ /**
+ * Control for the test above, at the exact boundary: a NotificationMessage {@value
+ * #DUPLICATE_WINDOW} behind the expected one is still within retransmission range, so it is still
+ * a duplicate and must still be discarded. Without this the fix could have turned every duplicate
+ * into a resynchronization, which rolls {@code lastSequenceNumber} backwards and makes every
+ * message already received after it look missing.
+ */
+ @Test
+ void duplicateAtTheEdgeOfTheDuplicateWindowIsStillDiscarded() throws Exception {
+ seedLastSequenceNumber(100);
+
+ // Exactly DUPLICATE_WINDOW behind the expected 101.
+ long duplicate = 101 - DUPLICATE_WINDOW;
+ sendDataChange(duplicate, uint(duplicate));
+
+ // The next message in the established numbering, which is delivered: the serial processing
+ // queue means observing this one is proof the duplicate ahead of it has been dealt with.
+ sendDataChange(101, uint(duplicate), uint(101));
+
+ assertTrue(awaitDelivered(101), "the expected NotificationMessage 101 was never delivered");
+
+ assertEquals(
+ List.of(101),
+ List.copyOf(deliveredValues),
+ "the NotificationMessage "
+ + DUPLICATE_WINDOW
+ + " behind the expected one is within the Server's retransmission range, so it is a"
+ + " duplicate and must not be delivered a second time");
+ }
+
+ /**
+ * The other side of the same boundary: one step further behind than a duplicate can be, so it is
+ * the Server's numbering having regressed and is delivered. Together with the test above this
+ * pins where the boundary is, not merely that there is one.
+ */
+ @Test
+ void numberingRegressedJustBeyondTheDuplicateWindowIsDelivered() throws Exception {
+ seedLastSequenceNumber(100);
+
+ long regressed = 101 - (DUPLICATE_WINDOW + 1);
+ sendDataChange(regressed, uint(regressed));
+
+ assertTrue(
+ awaitDelivered((int) regressed),
+ "a NotificationMessage "
+ + (DUPLICATE_WINDOW + 1)
+ + " behind the expected 101 is further behind than any duplicate can be — beyond both"
+ + " the advertised retransmission queue and DEFAULT_MAX_RECOVERABLE_GAP — so it is a"
+ + " regression in the Server's numbering and must be delivered");
+
+ assertEquals(
+ List.of((int) regressed),
+ List.copyOf(deliveredValues),
+ "exactly the regressed NotificationMessage must be delivered");
+ }
+
+ /**
+ * The same control as {@link #duplicateAtTheEdgeOfTheDuplicateWindowIsStillDiscarded} on an
+ * ordinary trace with no seeded state: a retransmitted copy of a message received a moment ago is
+ * one step behind, and is discarded. This is also the positive control for the fixture — it
+ * proves the scripted notifications, the ClientHandle and the delivery observation are wired up
+ * without any reflection involved.
+ */
+ @Test
+ void duplicateInAnOrdinaryTraceIsStillDiscarded() throws Exception {
+ for (long sequenceNumber = 1; sequenceNumber <= 3; sequenceNumber++) {
+ sendDataChange(sequenceNumber, uint(sequenceNumber));
+
+ assertTrue(
+ awaitDelivered((int) sequenceNumber),
+ "sequence " + sequenceNumber + " was never delivered");
+ }
+
+ // A retransmitted copy of NotificationMessage 2, which the client has already accounted for.
+ sendDataChange(2, uint(1), uint(2), uint(3));
+ sendDataChange(4, uint(1), uint(2), uint(3), uint(4));
+
+ assertTrue(awaitDelivered(4), "sequence 4 was never delivered");
+
+ assertEquals(
+ List.of(1, 2, 3, 4),
+ List.copyOf(deliveredValues),
+ "the retransmitted copy of NotificationMessage 2 must not be delivered a second time");
+ }
+
+ // region fixture helpers
+
+ /**
+ * Enqueue a PublishResponse carrying a DataChangeNotification whose value is {@code
+ * sequenceNumber}, so the delivered value identifies the NotificationMessage it came from.
+ */
+ private void sendDataChange(long sequenceNumber, UInteger... available) {
+ scriptable.enqueueDataChange(
+ subscriptionId,
+ sequenceNumber,
+ List.of(
+ new MonitoredItemNotification(
+ clientHandle, new DataValue(Variant.ofInt32((int) sequenceNumber)))),
+ available);
+ }
+
+ /**
+ * Seeds the {@code lastSequenceNumber} the {@code PublishingManager} tracks for this
+ * Subscription.
+ *
+ *
White-box seeding. {@code lastSequenceNumber} only ever advances one
+ * NotificationMessage at a time, so there is no wire path to a value hundreds ahead of where the
+ * Server's numbering will restart: reaching it legitimately would mean scripting that many
+ * responses, and reaching it by a forward jump would spin the Republish loop instead. The same
+ * technique {@code PublishSequenceRecoveryTest} uses for the rollover boundary. Everything after
+ * the seed is the production code path: real PublishResponses over a real connection, and the
+ * real accounting, gap detection and delivery. Called before any PublishResponse has been
+ * processed, so nothing races with it.
+ */
+ private void seedLastSequenceNumber(long sequenceNumber) throws Exception {
+ PublishingManager publishingManager = client.getPublishingManager();
+
+ Field subscriptionDetailsField =
+ PublishingManager.class.getDeclaredField("subscriptionDetails");
+ subscriptionDetailsField.setAccessible(true);
+
+ Map, ?> subscriptionDetails = (Map, ?>) subscriptionDetailsField.get(publishingManager);
+ Object details = subscriptionDetails.get(subscriptionId);
+ assertNotNull(details, "the Subscription is not registered with the PublishingManager");
+
+ Field lastSequenceNumberField = details.getClass().getDeclaredField("lastSequenceNumber");
+ lastSequenceNumberField.setAccessible(true);
+ lastSequenceNumberField.setLong(details, sequenceNumber);
+ }
+
+ private boolean awaitDelivered(int value) throws Exception {
+ return awaitTrue(() -> deliveredValues.contains(value));
+ }
+
+ /** Polls {@code condition} until it holds or {@link #DELIVERY_WINDOW_MILLIS} elapses. */
+ private static boolean awaitTrue(ThrowingBooleanSupplier condition) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DELIVERY_WINDOW_MILLIS);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(25);
+ }
+
+ return condition.get();
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSuspensionGateRaceTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSuspensionGateRaceTest.java
new file mode 100644
index 0000000000..42bd598740
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishSuspensionGateRaceTest.java
@@ -0,0 +1,731 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.DelegatingSessionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.TransferSubscriptionsResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The window between the Session future completing and the {@code onSessionActive} callbacks
+ * running, measured against the Part 4 §6.7 ordering the Publish suspension gate exists to impose.
+ *
+ *
Part 4 §6.7: "After re-establishing the connection the Client shall call Republish in a
+ * loop... After the Republish returns Bad_MessageNotAvailable the Client shall start sending
+ * Publish requests with the normal Publish handling." {@link PublishReconnectRecoveryTest} asserts
+ * that order for the ordinary caller — one that asks for a Session after the reconnect is over.
+ * This class covers the caller that was already there.
+ *
+ *
{@code SessionFsmFactory} completes the Session future in one executor task and fans the
+ * {@code onSessionActive} callbacks out in another, submitted after it. Every continuation parked
+ * on {@code getSessionAsync()} therefore runs before the {@code PublishingManager} has been
+ * told the Session is Active. A suspension gate that asks only "has every Session activation
+ * counted so far had its recovery run?" is open in that window, because the new activation has not
+ * been counted yet and the answer still describes the previous one — and a PublishRequest goes out
+ * on the new Session ahead of the Republish loop, which is precisely what the ordering forbids.
+ * Counting the activation earlier does not close it either: a re-activation hands back the same
+ * {@link UaSession} object, so the gate has to know both which activation was recovered and which
+ * Session that recovery ran on, and it has to stop trusting the latter the moment that Session goes
+ * away.
+ *
+ *
The interleaving is forced rather than raced. The client's transport executor has a single
+ * thread, so the task that completes the Session future runs to completion — the continuations
+ * parked on it included — before the task that delivers the activation callbacks starts. The parked
+ * caller is a real one: a PublishRequest returned with a failure while the Session is down makes
+ * {@code PublishingManager} try to replace it, find no Session, and wait for the one being
+ * established.
+ */
+public class PublishSuspensionGateRaceTest {
+
+ /** Log entry written when a PublishRequest reaches the Server. */
+ private static final String PUBLISH = "Publish";
+
+ /** Prefix of the log entry written when a RepublishRequest reaches the Server. */
+ private static final String REPUBLISH = "Republish:";
+
+ /** Recorded when the Session future completes, by a caller parked on it. */
+ private static final String SESSION_FUTURE = "SessionFuture";
+
+ /** Recorded when the {@code onSessionActive} callbacks run. */
+ private static final String SESSION_ACTIVE = "SessionActive";
+
+ /**
+ * The sequence number of the last NotificationMessage the client accounts for before the Session
+ * fault.
+ */
+ private static final long LAST_SEQUENCE_NUMBER_BEFORE_FAULT = 2;
+
+ /** The sequence number the Republish loop of Part 4 §6.7 has to start from. */
+ private static final long NEXT_EXPECTED_SEQUENCE_NUMBER = LAST_SEQUENCE_NUMBER_BEFORE_FAULT + 1;
+
+ /** How long to wait for something that must happen. */
+ private static final long AWAIT_TIMEOUT_MILLIS = 10_000;
+
+ /**
+ * How long to wait for a reconnect. The Session FSM waits one second in {@code ReactivatingWait}
+ * before its first re-activation attempt, another second in {@code CreatingWait} before creating
+ * a replacement Session, and doubles each wait on every failure.
+ */
+ private static final long RECONNECT_TIMEOUT_MILLIS = 30_000;
+
+ /**
+ * Long enough that nothing times out on its own: no parked Publish request, no Republish, and no
+ * Session keep-alive. Every ordering asserted against below is therefore the client's own doing.
+ */
+ private static final long REQUEST_TIMEOUT_MILLIS = 60_000;
+
+ private static final long DISCONNECT_TIMEOUT_MILLIS = 5_000;
+
+ /** Upper bound on how long a held ActivateSession is held, so nothing hangs indefinitely. */
+ private static final long GATE_TIMEOUT_MILLIS = 30_000;
+
+ /**
+ * The precondition the two tests below rest on, asserted on its own so that a failure there is
+ * unambiguous: a caller that parks on {@code getSessionAsync()} while the Session is down is
+ * released before any {@code onSessionActive} callback runs, and therefore before {@code
+ * PublishingManager} has counted the activation or started its Republish loop.
+ */
+ @Test
+ void aCallerParkedOnTheSessionFutureRunsBeforeTheActivationCallbacks() throws Exception {
+ try (var fixture = new Fixture()) {
+ fixture.deliverInitialNotifications();
+
+ fixture.faultSession();
+ fixture.awaitSessionInactive();
+
+ fixture.parkAnObserverOnTheSessionFuture();
+
+ fixture.awaitReactivation();
+
+ assertEquals(
+ List.of(SESSION_FUTURE, SESSION_ACTIVE),
+ fixture.activationOrder(),
+ "the Session future must complete before the activation callbacks run; if it does not,"
+ + " there is no window for a parked caller to send a PublishRequest in and the"
+ + " ordering assertions in this class prove nothing");
+ }
+ }
+
+ /**
+ * The same-Session reconnect path: {@code Active -> ReactivatingWait -> Reactivating ->
+ * Initializing -> Active}. The FSM hands back the very same {@link UaSession} object, so a gate
+ * that remembers which Session the last finished recovery ran on still matches it — unless that
+ * record is revoked when the Session becomes inactive.
+ *
+ *
Whatever the gate is keyed on, the first request the client sends for this Subscription once
+ * the Session is Active again must be the Republish that Part 4 §6.7 requires, even though a
+ * caller was already parked on the Session future when it completed.
+ */
+ @Test
+ void noPublishPrecedesTheRepublishDrainWhenACallerIsParkedOnAReactivatedSession()
+ throws Exception {
+
+ try (var fixture = new Fixture()) {
+ fixture.deliverInitialNotifications();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.awaitSessionInactive();
+ fixture.parkAPublishRefillOnTheSessionFuture();
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitRequestLogEntries(1),
+ "no request at all reached the Server after the Session was re-activated");
+
+ assertEquals(
+ REPUBLISH + NEXT_EXPECTED_SEQUENCE_NUMBER,
+ fixture.requestLog().get(0),
+ "the first request sent after re-activation must be the Republish Part 4 §6.7 requires,"
+ + " but a PublishRequest overtook it: the caller parked on the Session future was"
+ + " released before the activation was counted, and the suspension gate answered it"
+ + " with the previous activation's recovery; request log: "
+ + fixture.requestLog());
+ }
+ }
+
+ /**
+ * The replacement-Session reconnect path: re-activation is refused, so the client creates a new
+ * Session and transfers the Subscription to it. Here the {@link UaSession} handed to the parked
+ * caller is a different object from the one the last finished recovery ran on, so this is the
+ * half of the gate that the Session identity answers rather than the revocation.
+ *
+ *
The caller is parked while the replacement Session's ActivateSession is held at the Server,
+ * because the Session future a caller parks on during {@code ReactivatingWait} is discarded —
+ * never completed — when re-activation is refused, and a caller parked on that one is never
+ * released at all.
+ */
+ @Test
+ void noPublishPrecedesTheRepublishDrainWhenACallerIsParkedOnAReplacementSession()
+ throws Exception {
+
+ try (var fixture = new Fixture()) {
+ fixture.deliverInitialNotifications();
+ fixture.advertiseNothingOnTransfer();
+ fixture.refuseNextReactivationAndHoldTheReplacement();
+
+ fixture.armRequestLog();
+ fixture.faultSession();
+ fixture.awaitReplacementActivateSessionHeld();
+ fixture.parkAPublishRefillOnTheSessionFuture();
+ fixture.releaseReplacementActivateSession();
+ fixture.awaitReactivation();
+
+ assertTrue(
+ fixture.awaitRequestLogEntries(1),
+ "no request at all reached the Server after the Subscription was transferred");
+ assertTrue(
+ fixture.transferCount() >= 1,
+ "precondition: the reconnect did not go through TransferSubscriptions, so this test is"
+ + " not exercising the replacement-Session path at all");
+
+ assertEquals(
+ REPUBLISH + NEXT_EXPECTED_SEQUENCE_NUMBER,
+ fixture.requestLog().get(0),
+ "the first request sent after the transfer must be the Republish Part 4 §6.7 requires,"
+ + " but a PublishRequest overtook it: the caller parked on the Session future was"
+ + " released before the activation was counted, and the suspension gate answered it"
+ + " with the previous Session's recovery; request log: "
+ + fixture.requestLog());
+ }
+ }
+
+ // region fixture
+
+ /**
+ * The order in which Publish and Republish requests reach the Server.
+ *
+ *
Recording starts only when the log is {@linkplain #arm() armed}, which a test does once its
+ * Publish pipeline is quiescent — every request parked at the Server and no responder scripted
+ * for any of them. From that moment the only requests that can reach the Server are the ones the
+ * client sends after the reconnect.
+ */
+ private static final class RequestLog {
+
+ private final List entries = Collections.synchronizedList(new ArrayList<>());
+
+ private volatile boolean armed = false;
+
+ void arm() {
+ entries.clear();
+ armed = true;
+ }
+
+ void record(String entry) {
+ if (armed) {
+ entries.add(entry);
+ }
+ }
+
+ List entries() {
+ return List.copyOf(entries);
+ }
+ }
+
+ /**
+ * A {@link ScriptableSubscriptionServiceSet} that records the arrival of every PublishRequest and
+ * can answer TransferSubscriptions with a scripted availableSequenceNumbers list.
+ */
+ private static final class LoggingSubscriptionServiceSet
+ extends ScriptableSubscriptionServiceSet {
+
+ private final AtomicBoolean transferScripted = new AtomicBoolean(false);
+ private volatile int transferCount = 0;
+
+ private final RequestLog requestLog;
+
+ LoggingSubscriptionServiceSet(OpcUaServer server, RequestLog requestLog) {
+ super(server);
+
+ this.requestLog = requestLog;
+ }
+
+ @Override
+ public CompletableFuture onPublish(
+ ServiceRequestContext context, PublishRequest request) {
+
+ requestLog.record(PUBLISH);
+
+ return super.onPublish(context, request);
+ }
+
+ @Override
+ public TransferSubscriptionsResponse onTransferSubscriptions(
+ ServiceRequestContext context, TransferSubscriptionsRequest request) throws UaException {
+
+ if (!transferScripted.get()) {
+ return super.onTransferSubscriptions(context, request);
+ }
+
+ transferCount++;
+
+ UInteger[] subscriptionIds = request.getSubscriptionIds();
+ int count = subscriptionIds != null ? subscriptionIds.length : 0;
+
+ var results = new TransferResult[count];
+ for (int i = 0; i < count; i++) {
+ // Part 4 §5.14.7.1: the Server is holding nothing for retransmission, so the Republish loop
+ // of §6.7 is the increment-until-Bad_MessageNotAvailable form.
+ results[i] = new TransferResult(StatusCode.GOOD, new UInteger[0]);
+ }
+
+ var responseHeader =
+ new ResponseHeader(
+ DateTime.now(),
+ request.getRequestHeader().getRequestHandle(),
+ StatusCode.GOOD,
+ null,
+ null,
+ null);
+
+ return new TransferSubscriptionsResponse(responseHeader, results, null);
+ }
+ }
+
+ /**
+ * A {@link DelegatingSessionServiceSet} that can refuse a single ActivateSession with a
+ * ServiceFault — which is what drives the Session FSM off the re-activation path and onto the
+ * create-a-new-Session-and-transfer path — and then hold the next one until the test releases it.
+ */
+ private static final class ScriptedSessionServiceSet extends DelegatingSessionServiceSet {
+
+ private final AtomicBoolean refuseNext = new AtomicBoolean(false);
+ private final AtomicBoolean holdNext = new AtomicBoolean(false);
+
+ private final CountDownLatch heldActivateSession = new CountDownLatch(1);
+ private final CountDownLatch activateSessionGate = new CountDownLatch(1);
+
+ ScriptedSessionServiceSet(OpcUaServer server) {
+ super(server);
+ }
+
+ @Override
+ public ActivateSessionResponse onActivateSession(
+ ServiceRequestContext context, ActivateSessionRequest request) throws UaException {
+
+ if (refuseNext.compareAndSet(true, false)) {
+ throw new UaException(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ if (holdNext.compareAndSet(true, false)) {
+ heldActivateSession.countDown();
+
+ try {
+ if (!activateSessionGate.await(GATE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
+ throw new UaException(StatusCodes.Bad_Timeout);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new UaException(StatusCodes.Bad_UnexpectedError, e);
+ }
+ }
+
+ return super.onActivateSession(context, request);
+ }
+ }
+
+ /**
+ * A running Server whose Publish, Republish and TransferSubscriptions responses are scripted, and
+ * a connected client with one Subscription carrying one MonitoredItem.
+ *
+ *
The client's transport executor has a single thread. That is what makes the window this
+ * class is about deterministic: the Session FSM submits the task that completes the Session
+ * future before the task that fans the activation callbacks out, so on one thread the first runs
+ * to completion — parked continuations included — before the second begins.
+ *
+ *
The client is configured for two pending PublishRequests, which is one per role: one to be
+ * answered with the Session fault, and one to be returned, once the Session is down, with a
+ * failure the client answers by trying to send a replacement.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private static final long MAX_PENDING_PUBLISH_REQUESTS = 2;
+
+ private final RequestLog requestLog = new RequestLog();
+
+ private final List deliveredValues = Collections.synchronizedList(new ArrayList<>());
+
+ /** The order in which the Session future completed and the activation callbacks ran. */
+ private final List activationOrder = Collections.synchronizedList(new ArrayList<>());
+
+ private final CountDownLatch sessionInactive = new CountDownLatch(1);
+ private final CountDownLatch sessionReactivated = new CountDownLatch(1);
+
+ /** Counted down when the client observes the ServiceFault that provokes the parked refill. */
+ private final CountDownLatch refillFaultObserved = new CountDownLatch(1);
+
+ private final ExecutorService executor =
+ Executors.newSingleThreadExecutor(daemonThreadFactory("publish-suspension-gate-race"));
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final LoggingSubscriptionServiceSet scriptable;
+ private final ScriptedSessionServiceSet sessionServiceSet;
+
+ private final UInteger subscriptionId;
+ private final UInteger clientHandle;
+
+ Fixture() throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable = new LoggingSubscriptionServiceSet(server, requestLog);
+ sessionServiceSet = new ScriptedSessionServiceSet(server);
+
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ server.addServiceSet(endpoint.getPath(), sessionServiceSet);
+ }
+
+ server.startup().get();
+
+ client =
+ TestClient.create(
+ server,
+ transportConfig -> transportConfig.setExecutor(executor),
+ cfg ->
+ cfg.setRequestTimeout(uint(REQUEST_TIMEOUT_MILLIS))
+ // No Session keep-alive traffic: the only requests in flight during a test
+ // are the ones it scripts.
+ .setKeepAliveInterval(uint(REQUEST_TIMEOUT_MILLIS))
+ .setMaxPendingPublishRequests(uint(MAX_PENDING_PUBLISH_REQUESTS)));
+ client.connect();
+
+ client.addFaultListener(
+ serviceFault -> {
+ if (serviceFault.getResponseHeader().getServiceResult().value()
+ == StatusCodes.Bad_UnexpectedError) {
+
+ refillFaultObserved.countDown();
+ }
+ });
+
+ client.addSessionActivityListener(
+ new SessionActivityListener() {
+ @Override
+ public void onSessionInactive(UaSession session) {
+ sessionInactive.countDown();
+ }
+
+ @Override
+ public void onSessionActive(UaSession session) {
+ if (sessionInactive.getCount() == 0) {
+ activationOrder.add(SESSION_ACTIVE);
+ sessionReactivated.countDown();
+ }
+ }
+ });
+
+ scriptable.setRepublishResponder(this::respondToRepublish);
+
+ var subscription = new OpcUaSubscription(client);
+ subscription.setSubscriptionListener(
+ new OpcUaSubscription.SubscriptionListener() {
+ @Override
+ public void onDataReceived(
+ OpcUaSubscription s, List items, List values) {
+
+ for (DataValue value : values) {
+ deliveredValues.add((Integer) value.getValue().getValue());
+ }
+ }
+ });
+ subscription.create();
+
+ subscriptionId = subscription.getSubscriptionId().orElseThrow();
+
+ // The MonitoredItem only has to exist on the client: addMonitoredItem assigns the
+ // ClientHandle
+ // the notification fan-out looks scripted notifications up by, and no Server-side item
+ // participates in delivering one.
+ OpcUaMonitoredItem item =
+ OpcUaMonitoredItem.newDataItem(NodeIds.Server_ServerStatus_CurrentTime);
+ subscription.addMonitoredItem(item);
+
+ clientHandle = item.getClientHandle().orElseThrow();
+ }
+
+ /**
+ * Deliver NotificationMessages 1 and 2, leaving the client's last accounted-for sequence number
+ * at {@value #LAST_SEQUENCE_NUMBER_BEFORE_FAULT} and its Publish pipeline quiescent: every
+ * request parked at the Server with no responder scripted for any of them.
+ */
+ void deliverInitialNotifications() throws Exception {
+ enqueueDataChange(1);
+ assertTrue(
+ awaitTrue(() -> deliveredValues().size() >= 1, AWAIT_TIMEOUT_MILLIS),
+ "the first NotificationMessage was never delivered");
+
+ enqueueDataChange(LAST_SEQUENCE_NUMBER_BEFORE_FAULT);
+ assertTrue(
+ awaitTrue(() -> deliveredValues().size() >= 2, AWAIT_TIMEOUT_MILLIS),
+ "the second NotificationMessage was never delivered");
+
+ assertTrue(
+ awaitTrue(
+ () -> scriptable.getParkedRequestCount() == MAX_PENDING_PUBLISH_REQUESTS,
+ AWAIT_TIMEOUT_MILLIS),
+ "the client did not refill its Publish pipeline");
+
+ assertEquals(
+ List.of(1, (int) LAST_SEQUENCE_NUMBER_BEFORE_FAULT),
+ deliveredValues(),
+ "precondition: the client must have accounted for NotificationMessages 1 and 2 before the"
+ + " Session fault");
+ }
+
+ /** Answer TransferSubscriptions with an empty availableSequenceNumbers (Part 4 §5.14.7.1). */
+ void advertiseNothingOnTransfer() {
+ scriptable.transferScripted.set(true);
+ }
+
+ /**
+ * Refuse the next ActivateSession with a ServiceFault, which sends the Session FSM to {@code
+ * CreatingWait} and from there onto the create-a-new-Session-and-transfer path, and hold the
+ * ActivateSession of the replacement Session so that a caller can be parked on the Session
+ * future the FSM will complete for it.
+ */
+ void refuseNextReactivationAndHoldTheReplacement() {
+ sessionServiceSet.refuseNext.set(true);
+ sessionServiceSet.holdNext.set(true);
+ }
+
+ void awaitReplacementActivateSessionHeld() throws Exception {
+ assertTrue(
+ sessionServiceSet.heldActivateSession.await(
+ RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the client never got as far as activating a replacement Session");
+ }
+
+ void releaseReplacementActivateSession() {
+ sessionServiceSet.activateSessionGate.countDown();
+ }
+
+ void armRequestLog() {
+ requestLog.arm();
+ }
+
+ /**
+ * Answer one parked PublishRequest with a Bad_SessionIdInvalid ServiceFault, which {@code
+ * SessionFsmFactory}'s SessionFaultListener classifies as a Session error and turns into a
+ * reconnect. The Server-side Session is untouched, so re-activation succeeds unless {@link
+ * #refuseNextReactivation()} was called.
+ */
+ void faultSession() {
+ scriptable.enqueueServiceFault(StatusCodes.Bad_SessionIdInvalid);
+ }
+
+ /**
+ * Return the remaining outstanding PublishRequest with a failure that is a statement about that
+ * one request rather than about the Session or the Subscription set, so {@code
+ * PublishingManager} answers it by trying to send a replacement. The Session is gone by now, so
+ * that replacement's caller finds no Session and parks on the one being established: exactly
+ * the caller the suspension gate has to hold back when it arrives.
+ *
+ *
Returns only once the client has run the failure handler, so the caller is provably parked
+ * before the Session future can complete. The fault listener fires from a task the failing
+ * request's completion handler submits, and the failure handler is another such task, so a
+ * barrier queued once the listener has fired can still be ahead of it — but the second barrier
+ * cannot, because the first only runs after that completion handler has returned, by which time
+ * the failure handler is queued.
+ */
+ void parkAPublishRefillOnTheSessionFuture() throws Exception {
+ scriptable.enqueueServiceFault(StatusCodes.Bad_UnexpectedError);
+
+ assertTrue(
+ refillFaultObserved.await(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the client never observed the ServiceFault that provokes the parked Publish refill");
+
+ awaitTransportExecutorDrained();
+ awaitTransportExecutorDrained();
+ }
+
+ /** Wait until the transport executor has run everything queued before this call. */
+ private void awaitTransportExecutorDrained() throws Exception {
+ var drained = new CompletableFuture();
+ executor.execute(() -> drained.complete(null));
+
+ drained.get(AWAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ }
+
+ /** Park a caller on the Session future that records when it is released. */
+ void parkAnObserverOnTheSessionFuture() {
+ client.getSessionAsync().whenComplete((session, ex) -> activationOrder.add(SESSION_FUTURE));
+ }
+
+ /**
+ * Script the next PublishResponse as a data message carrying {@code sequenceNumber}, whose
+ * value identifies the NotificationMessage it came from.
+ */
+ void enqueueDataChange(long sequenceNumber) {
+ scriptable.enqueue(
+ request ->
+ CompletableFuture.completedFuture(
+ scriptable.buildPublishResponse(
+ request,
+ subscriptionId,
+ sequenceNumber,
+ notificationData(sequenceNumber),
+ new UInteger[] {uint(sequenceNumber)},
+ false)));
+ }
+
+ /**
+ * The Server holds nothing for retransmission, so every Republish is answered
+ * Bad_MessageNotAvailable — which is also what terminates the Republish loop Part 4 §6.7
+ * describes.
+ */
+ private RepublishResponse respondToRepublish(RepublishRequest request) throws UaException {
+ requestLog.record(REPUBLISH + request.getRetransmitSequenceNumber().longValue());
+
+ throw new UaException(StatusCodes.Bad_MessageNotAvailable);
+ }
+
+ private ExtensionObject[] notificationData(long sequenceNumber) {
+ var notification =
+ new MonitoredItemNotification(
+ clientHandle, new DataValue(Variant.ofInt32((int) sequenceNumber)));
+
+ return new ExtensionObject[] {
+ scriptable.encode(
+ new DataChangeNotification(new MonitoredItemNotification[] {notification}, null))
+ };
+ }
+
+ void awaitSessionInactive() throws Exception {
+ assertTrue(
+ sessionInactive.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the scripted Bad_SessionIdInvalid Publish fault did not take the Session out of Active");
+ }
+
+ void awaitReactivation() throws Exception {
+ awaitSessionInactive();
+
+ assertTrue(
+ sessionReactivated.await(RECONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS),
+ "the Session never became Active again");
+ }
+
+ List requestLog() {
+ return requestLog.entries();
+ }
+
+ boolean awaitRequestLogEntries(int count) throws Exception {
+ return awaitTrue(() -> requestLog.entries().size() >= count, AWAIT_TIMEOUT_MILLIS);
+ }
+
+ List activationOrder() {
+ return List.copyOf(activationOrder);
+ }
+
+ List deliveredValues() {
+ return List.copyOf(deliveredValues);
+ }
+
+ int transferCount() {
+ return scriptable.transferCount;
+ }
+
+ /** Polls {@code condition} until it holds or the timeout elapses. */
+ boolean awaitTrue(ThrowingBooleanSupplier condition, long timeoutMillis) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+
+ while (System.nanoTime() < deadline) {
+ if (condition.get()) {
+ return true;
+ }
+ Thread.sleep(10);
+ }
+
+ return condition.get();
+ }
+
+ @Override
+ public void close() throws Exception {
+ sessionServiceSet.activateSessionGate.countDown();
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(DISCONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ignored) {
+ // A client whose single transport thread is occupied cannot run the disconnect it is asked
+ // for; shutting the Server and the executor down below is what releases it. Tolerated here
+ // so teardown does not mask the assertion that detected the stall.
+ } finally {
+ try {
+ server.shutdown().get(10, TimeUnit.SECONDS);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+ }
+ }
+
+ /** Daemon threads, so a test that leaves work behind cannot keep the JVM alive. */
+ private static ThreadFactory daemonThreadFactory(String name) {
+ return runnable -> {
+ var thread = new Thread(runnable, name);
+ thread.setDaemon(true);
+
+ return thread;
+ };
+ }
+
+ @FunctionalInterface
+ private interface ThrowingBooleanSupplier {
+ boolean get() throws Exception;
+ }
+
+ // endregion
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishTimeoutHintOverflowTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishTimeoutHintOverflowTest.java
new file mode 100644
index 0000000000..4b0fa62a51
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishTimeoutHintOverflowTest.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.OpcUaSession;
+import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.test.ScriptableSubscriptionServiceSet;
+import org.eclipse.milo.opcua.sdk.test.TestClient;
+import org.eclipse.milo.opcua.sdk.test.TestServer;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.CreateSubscriptionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CreateSubscriptionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A Server is free to revise the publishing interval and max keep-alive count it returns from
+ * CreateSubscription (Part 4 §5.13.2); nothing bounds their product.
+ *
+ *
{@code PublishingManager} derives the Publish request's {@code timeoutHint} from {@code
+ * revisedPublishingInterval * revisedMaxKeepAliveCount * maxPendingPublishes * 1.5}, and {@code
+ * timeoutHint} is encoded as a UInt32. When the product exceeds {@code UInteger.MAX_VALUE} the
+ * client must clamp it. If it does not, building the request throws — and because {@code
+ * maybeSendPublishRequests()} takes a pending-publish permit before calling {@code
+ * sendPublishRequest()}, the permit is never returned. The permits ratchet up to the maximum and
+ * the client stops sending Publish requests for the rest of the Session.
+ */
+public class PublishTimeoutHintOverflowTest {
+
+ /** Revised parameters a Server would plausibly return; their product is far below UInt32. */
+ private static final double ORDINARY_PUBLISHING_INTERVAL = 1_000.0;
+
+ private static final UInteger ORDINARY_MAX_KEEP_ALIVE_COUNT = uint(10);
+
+ /**
+ * A one-hour publishing interval with a max keep-alive count of 1000: both legal, and their
+ * product (3.6e9 ms) multiplied by the two in-flight Publish requests and the 1.5 safety factor
+ * yields ~1.08e10, well beyond the UInt32 range {@code timeoutHint} is carried in.
+ */
+ private static final double LARGE_PUBLISHING_INTERVAL = 3_600_000.0;
+
+ private static final UInteger LARGE_MAX_KEEP_ALIVE_COUNT = uint(1_000);
+
+ /**
+ * Control for {@link #publishRequestIsSentWhenRevisedParametersOverflowTheTimeoutHint()}: with
+ * ordinary revised parameters the client pipelines a Publish request as soon as the Subscription
+ * is created. If this one fails, the fixture is broken rather than the timeout hint.
+ */
+ @Test
+ void publishRequestIsSentWithOrdinaryRevisedParameters() throws Exception {
+ try (var fixture = new Fixture(ORDINARY_PUBLISHING_INTERVAL, ORDINARY_MAX_KEEP_ALIVE_COUNT)) {
+ fixture.createSubscription();
+
+ assertTrue(
+ fixture.awaitPublishRequest(),
+ "no Publish request was sent after the Subscription was created");
+ }
+ }
+
+ /**
+ * Creating a Subscription whose revised parameters overflow the computed timeout hint must still
+ * start Publish traffic. Under the defect the overflow throws out of {@code sendPublishRequest()}
+ * after the pending-publish permit was taken, so no Publish request is ever sent.
+ */
+ @Test
+ void publishRequestIsSentWhenRevisedParametersOverflowTheTimeoutHint() throws Exception {
+ try (var fixture = new Fixture(LARGE_PUBLISHING_INTERVAL, LARGE_MAX_KEEP_ALIVE_COUNT)) {
+ fixture.createSubscription();
+
+ assertTrue(
+ fixture.awaitPublishRequest(),
+ "no Publish request was sent: the timeout hint overflowed UInt32 and the"
+ + " pending-publish permit was leaked");
+ }
+ }
+
+ /**
+ * The same defect observed at its source. Anything thrown while building or sending the Publish
+ * request escapes with the caller's pending-publish permit already taken and no completion
+ * handler registered to return it.
+ */
+ @Test
+ void sendPublishRequestDoesNotThrowWhenRevisedParametersOverflowTheTimeoutHint()
+ throws Exception {
+ try (var fixture = new Fixture(LARGE_PUBLISHING_INTERVAL, LARGE_MAX_KEEP_ALIVE_COUNT)) {
+ fixture.createSubscription();
+
+ PublishingManager publishingManager = fixture.client.getPublishingManager();
+ OpcUaSession session = fixture.client.getSession();
+
+ assertDoesNotThrow(() -> publishingManager.sendPublishRequest(session, new AtomicLong(1)));
+ }
+ }
+
+ /**
+ * A running Server whose CreateSubscription response is rewritten to advertise the given revised
+ * publishing interval and max keep-alive count, plus a connected client. Publish requests are
+ * counted and then parked by {@link ScriptableSubscriptionServiceSet}.
+ */
+ private static final class Fixture implements AutoCloseable {
+
+ private final CountDownLatch publishRequestReceived = new CountDownLatch(1);
+
+ private final OpcUaServer server;
+ private final OpcUaClient client;
+ private final ScriptableSubscriptionServiceSet scriptable;
+
+ Fixture(double revisedPublishingInterval, UInteger revisedMaxKeepAliveCount) throws Exception {
+ TestServer testServer = TestServer.create();
+ server = testServer.getServer();
+
+ scriptable =
+ new ScriptableSubscriptionServiceSet(server) {
+ @Override
+ public CreateSubscriptionResponse onCreateSubscription(
+ ServiceRequestContext context, CreateSubscriptionRequest request)
+ throws UaException {
+
+ CreateSubscriptionResponse response = super.onCreateSubscription(context, request);
+
+ return new CreateSubscriptionResponse(
+ response.getResponseHeader(),
+ response.getSubscriptionId(),
+ revisedPublishingInterval,
+ response.getRevisedLifetimeCount(),
+ revisedMaxKeepAliveCount);
+ }
+
+ @Override
+ public CompletableFuture onPublish(
+ ServiceRequestContext context, PublishRequest request) {
+
+ publishRequestReceived.countDown();
+
+ return super.onPublish(context, request);
+ }
+ };
+
+ for (EndpointConfig endpoint : server.getConfig().getEndpoints()) {
+ server.addServiceSet(endpoint.getPath(), scriptable);
+ }
+
+ server.startup().get();
+
+ // Long request timeout so parked Publish requests do not time out during the test.
+ client = TestClient.create(server, cfg -> cfg.setRequestTimeout(uint(60_000)));
+ client.connect();
+ }
+
+ OpcUaSubscription createSubscription() throws UaException {
+ var subscription = new OpcUaSubscription(client);
+ subscription.create();
+ return subscription;
+ }
+
+ boolean awaitPublishRequest() throws InterruptedException {
+ return publishRequestReceived.await(5, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void close() throws Exception {
+ scriptable.failParkedRequests(StatusCodes.Bad_NoSubscription);
+ try {
+ client.disconnectAsync().get(5, TimeUnit.SECONDS);
+ } finally {
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+ }
+}
diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishingManagerSessionIsolationTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishingManagerSessionIsolationTest.java
new file mode 100644
index 0000000000..92755ba1f5
--- /dev/null
+++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/client/subscriptions/PublishingManagerSessionIsolationTest.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.client.subscriptions;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.stream.LongStream;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClientConfig;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.UaResponseMessageType;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.structured.NotificationMessage;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.RepublishResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
+import org.eclipse.milo.opcua.stack.core.util.TaskQueue;
+import org.junit.jupiter.api.Test;
+
+/** Session-activation ownership of reconnect recovery state in {@link PublishingManager}. */
+public class PublishingManagerSessionIsolationTest {
+
+ private static final long AWAIT_TIMEOUT_SECONDS = 5L;
+
+ /**
+ * A recovery from activation A can remain queued when replacement Session B transfers the same
+ * Subscription. A must not consume B's advertised sequence numbers; otherwise B falls back to a
+ * blind drain and can lose messages the TransferResult proved were recoverable.
+ */
+ @Test
+ void staleRecoveryDoesNotConsumeReplacementSessionsTransferResult() throws Exception {
+ Fixture fixture = new Fixture(1, 1);
+ UaSession staleSession = fixture.newSession();
+ UaSession replacementSession = fixture.newSession();
+
+ fixture.setLastSequenceNumber(6L);
+ fixture.setActivation(1L);
+ fixture.setResponder(request -> response(request.getRetransmitSequenceNumber().longValue()));
+
+ fixture.manager.notifySubscriptionTransferred(
+ replacementSession, fixture.subscriptionId, new UInteger[] {uint(7)});
+
+ // Models another activation callback being counted before this Session's callback runs. The
+ // TransferResult belongs to the Session itself; predicting "current + 1" at notification time
+ // would stamp it with activation 2 and make activation 3 ignore it.
+ fixture.setActivation(3L);
+ fixture.republishUntilUnavailable(staleSession, 1L);
+ fixture.republishUntilUnavailable(replacementSession, 3L);
+
+ assertEquals(
+ List.of(7L),
+ fixture.republishRequests,
+ "the stale recovery consumed the replacement Session's TransferResult, so the replacement"
+ + " performed a blind extra Republish instead of following its advertised set");
+ }
+
+ /**
+ * Milo can permit more than 64 outstanding PublishRequests. Part 4 §6.7 recovery must cover every
+ * response that pipeline could have lost and still make the extra request that receives
+ * Bad_MessageNotAvailable; otherwise Publish resumes before the retransmission queue is drained.
+ */
+ @Test
+ void blindRecoveryCoversConfiguredPipelineDepthBeyond64AndTerminationProbe() throws Exception {
+ int pipelineDepth = 65;
+ Fixture fixture = new Fixture(pipelineDepth - 1, pipelineDepth);
+ UaSession session = fixture.newSession();
+
+ fixture.capturePermittedPipelineDepth();
+
+ // The recovery bound belongs to the pre-disconnect pipeline. Removing all but the Subscription
+ // under test after the outage must not shrink it to the new two-request natural target.
+ fixture.retainOnlyPrimarySubscription();
+ fixture.setActivation(1L);
+ fixture.setResponder(
+ request -> {
+ long sequenceNumber = request.getRetransmitSequenceNumber().longValue();
+
+ return sequenceNumber <= pipelineDepth
+ ? response(sequenceNumber)
+ : CompletableFuture.failedFuture(
+ new UaException(StatusCodes.Bad_MessageNotAvailable));
+ });
+
+ fixture.republishUntilUnavailable(session, 1L);
+
+ List expected = LongStream.rangeClosed(1L, pipelineDepth + 1L).boxed().toList();
+
+ assertEquals(
+ expected,
+ fixture.republishRequests,
+ "a blind reconnect drain must request all 65 NotificationMessages a configured 65-deep"
+ + " Publish pipeline could have lost, then request sequence 66 to receive the"
+ + " Bad_MessageNotAvailable termination response");
+ }
+
+ /**
+ * Bad_TooManyPublishRequests describes the Session that rejected the request. A delayed failure
+ * from activation A must not install a ceiling after activation B has reset its independent
+ * Publish pipeline state.
+ */
+ @Test
+ void oldActivationCannotClampReplacementSessionsPublishCeiling() throws Exception {
+ Fixture fixture = new Fixture(0, 1);
+ fixture.setActivation(2L);
+
+ fixture.handlePublishFailure(
+ new UaException(StatusCodes.Bad_TooManyPublishRequests), new AtomicLong(1L), 1L);
+
+ assertEquals(
+ Long.MAX_VALUE,
+ fixture.pendingPublishCeiling(),
+ "Bad_TooManyPublishRequests from activation 1 clamped activation 2 even though pending"
+ + " Publish limits are Session-scoped");
+ }
+
+ /** A delayed successful response cannot pay down a replacement Session's learned ceiling. */
+ @Test
+ void oldActivationCannotAdvanceReplacementSessionsCeilingCooldown() throws Exception {
+ Fixture fixture = new Fixture(0, 1);
+ fixture.setActivation(2L);
+
+ fixture.handlePublishFailure(
+ new UaException(StatusCodes.Bad_TooManyPublishRequests), new AtomicLong(2L), 2L);
+
+ fixture.releasePendingPublish(new AtomicLong(1L), 1L);
+
+ assertEquals(
+ 0L,
+ fixture.pendingPublishCeilingSuccesses(),
+ "a successful PublishResponse from activation 1 advanced activation 2's cooldown");
+ }
+
+ private static CompletableFuture response(long sequenceNumber) {
+ var notificationMessage =
+ new NotificationMessage(uint(sequenceNumber), DateTime.now(), new ExtensionObject[0]);
+
+ return CompletableFuture.completedFuture(
+ new RepublishResponse(mock(ResponseHeader.class), notificationMessage));
+ }
+
+ /** A minimal, synchronously executed PublishingManager fixture for activation-boundary tests. */
+ private static final class Fixture {
+
+ private final List republishRequests = new ArrayList<>();
+
+ private final AtomicReference<
+ Function>>
+ responder = new AtomicReference<>();
+
+ private final OpcUaClient client = mock(OpcUaClient.class);
+ private final PublishingManager manager;
+ private final Class> subscriptionDetailsClass;
+ private final Object primaryDetails;
+ private final UInteger subscriptionId = uint(1);
+
+ Fixture(int subscriptionCount, int maxPendingPublishRequests) throws Exception {
+ OpcUaClientConfig config = mock(OpcUaClientConfig.class);
+ when(config.getMaxPendingPublishRequests()).thenReturn(uint(maxPendingPublishRequests));
+ when(client.getConfig()).thenReturn(config);
+ when(client.newRequestHeader(any(NodeId.class))).thenReturn(mock(RequestHeader.class));
+ when(client.getSessionAsync()).thenReturn(new CompletableFuture<>());
+ when(client.sendRequestAsync(any()))
+ .thenAnswer(
+ invocation -> {
+ RepublishRequest request = invocation.getArgument(0);
+ republishRequests.add(request.getRetransmitSequenceNumber().longValue());
+
+ return responder.get().apply(request);
+ });
+
+ manager = new PublishingManager(client);
+
+ subscriptionDetailsClass =
+ Class.forName(PublishingManager.class.getName() + "$SubscriptionDetails");
+
+ Map details = subscriptionDetails(manager);
+
+ primaryDetails = newSubscriptionDetails(subscriptionId);
+
+ for (int i = 0; i < subscriptionCount; i++) {
+ UInteger id = uint(i + 1L);
+ details.put(id, i == 0 ? primaryDetails : newSubscriptionDetails(id));
+ }
+ }
+
+ UaSession newSession() {
+ UaSession session = mock(UaSession.class);
+ when(session.getAuthenticationToken()).thenReturn(NodeId.NULL_VALUE);
+ when(session.getSessionId()).thenReturn(new NodeId(1, System.identityHashCode(session)));
+
+ return session;
+ }
+
+ void capturePermittedPipelineDepth() throws Exception {
+ Method method = PublishingManager.class.getDeclaredMethod("maybeSendPublishRequests");
+ method.setAccessible(true);
+ method.invoke(manager);
+ }
+
+ void retainOnlyPrimarySubscription() throws Exception {
+ Map details = subscriptionDetails(manager);
+ details.keySet().removeIf(id -> !id.equals(subscriptionId));
+ }
+
+ void setResponder(
+ Function> responder) {
+
+ this.responder.set(responder);
+ }
+
+ @SuppressWarnings("unchecked")
+ void setActivation(long activation) throws Exception {
+ Field field = PublishingManager.class.getDeclaredField("sessionActivations");
+ field.setAccessible(true);
+
+ ((AtomicLong) field.get(manager)).set(activation);
+
+ Field ceilingField = PublishingManager.class.getDeclaredField("pendingPublishCeiling");
+ ceilingField.setAccessible(true);
+
+ AtomicReference