Skip to content

Publishing and Subscription Hardening - #1823

Merged
kevinherron merged 34 commits into
mainfrom
publishing-manager-hardening
Aug 1, 2026
Merged

Publishing and Subscription Hardening#1823
kevinherron merged 34 commits into
mainfrom
publishing-manager-hardening

Conversation

@kevinherron

@kevinherron kevinherron commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Corrects client-side subscription publishing, recovery, and lifecycle handling under lost NotificationMessages, reordered Publish responses, UInt32 sequence rollover, reconnect, subscription reset and recreation, and failed service calls. Affects PublishingManager, OpcUaSubscription, and the transfer-result handling in SessionFsmFactory.

Sequence-number accounting and response ordering

Sequence arithmetic is implemented in a package-private SequenceNumbers type using the rule in Part 4 §5.14.1.1 — sequence numbers roll over to 1 and never use 0 — rather than the generic wrap-to-0 Counter rule of §7.8, which is off by one at each rollover. Previously the arithmetic was plain long addition and comparison with no rollover handling, so a message lost at the boundary was never detected.

A keep-alive sets lastSequenceNumber to the modular predecessor of the advertised number, because §5.14.1.1 defines a keep-alive as carrying the sequence number of the next NotificationMessage to be sent. Previously an initial keep-alive advertising sequence 1 recorded 1 as received, masking the loss of NotificationMessage 1; and a gap ending in a keep-alive advanced only to the first missing number, so each subsequent keep-alive re-requested the remainder of the gap and reported data loss again.

Publish responses are processed in the order the transport delivers them. AbstractUascClientTransport serializes PublishResponse completion through a dedicated queue; the manager previously re-dispatched that callback onto the multi-threaded transport executor, so responses could be processed out of order, regressing sequence state and producing duplicate delivery and spurious onNotificationDataLost. Processing is now enqueued from the ordered completion callback and is monotonic, so a late or duplicate response cannot move state backwards.

Gap recovery is bounded by what the server advertises in availableSequenceNumbers, falling back to a fixed maximum. A gap larger than that is reported as lost data and resynchronized to, rather than iterated — necessary because modular comparison, unlike the previous plain >, will compute a large forward distance for a stale or wrapped sequence number.

A NotificationMessage further behind the expected number than any plausible duplicate — beyond max(DEFAULT_MAX_RECOVERABLE_GAP, advertised availableSequenceNumbers) — is treated as the server having renumbered, for example after a restart that restored a subscription. It is delivered and resynchronized to, where previously it was discarded and the client remained stalled until the server's numbering caught back up.

Republish recovery

Recovery is asynchronous and never blocks the executor that completes its own responses. Previously it called the blocking client.republish() from the response-processing queue while the Republish response could only be completed by the same executor. On a single-thread executor this deadlocks permanently: handleResponse cancels the request timeout before queueing the completion, so once the server answers, nothing remains to break the stall.

Response processing uses a per-subscription queue rather than one client-wide queue, so a gap in one subscription does not stall response processing for the others.

Recovery is bound to the session in hand when the gap is found. Going through client.republishAsync re-resolves the session per call and parks on one being established; a parked repair holds the paused processing queue, and with it reconnect recovery and Publish traffic for every subscription.

Reconnect

After a session is re-activated the client performs the Part 4 §6.7 Republish drain for each subscription before resuming normal Publish handling. The drain runs only where the server may hold something the client has not collected: a session has previously become inactive, or a subscription carries the availableSequenceNumbers a transfer named. It does not run on the first activation of a session the client just created, where a subscription registered between the session future completing and the onSessionActive fan-out would otherwise be drained for messages that cannot exist — spending a round trip per subscription and holding the publish gate shut for their duration. The SecureChannel, ActivateSession, and TransferSubscriptions parts of §6.7 were already handled; the drain was not, which allowed the server's retransmission queue to evict missed messages before the client requested them.

SessionFsmFactory passes each good TransferResult's availableSequenceNumbers to the drain, so after a transfer recovery follows the list the server holds rather than incrementing until Bad_MessageNotAvailable. §5.14.7.1 states the client should acknowledge messages in that list it will not request, so this is a conformance improvement against a "should", not a "shall".

The SubscriptionIds sent in TransferSubscriptions and the Subscription objects they came from are captured together, so a concurrent reset() cannot shift result indexes and apply a failed result to a Subscription that transferred successfully. Transfer recovery input is owned by the exact UaSession that produced it and is consumed only by a matching recovery. A recovery task queued for an older Session cannot take the replacement Session's sequence-number list, and a late Republish response is discarded once its activation has been superseded.

The suspension gate records which session the most recent completed recovery ran on, and onSessionInactive revokes it. A gate keyed only on activation counts can be bypassed, because the Session FSM completes the session future in an executor task submitted before the activity-listener fan-out, so a caller already parked on getSessionAsync() can send a Publish before recovery is counted. Recording the session also covers reactivation, where the FSM hands back the same Session object.

Every failure path in recovery reaches resumePublishing, including an empty registry, a rejected queue task, a throwing drain body, and any Republish failure. Drains are sent against the just-activated session with sendRequestAsync so a drain cannot park on a future session and leave a queue paused.

Where no transfer result advertises the retransmission queue, the new reconnect drain is bounded by the deepest Publish pipeline Milo has permitted, plus the terminating request that receives Bad_MessageNotAvailable. The base branch resumed normal Publish handling without performing this drain; the bound covers every response Milo could have left outstanding while remaining finite. The pipeline high-water mark is recorded when the target is chosen, before asynchronous request failures, Session-inactive callbacks, or Subscription removal can make the old depth appear smaller.

A failed TransferResult, or an expected service-level transfer failure, resets and unregisters the affected Subscription before the transfer future can move the Session FSM through initialization and into Active. The cleanup path is final and non-overridable; the public notifyTransferFailed() callback is dispatched afterward, so a blocking or throwing application override cannot delay reconnect or let recovery observe a Subscription that was not transferred.

Acknowledgements

Acknowledgements are built from the sequence numbers the client actually received or successfully recovered. Previously the acknowledgement set was replaced with the server's entire availableSequenceNumbers, which §5.14.5.2 describes as diagnostic information about the retransmission queue, not evidence of receipt — so a failed Republish could be followed by an acknowledgement telling the server to delete a message the client never received and could still have recovered.

A duplicate copy of a NotificationMessage queues at most one acknowledgement for its (SubscriptionId, sequenceNumber). Without the duplicate guard, a second acknowledgement for a message the first one already deleted produced recurring spurious Bad_SequenceNumberUnknown warnings.

Acknowledgements drained into a PublishRequest are restored if that request fails, so they are not lost when the request carrying them does not reach the server.

A gap abandoned as lost data acknowledges the advertised sequence numbers it gives up on, per §5.14.7.1, so the server's retransmission queue drains rather than holding them for the life of the subscription.

PublishResponse.results is inspected and acknowledgement failures are logged; it was previously ignored, so Bad_SequenceNumberUnknown and Bad_SubscriptionIdInvalid were invisible.

Subscription identity and lifecycle

PublishingManager entries carry an immutable SubscriptionId and a registration flag. Entries previously held the mutable OpcUaSubscription and asked it for its current id, so a Bad_Timeout status notification queued before a reset would unregister and reset a subscription created after it — leaving a live server-side subscription with no client registration and no way to delete it, since delete() requires serverState. Response processing now carries the entry resolved at receipt rather than re-resolving by id, which also covers a server reusing a SubscriptionId, and the Bad_Timeout teardown runs only if the entry is still registered and the object still holds the incarnation the entry was registered under. Messages discarded by that guard raise onNotificationDataLost.

If registering a new Subscription displaces an older entry under a reused SubscriptionId, the displaced entry is marked unregistered immediately. Work already queued for it is then discarded instead of being applied indefinitely as if the old Subscription still existed.

lifecycleLock serializes create, modify, delete, setPublishingMode, and reset, and is held only for state transitions — never across a service call. Each transition validates and claims under the lock, performs the service call with it released, then applies the result under it again, comparing an incarnation counter so a superseded result is discarded. Holding the lock across the call deadlocks: reset() takes the same lock and can be reached from notifyStatusChanged(Bad_Timeout) on the delivery queue, a transport-executor thread, so a blocked create() stalls the delivery queue for a full request timeout on any executor, and on a bounded executor can exhaust the pool that would complete the response releasing the lock. The parameter setters take the lock for their check-then-act on modifications and syncState; without it a setter racing reset() could leave syncState UNSYNCHRONIZED with serverState null, a combination every transition answers Bad_InvalidState.

Before this PR, transfer-failure reset and notification were dispatched together without making TransferSubscriptions completion wait for the reset, so the Session FSM could reach Active while a failed Subscription was still registered. A final handleTransferFailure() now performs reset and unregistration synchronously before the FSM proceeds, then dispatches the overridable notification separately.

If a reset() supersedes an in-flight create(), the subscription the server already created is deleted rather than abandoned.

createAsync, modifyAsync, deleteAsync, and setPublishingModeAsync are composed from createSubscriptionAsync, modifySubscriptionAsync, deleteSubscriptionsAsync, and setPublishingModeAsync; each blocking method awaits the corresponding stage, so the two forms cannot diverge in their state handling. Previously all four wrapped their blocking counterparts in supplyAsyncCompose on the transport executor and self-deadlocked on a single-thread executor. Queued transitions wait on handed-over CompletableFuture slots rather than by blocking a thread, and the hand-off is dispatched through the executor rather than completed inline, which would otherwise unwind with recursion depth equal to the number of queued synchronously-completing transitions.

The stage returned to a lifecycle caller is now a dependent view of the internal transition stage. Cancelling it, or completing it early with orTimeout(), does not cancel the internal completion that owns and eventually releases the transition slot; later lifecycle operations therefore cannot be wedged by caller-side cancellation.

create() publishes serverState before syncState. Both classes document their threading contract.

MonitoredItem deletion

deleteMonitoredItems() removes items from the pending-deletion set only when the delete produced an operation-level result — Good, or Bad_MonitoredItemIdInvalid, the only other operation result §5.13.6.4 Table 77 defines — and in both cases the item is gone server-side. Previously the set was cleared before the service call, so a service fault, a null serverState, or unresolvable MonitoredItemIds orphaned the item: it could not be retried, could not be re-added (addMonitoredItem ignores an item holding a handle it does not own), and isMonitoredItemsSynchronized() reported true while the server kept publishing for it.

MonitoredItem add/remove bookkeeping and application of delete results are ordered under one lock. If an item is added back while its server deletion is in flight, the response clears the obsolete server state but restores the ClientHandle under which the item was re-added, leaving it mapped and ready to be created exactly once.

reset() resets and clears the pending-deletion set. Items left in it retained their MonitoredItemIds and SYNCHRONIZED state, and a later deleteMonitoredItems() sent those ids against the new subscription. Milo's server allocates MonitoredItemIds per subscription starting at 1, so a stale id generally matches an unrelated item in the replacement subscription and the delete succeeds against the wrong item.

Publish pipeline accounting

The getTimeoutHint() overflow guard clamps the computed timeout. It previously assigned a different variable, so an out-of-range value reached uint() and threw NumberFormatException after the pending permit was taken and before any completion handler was registered, leaking the permit; repeated attempts pinned the counter and stopped Publish traffic. Request construction now releases its permit on every synchronous failure path, as do the rejected-task paths at receipt and on the delivery queue. handlePublishFailure no longer calls UaException.extract with a null throwable, which threw and leaked the permit when a future completed with a non-PublishResponse value.

Refill after Bad_NoSubscription is suppressed only while the subscription set is still empty, so deleting and recreating the last subscription does not leave the pipeline with no trigger to refill it. Refill also runs when the outstanding count reaches zero, so a Bad_TooManyPublishRequests returning the last outstanding request does not halt Publish traffic until an unrelated event.

The ceiling applied after Bad_TooManyPublishRequests recovers rather than persisting for the life of the client. It rises one step after PROBE_COOLDOWN_BASE_SUCCESSES successful round trips; a probe that draws another refusal clamps the ceiling again and doubles the next cooldown, up to PROBE_COOLDOWN_MAX_SUCCESSES. One probe is in flight at a time and the ceiling never exceeds min(subscriptionCount + 1, maxPendingPublishRequests). A server that consistently refuses therefore sees a probe count growing logarithmically in successful responses. §5.14.5.1's requirement not to issue another Publish request before an outstanding one returns is unaffected: recovery is separate from the one-replacement-per-return handling of the refusal itself.

The learned ceiling is tagged with the Session activation that produced it. A delayed Bad_TooManyPublishRequests from an old Session cannot clamp the replacement Session, and delayed successful responses from the old Session cannot pay down the replacement's cooldown or trigger an early probe.

Notification delivery

Listener invocations are individually isolated, so one throwing listener does not suppress the others. A throwing SubscriptionListener previously prevented every monitored-item listener from seeing that notification, a throwing item listener prevented later items from being notified, and the throw escaped the NotificationData loop, discarding the remaining elements of the same NotificationMessage. The lists passed to the subscription listener are immutable; the same mutable lists were previously reused to drive the per-item fan-out, so listener mutation could suppress it or mispair items with values.

onKeepAliveReceived and onStatusChanged are invoked inline, as data and event callbacks already were. They were re-enqueued onto the delivery queue they were already running on, which placed them behind an already-queued later notification and released Publish backpressure before they ran. onNotificationDataLost and onTransferFailed remain enqueued, as they are called from off the delivery queue.

Part 4 does not specify client-side callback ordering; this is an SDK delivery-contract change.

Watchdog

modify() re-arms the watchdog after installing the revised publishing interval and counts. Re-arming first uses the pre-modify ServerState, so a longer revised interval leaves the timer armed for the old, shorter delay.

A session fault suspends the watchdog rather than destroying it. cancelWatchdogTimer() deregisters the SessionActivityListener and clears the timer, and only create() constructs one, so a Bad_SessionClosed or Bad_SessionIdInvalid fault left the watchdog permanently dead on a subscription that then reconnected and transferred successfully. Suspension cancels the pending expiry and leaves the listener registered; the destructive path remains for subscription teardown.

The pause is activation-scoped: a straggling Session failure cannot run after replacement-session recovery and disarm the watchdogs that recovery just re-armed.

The timer is re-armed by resumePublishing() rather than by onSessionActive, because a PublishResponse is the only event that feeds it and none can arrive while reconnect recovery holds Publish suspended — so a recovery longer than the watchdog delay fired a spurious onWatchdogTimerElapsed on a healthy subscription.

resetWatchdogTimer() also refuses to arm while publishing is suspended. Creation registers the Subscription with PublishingManager before its final watchdog check, so either the recovery's registry sweep sees the new Subscription or the final check sees publishing resumed; the watchdog cannot fall between those two paths and remain unarmed.

Timer transitions are serialized behind a lock with a terminal cancelled flag and an epoch validated inside the expiry callback, so an expiry already running when cancel(false) arrives is recognized as stale, and a scheduleNext() that loses a race cancels its own future rather than leaking it.

Subscription parameters

OpcUaSubscription(OpcUaClient, double) derives MaxKeepAliveCount and LifetimeCount from the given publishing interval. Field initializers run before the constructor body, so both were derived from the 1000 ms default: new OpcUaSubscription(client, 100.0) requested MaxKeepAliveCount 10 and LifetimeCount 50 instead of 100 and 500, making the keep-alive cadence and the subscription lifetime wrong by requestedInterval / 1000 and carrying the same error into the watchdog delay.

A failed modify() restores the pending Modifications rather than clearing them before the service call, where a retry found them null. Values requested while the failed call was in flight take precedence over the restored ones.

Behavior changes

  • Republish is issued for sequence 1 after keep-alive(1) followed by data(2). Commit 33438b732 (Set lastSequenceNumber on the first PublishResponse #1401) suppressed this. Per §5.14.1.1 a keep-alive advertising 1 means NotificationMessage 1 has not been sent, so when data 2 arrives, 1 is missing. Milo's server sends keep-alive(1) then data(1) and is unaffected. Against a server that consumes a sequence number for keep-alives the cost is one failed Republish and one onNotificationDataLost per subscription.
  • Publish does not resume immediately on session activation. The base branch resumed without performing the reconnect Republish drain. The new per-subscription drain completes first and, when the server does not provide an exact retransmission list, covers the deepest Publish pipeline Milo permitted plus the terminating probe.
  • A second concurrent create() fails with Bad_InvalidState rather than creating and leaking a second server-side subscription.
  • isMonitoredItemsSynchronized() reports false after a failed MonitoredItem delete, since the deletion stays queued.
  • getSyncState() returns the pre-call value while a transition is in flightINITIAL while creating, UNSYNCHRONIZED while modifying. SyncState remains a three-value public enum; this is documented on the method.
  • Response processing is per-subscription rather than one client-wide queue.
  • Lifecycle transitions are FIFO and no longer contend on a monitor.

Testing

mvn clean verify passes, with spotless and checkstyle clean.

New tests cover: the initial keep-alive masking a lost NotificationMessage; a gap ending in repeated keep-alives; rollover with and without a missing boundary message; reordered Publish completion via an injected reordering executor; Republish and all four async lifecycle methods on a single-thread transport executor; timeout-hint overflow and pending-permit recovery; delete-and-recreate of the last subscription with delayed Bad_NoSubscription callbacks; Bad_TooManyPublishRequests against a server cap below the attempted window, including ceiling recovery and the probe bound; reset and recreate with stale delivery and Bad_Timeout tasks still queued; reconnect asserting no Publish precedes the Republish drain, including with a caller parked on the session future and across both reactivation and replacement-session paths; the watchdog during a held drain; modify failure followed by retry; watchdog rescheduling after revised parameters; listener isolation and callback ordering; MonitoredItem deletion failure and stale-id reuse across a reset; abandoned-gap acknowledgement; and queued lifecycle transition hand-off.

Additional regression tests cover caller-cancelled lifecycle futures; watchdog registration racing reconnect resume; a MonitoredItem re-added during an in-flight delete; stale recovery versus replacement-Session transfer data; the configured Publish pipeline depth and terminating probe; stale activation failures and successes versus the replacement Session's Publish ceiling; and transfer-failure cleanup ordering with blocking and throwing overrides.

SequenceNumbers has server-free unit tests for successor, predecessor, forward distance, and the wrap-to-1 boundary.

Two fixed sub-defects have no test: the serverState-before-syncState publication order, where the two statements are adjacent with no interposable call and every reader null-checks serverState; and the unresolvable-MonitoredItemIds branch of the deletion path, reachable only via a reset() racing an in-flight deleteMonitoredItems().

The far-behind-sequence and rollover tests seed lastSequenceNumber reflectively, because it advances one message at a time and there is no wire path to a value far ahead of where a renumbering restarts. Everything after the seed is driven by real responses over a real connection.

A test seam supports the above: ScriptableSubscriptionServiceSet scripts the exact Publish and Republish responses the client observes, parks pipelined Publish requests, and captures the acknowledgements the client sends; DelegatingSubscriptionServiceSet and DelegatingMonitoredItemServiceSet allow individual service handlers to be gated or failed; and TestClient exposes the transport config builder for injecting executors.

🤖 Originally generated with Claude Code; updated after subsequent code review.

kevinherron and others added 23 commits July 24, 2026 05:53
Introduce a test seam for deterministically driving the client's
PublishingManager and OpcUaSubscription. ScriptableSubscriptionServiceSet
(built on a new DelegatingSubscriptionServiceSet) lets a test script the
exact Publish/Republish responses the client observes — sequence numbers,
keep-alives, gaps, rollover — while parking pipelined Publish requests and
capturing the acknowledgements the client sends back.

This provides the foundation for the deterministic bug-reproduction tests
targeting publishing-manager reliability. PublishScriptHarnessTest is a
smoke test proving the seam delivers a scripted keep-alive and captures the
resulting acknowledgement.

Also add a TestClient.create overload that exposes the transport config
builder, so tests can inject a controllable ExecutorService.
PublishingManager.getTimeoutHint() derives the Publish request's timeoutHint
from revisedPublishingInterval * revisedMaxKeepAliveCount * maxPendingPublishes
* 1.5. timeoutHint is a UInt32 (OPC UA Part 4, 7.32), and the overflow guard at
PublishingManager.java:395 assigned the wrong variable:

    if (Double.isInfinite(timeoutHint) || timeoutHint > UInteger.MAX_VALUE) {
      maxKeepAlive = 0d;   // never read again
    }
    return uint((long) timeoutHint);   // still the oversized value

The oversized value therefore still reached uint(), which range-checks and
throws an unchecked NumberFormatException:

    java.lang.NumberFormatException: Value is out of range : 10800000000
      at UInteger.rangeCheck(UInteger.java:210)
      at UInteger.valueOf(UInteger.java:170)
      at Unsigned.uint(Unsigned.java:140)
      at PublishingManager.getTimeoutHint(PublishingManager.java:405)
      at PublishingManager.sendPublishRequest(PublishingManager.java:152)

maybeSendPublishRequests() (PublishingManager.java:106-112) increments
pendingCount *before* calling sendPublishRequest(), and getTimeoutHint() is
called inside sendPublishRequest(). The throw unwound past the already-taken
permit with no request sent and no completion handler registered, so nothing
ever decremented it. It escaped into the discarded dependent future of the
whenComplete() in maybeSendPublishRequests(), so subscription.create()
returned normally and nothing was logged. Each attempt ratcheted pendingCount
up until it hit maxPendingPublishes, after which Publish traffic stopped for
the remainder of the Session.

The trigger needs no misbehaving Server: a Server may revise the publishing
interval and max keep-alive count freely (Part 4, 5.14.2.2), and nothing bounds
their product.

Fixed by assigning the correct variable and clamping to UInteger.MAX_VALUE
rather than substituting 0, which would disable the Client-side request
timeout entirely and reintroduce a permit leak whenever a Server never
answered. sendPublishRequest() now also wraps request construction and
dispatch in a try/catch that releases the caller's permit and logs before
returning, so any synchronous failure on that path can no longer stall
publishing.

PublishTimeoutHintOverflowTest drives a Server that rewrites its
CreateSubscription response to advertise a 3 600 000 ms revised publishing
interval and a revised max keep-alive count of 1000 (product 1.08e10 after the
in-flight and safety factors, well past UInt32). It asserts that a Publish
request is still sent after the Subscription is created, that
sendPublishRequest() does not throw, and — as a control against a vacuous
result — that ordinary revised parameters also produce a Publish request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpcUaSubscription.modify() took the pending Modifications and cleared the
field before calling the ModifySubscription service:

    Modifications diff = modifications;
    modifications = null;          // cleared BEFORE the service call
    assert diff != null;
    ModifySubscriptionResponse response = client.modifySubscription(...);

If the service call threw, syncState stayed UNSYNCHRONIZED but the
modifications field was already null, so the caller's requested parameters
were gone. The next modify() re-entered the UNSYNCHRONIZED branch, read a
null diff and blew up at OpcUaSubscription.java:226 with an AssertionError
(surefire enables assertions), or, with assertions disabled, at
OpcUaSubscription.java:231 with a NullPointerException on
diff.publishingInterval(). No concurrency was required to hit this: a single
failed modify() followed by a retry was enough. A transient service failure
is exactly the case a caller is expected to retry, and per OPC UA Part 4
5.13.3 ModifySubscription only revises the parameters the request carries, so
losing the diff means the retry could never apply the requested values.

The modify service call is now wrapped in a try/catch that restores the diff
as the pending Modifications before rethrowing, via the new private
restorePendingModifications(). Modifications requested while the failed call
was in flight take precedence over the restored values, which preserves the
existing behavior of clearing the field before the call so that concurrent
setter calls are not swallowed on success.

SubscriptionModifyRetryTest.modifyAfterFailedModifyRetriesTheSamePendingModifications
uses the DelegatingSubscriptionServiceSet seam to fail the first
ModifySubscription with Bad_TooManyOperations and record every request the
server receives. It asserts the first attempt really did request the new
PublishingInterval, that the Subscription is still UNSYNCHRONIZED after the
failure, and that the second modify() succeeds, reaches SYNCHRONIZED, and
sends the same requested PublishingInterval rather than falling back to the
server's current value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpcUaSubscription's field initializers run before any constructor body, so
maxKeepAliveCount and lifetimeCount were always computed from
DEFAULT_PUBLISHING_INTERVAL (1000 ms):

  OpcUaSubscription.java:98-101
    publishingInterval = DEFAULT_PUBLISHING_INTERVAL;                 // 1000.0
    maxKeepAliveCount  = calculateMaxKeepAliveCount(publishingInterval,
                             DEFAULT_TARGET_KEEP_ALIVE_INTERVAL);     // 10
    lifetimeCount      = calculateLifetimeCount(maxKeepAliveCount);   // 50

The OpcUaSubscription(OpcUaClient, double) constructor
(OpcUaSubscription.java:125-130) then overwrote only publishingInterval, leaving
both counts at their 1000 ms-derived values. create()
(OpcUaSubscription.java:149-156) recomputes them only when they are null, so it
did not rescue the case.

calculateMaxKeepAliveCount is max(1, ceil(target / max(1, interval))) and
calculateLifetimeCount is 5 * maxKeepAliveCount
(OpcUaSubscription.java:1450-1468); with DEFAULT_TARGET_KEEP_ALIVE_INTERVAL of
10000 ms the intended invariant is "a keep-alive roughly every 10 s". Instead,
new OpcUaSubscription(client, 100.0) put RequestedMaxKeepAliveCount=10 and
RequestedLifetimeCount=50 on the wire in CreateSubscription rather than 100 and
500, so:

  - the keep-alive cadence became 100 ms x 10 = 1000 ms instead of
    100 ms x 100 = 10,000 ms (10x too fast);
  - the Subscription lifetime became 100 ms x 50 = 5,000 ms instead of
    100 ms x 500 = 50,000 ms (10x too short), so the Server may terminate the
    Subscription far sooner than intended (Part 4, 5.14.2.2: the lifetime count
    is the number of publishing cycles that may expire without the Server
    receiving a Subscription Service request from the Client);
  - the client watchdog inherited the same 10x error, because
    WatchdogTimer.scheduleNext() (OpcUaSubscription.java:1629-1636) derives its
    delay from the revised values the Server echoes back:
    publishingInterval x (maxKeepAliveCount + 1) x watchdogMultiplier
    = 100 x 11 x 1.5 = 1,650 ms instead of 100 x 101 x 1.5 = 15,150 ms, risking
    spurious watchdog trips.

Both errors scale as requestedInterval / 1000, so they get worse the faster the
requested PublishingInterval is.

Fixed by having the two-arg constructor delegate to the one-arg constructor and
then call setPublishingInterval(), which is already the single site that derives
the counts and already honours the lifetimeAndKeepAliveCalculated flag. That
keeps one derivation site instead of duplicating the calculation, which is the
drift that caused the defect. The flag defaults to true and cannot be set to
false before the constructor runs, so no explicitly configured count can be
clobbered, and setPublishingInterval() no-ops on the modifications/syncState
bookkeeping while syncState is INITIAL. setPublishingInterval() itself was
checked and was already correct; this was a single-site defect.

New test SubscriptionKeepAliveDerivationTest captures the CreateSubscription
request the client actually puts on the wire, using a
DelegatingSubscriptionServiceSet subclass registered on every endpoint, and
asserts that new OpcUaSubscription(client, 100.0) requests
PublishingInterval=100, MaxKeepAliveCount=100 and LifetimeCount=500. A second
control test drives the same expectations through the already-correct
setPublishingInterval() path so the expected numbers cannot be vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two coupled defects in the client-side notification fan-out, both living in
OpcUaSubscription's notify* methods.

Listener failure isolation was incomplete. notifyDataReceived and
notifyEventsReceived called the application's SubscriptionListener and then each
MonitoredItem's listener with no guard, so the first callback to throw abandoned
every sink behind it. Worse, the throw escaped
PublishingManager.deliverNotificationMessage, whose loop over
NotificationMessage.notificationData then skipped every remaining element; the
only containment was TaskQueue.TaskWrapper.run()'s catch (Throwable), so
publishing carried on while the notification was silently half-delivered. A
throwing DataValueListener produced:

  at OpcUaMonitoredItem.notifyDataValueReceived(OpcUaMonitoredItem.java:486)
  at OpcUaSubscription.notifyDataReceived(OpcUaSubscription.java:1515)
  at PublishingManager.deliverNotificationMessage(PublishingManager.java:351)

and the StatusChangeNotification that followed the DataChangeNotification in the
same NotificationMessage was dropped. The two ArrayLists were also handed to the
SubscriptionListener directly and then re-read by the per-item fan-out, so
items.clear() suppressed the fan-out entirely and items.remove(k) shifted the
items while the values kept their positions, pairing items with the wrong
DataValue.

Every callback invocation now goes through deliverToListener, which catches and
logs any Exception, and the SubscriptionListener receives unmodifiable views of
the Lists the fan-out iterates.

Callbacks were also delivered out of order. deliverNotificationMessage always
runs on the Subscription's delivery queue, but notifyKeepAliveReceived and
notifyStatusChanged re-enqueued their callback onto that same queue while the
data and event callbacks ran inline. TaskQueue is FIFO with one concurrent task,
so the re-enqueued callback landed at the tail: with a keep-alive delivery
running and a data delivery already queued, the application observed the data
notification before the keep-alive that had preceded it. The extra hop also
released backpressure early -- the submitted task's future completed once the
callback was merely enqueued, so PublishingManager decremented its pending
count and sent another PublishRequest before onKeepAliveReceived had run,
contradicting the synchronous-processing contract documented on
SubscriptionListener. Both callbacks are now invoked inline, inside the delivery
task the pending count waits on, and their javadoc gained the blocking caveat
the data and event callbacks already carried. This is the SDK's own delivery
contract, not a wire requirement: OPC UA Part 4 does not specify client-side
callback ordering.

notifyNotificationDataLost and notifyTransferFailed are called from off the
delivery queue (PublishingManager's processing queue and the session FSM) and
must keep enqueuing onto it; that is now stated in their javadoc.

SubscriptionNotificationDeliveryTest scripts Publish responses through
ScriptableSubscriptionServiceSet and asserts that a throwing
SubscriptionListener still lets every MonitoredItem listener run, that a
throwing item listener abandons neither the items behind it nor the remaining
NotificationData, that the event path behaves the same, that a listener
mutating the notification Lists cannot disturb the item-to-value pairing, that a
keep-alive is delivered before a data notification that arrived after it, and
that no PublishRequest is sent while onKeepAliveReceived is still running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpcUaSubscription.modify() called resetWatchdogTimer() before assigning the
new ServerState built from the ModifySubscription response, so the watchdog was
always re-armed from the pre-modify PublishingInterval and MaxKeepAliveCount.

The watchdog delay is computed in WatchdogTimer.scheduleNext() as
publishingInterval * (maxKeepAliveCount + 1) * watchdogMultiplier, read from
getServerState(). At OpcUaSubscription.java:255 (pre-fix) this.serverState was
still the object captured at :218, so the timer was armed with stale values and
nothing re-armed it afterwards; the only other re-arm is
PublishingManager.java:190, which needs a PublishResponse to arrive.

Concretely, a Subscription at 100 ms / MaxKeepAliveCount 10 arms for
100 * 11 * 1.5 = 1650 ms. Modifying it to 2000 ms / 10 (correct delay
2000 * 11 * 1.5 = 33000 ms) left it armed for 1650 ms, so
onWatchdogTimerElapsed fired roughly 31 seconds before the Server's next
keep-alive was even due. The mirror case is equally broken: modifying from a
slow interval to a fast one left the watchdog armed for the old, longer delay,
delaying detection of a Subscription that had actually gone silent. The revised
values are the ones the Server committed to in its ModifySubscription response
(OPC UA Part 4 section 5.14.3), so they are what the watchdog must track.

Fixed by moving the resetWatchdogTimer() call below the this.serverState
assignment, so the timer is scheduled from the revised parameters.

SubscriptionWatchdogRearmOnModifyTest covers both directions. Every Publish is
parked by ScriptableSubscriptionServiceSet so no PublishResponse can mask the
arming done by modify(), and the revised PublishingInterval and
MaxKeepAliveCount are asserted before each timing assertion so a Server that
revises the requested values fails loudly.
modifyToSlowerIntervalDoesNotElapseWatchdogAtThePreModifyInterval asserts the
watchdog does not elapse within 6 s after modifying 100 ms -> 2000 ms;
modifyToFasterIntervalElapsesWatchdogAtThePostModifyInterval asserts it does
elapse within 6 s after modifying 2000 ms -> 100 ms, which also proves the
first test is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two coupled defects in the client Subscription watchdog lifecycle.

1. A Session fault destroyed the watchdog permanently.

PublishingManager.sendPublishRequest() handled a Publish failing with
Bad_SessionClosed or Bad_SessionIdInvalid by calling
OpcUaSubscription.cancelWatchdogTimer() on every Subscription. That method is
the teardown path: it de-registers the WatchdogTimer from the client's
SessionActivityListeners, cancels the pending expiry, and nulls the
watchdogTimer field. The only place a WatchdogTimer is ever constructed is
OpcUaSubscription.create(), which is guarded by syncState == INITIAL and throws
Bad_InvalidState otherwise, so a still-SYNCHRONIZED Subscription can never get
another one.

Both status codes are classified as Session errors by
SessionFsmFactory.SessionFaultListener, which drives the FSM through
re-activation; TransferSubscriptions then keeps the Subscription alive on the
Server and Publish traffic resumes, because PublishingManager's own
onSessionActive listener is still registered. Only the watchdog is lost. The
concrete failure was therefore: transient Session fault -> watchdog cancelled
and de-registered -> Session re-activates -> TransferSubscriptions succeeds ->
Subscription runs unsupervised for the rest of its life, so a Server that later
stops honouring the keep-alive interval it promised in its CreateSubscription
response (Part 4, 5.13.2) is never reported to the application.

Fixed by adding OpcUaSubscription.pauseWatchdogTimer(), which cancels the
pending expiry but leaves the WatchdogTimer registered and reachable, and
calling that from the Session-fault path. WatchdogTimer.onSessionActive()
re-arms it when the Session comes back. cancelWatchdogTimer() is unchanged and
still used by reset() for genuine Subscription teardown; the distinction now
encoded is temporary Session unavailability versus permanent removal.
WatchdogTimer.onSessionInactive() likewise now pauses instead of cancelling,
which is what it always meant.

2. The timer's state transitions were not atomic.

WatchdogTimer held its pending expiry in an AtomicReference and performed a
read/cancel/schedule/store sequence across it. reset() also used get() rather
than getAndSet(). The outer wrappers resetWatchdogTimer() and
cancelWatchdogTimer() are synchronized on the OpcUaSubscription, but the
Session callbacks bypassed them and called the inner reset()/cancel()
directly, so the two entry points shared no lock at all.

They run concurrently in practice: SessionFsmFactory dispatches activity
listeners on the transport executor, and PublishingManager completes Publish
responses with whenCompleteAsync on that same executor before calling
resetWatchdogTimer(). It defaults to Stack.sharedExecutor(), an unbounded
cached pool.

The losing interleaving: T1 in reset() reads sf0, cancels it, and enters
scheduleNext(), which creates sf1; T2 in cancel() does getAndSet(null) -> sf0
and re-cancels the already-cancelled sf0; T1 then stores sf1. sf1 is live after
a cancel that happened after it was created, and it fires, telling the
application a healthy Subscription is dead. Via cancelWatchdogTimer() the
WatchdogTimer was also unreachable by then, so nothing could ever cancel sf1.
Independently, two concurrent reset() calls both cancelled sf0 and both
scheduled, leaking whichever future lost the store.

Fixed by making WatchdogTimer self-synchronized on a private lock covering
every transition, with a terminal cancelled flag so a cancelled timer never
arms again, and an epoch captured when each expiry is scheduled and re-checked
inside notifyWatchdogTimerElapsed(). The epoch closes the remaining window
where an expiry has already started running and cancel(false) cannot stop it.
Nothing under the new lock reaches back for the OpcUaSubscription monitor -
getServerState() and watchdogMultiplier are volatile reads - so the lock order
remains one-way.

New tests:

SubscriptionWatchdogSessionFaultTest answers the first Publish with
Bad_SessionIdInvalid and parks every subsequent one, so no Publish response can
re-arm the watchdog. It asserts the fault took the Session out of Active and
that it was re-activated, then asserts onWatchdogTimerElapsed does fire after
re-activation, counting only expiries observed after the return to Active. Its
control asserts the watchdog fires in the same fixture with no fault at all.

SubscriptionWatchdogCancelRaceTest forces the interleaving instead of racing
for it: the client is given a ScheduledExecutorService that suspends the
re-arming thread inside schedule(), after the current future was cancelled and
before the new one is stored, and releases onSessionInactive() into that
window. It asserts a cancelled watchdog does not fire. Two controls bound it:
the same gated re-arm with no cancel does fire, and the same two operations
performed sequentially do cancel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sequence-number accounting in PublishingManager.processPublishResponse had
three defects that let NotificationMessages be lost silently. They share one
root cause -- what the sequence number of a keep-alive means -- and one fix,
and cannot be separated: the "receivedSequenceNumber == 1" disjunct that caused
the first defect was also the only thing that ever un-stuck the state after a
rollover, so removing it requires the modular arithmetic to land with it.

WHAT THE DEFECTS WERE (line numbers are from the parent commit)

1. An initial keep-alive masked the loss of NotificationMessage 1.
   PublishingManager.java:300 set lastSequenceNumber for any message numbered 1,
   keep-alive or not. Part 4 §5.14.1.1 says a keep-alive "does not contain any
   Notifications and ... contains the sequence number of the next
   NotificationMessage that is to be sent", so a keep-alive carrying 1 means
   message 1 has *not* been sent. Trace: keep-alive(1) set lastSequenceNumber =
   1; NotificationMessage 1 was then sent and lost; message 2 arrived with
   expected == 2, so "received > expected" was false, no gap was detected, and
   message 1 was never requested via Republish. Aggravating it, the block at
   PublishingManager.java:307-314 refilled the acknowledgement list verbatim
   from availableSequenceNumbers, so the client acknowledged sequence 1 -- a
   message it never received. Part 4 §5.14.5.2: "The Server may delete the
   Message with this sequence number from its retransmission queue", so the
   acknowledgement destroyed the only remaining copy.

2. A gap ending in a keep-alive was recovered repeatedly. The loop bounds at
   PublishingManager.java:271-273 were right, but PublishingManager.java:297
   then advanced lastSequenceNumber to the *first* missing sequence number
   instead of the last, and the "== 1 || !isKeepAlive" guard declined to correct
   it on the keep-alive path. With lastSequenceNumber = 5 and a keep-alive
   carrying 10, the first pass republished 6,7,8,9 and left lastSequenceNumber =
   6, so the next keep-alive republished 7,8,9, then 8,9, then 9: O(n^2)
   blocking Republish calls for an n-message gap. It terminated, but the first
   pass had already acknowledged 6-9, so the Server had purged them and every
   later cycle failed, raising up to n-1 spurious notifyNotificationDataLost()
   callbacks -- or, if the acknowledgements had not landed yet, delivering the
   same notifications to the application twice.

3. None of the arithmetic wrapped. PublishingManager.java:257 computed
   "lastSequenceNumber + 1", which yields 4294967296 -- a value no
   NotificationMessage can carry -- and PublishingManager.java:268 compared with
   a plain ">". With lastSequenceNumber = 0xfffffffe and message 0xffffffff
   lost, the wrapped message 1 compared as behind and the gap vanished; with
   lastSequenceNumber = 0xffffffff, no sequence number could ever again compare
   as ahead and gap detection was dead for the life of the Subscription. Note
   that delivery is unconditional either way: what these defects corrupt is the
   gap-detection state, and the acknowledgement block then acknowledged messages
   on the strength of it.

HOW IT WAS FIXED

New package-private SequenceNumbers holds the arithmetic. Part 4 §5.14.1.1 --
"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" -- makes sequence numbers a
cycle over 1..0xFFFFFFFF, so successor(0xFFFFFFFF) is 1 and predecessor(1) is
0xFFFFFFFF. This is deliberately *not* the plain Counter of Part 4 §7.8, whose
successor of 0xFFFFFFFF is 0; using §7.8 here would be wrong by one at every
rollover.

"Nothing accounted for yet" is represented by keeping the existing
lastSequenceNumber = 0 sentinel, which §5.14.1.1 guarantees is not a legal
sequence number. successor(0) is defined as 1, the sequence number of the first
NotificationMessage a Subscription sends, so the initial state needs no special
case; predecessor(0) and every other operation reject it. A NotificationMessage
carrying an illegal sequence number is now logged and delivered, but leaves the
accounting untouched rather than feeding a nonsensical value into it.

processPublishResponse now applies one rule for both defects 1 and 2:
lastSequenceNumber = isKeepAlive ? predecessor(received) : received. A
keep-alive accounts for everything through the predecessor of the sequence
number it carries and is not evidence that that sequence number arrived.

Acknowledgements are no longer refilled from availableSequenceNumbers. The
client now acknowledges exactly the NotificationMessages it took delivery of:
the received message when it is not a keep-alive, plus each message recovered
by Republish. Acknowledging a message that never arrived is what turns a
detected gap into permanent data loss (§5.14.5.2).

Recovery is now bounded. With modular distance, a stale or corrupt sequence
number can be up to 2^31 steps "ahead", and the recovery loop issues one
synchronous Republish per missing message on the shared processing queue. The
gap is therefore capped at the number of messages the Server says it is holding
in availableSequenceNumbers, falling back to 64 when it advertises none; a
larger gap is logged, reported through notifyNotificationDataLost(), and
resynchronized to the received sequence number instead of iterated. Republish
recovery remains synchronous; making it non-blocking is a separate change.

ON THE #1401 INTEROP CASE

Commit 33438b7 added the "== 1" disjunct to avoid "an unnecessary Republish
call when a Server sends an empty (keep-alive) first PublishResponse followed by
a second PublishResponse once it has acquired initial values". Milo's own Server
sends keep-alive(1) then data(1) -- its keep-alive reports the next sequence
number without consuming it -- and that trace is unchanged here: expected == 1,
received == 1, no gap. The trace the disjunct actually suppressed is
keep-alive(1) followed by data(2), and per §5.14.1.1 that means message 1 was
sent and did not arrive, so Republish(1) is correct rather than doomed. Against
a Server that instead consumes a sequence number for its keep-alives, this costs
one failed Republish and one notifyNotificationDataLost() per Subscription; that
is the honest report, since the client cannot account for the message and has no
way to tell that case apart from a genuine loss.

WHAT THE NEW TESTS ASSERT

SequenceNumbersTest (sdk-client, no Server) pins the arithmetic down at the
boundary the wire cannot reach: successor(0xFFFFFFFF) == 1, predecessor(1) ==
0xFFFFFFFF, that no operation ever produces 0, that 1 is "ahead" of an expected
0xFFFFFFFF, that a sequence number more than half a cycle ahead is treated as
stale rather than as an enormous gap, and that walking a gap from 0xFFFFFFFF to
2 enumerates exactly 0xFFFFFFFF and 1.

PublishSequenceRecoveryTest (integration-tests) drives real PublishResponses
through the real client stack with a scripted Server and asserts: a
NotificationMessage lost after an initial keep-alive is requested via Republish
and is never acknowledged; each message missing before a keep-alive is
requested exactly once and a fully recovered gap reports no lost data; and,
seeding lastSequenceNumber reflectively because the rollover is unreachable over
the wire, that a message lost at the rollover boundary is still detected. A
positive control covers a gap between two data messages, which was already
detected before this commit.

TestServer's free-port probe bound InetAddress.getLocalHost(), which resolves to
127.0.1.1 on some hosts while the Server binds 127.0.0.1, so an occupied port
could pass the probe and then fail the real bind with Bad_ConfigurationError. It
now probes the wildcard address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transport goes out of its way to preserve the order the Server sent
NotificationMessages in: AbstractUascClientTransport.handleResponse:146-147
completes PublishResponse futures on a serial ExecutionQueue, whose
concurrency limit of 1 guarantees "tasks run serially and in the order
submitted". PublishingManager then handed its completion handler to
whenCompleteAsync(..., client.getTransport().getConfig().getExecutor()),
which is by default Stack.sharedExecutor() -- an unbounded cached pool.
The dispatches happened in wire order on the ExecutionQueue thread, but
the handlers ran on arbitrary pool threads, so any two of them could
invert before reaching processingQueue.execute().

With lastSequenceNumber == 1 and responses 2 and 3 inverted:

  - 3 is processed first. recoverMissingNotificationMessages sees a gap
    at 2 and issues a Republish for a message the Server already sent
    and the transport already delivered; the recovered copy is delivered
    to the application. lastSequenceNumber becomes 3.
  - 2 is processed next. It is neither expected nor ahead, so no gap is
    detected, but PublishingManager.java:285 assigned lastSequenceNumber
    unconditionally and rolled it back from 3 to 2, and the message was
    delivered a second time.
  - 4 then looks like the far end of a gap at 3, producing a second
    spurious Republish -- and the regression repeats for every message
    that follows, one blocking Republish round trip each.

The same corruption is reachable with no threading involved at all: any
NotificationMessage that arrives after it has already been accounted
for -- a duplicate, or a copy of a message the client had already
recovered via Republish -- rolled the accounting backwards in exactly
the same way.

Both halves are fixed.

Ordering: the completion handler is registered with whenComplete again,
so for a PublishResponse it runs inline on the transport's serial
PublishResponse queue and hands the work straight to processingQueue,
which is itself serial. Wire order therefore survives into processing.
The handler does nothing that can block: only a map lookup, a watchdog
timer reset (a cancel plus a re-schedule, deliberately kept at the point
of receipt because the watchdog exists to detect the Server going
quiet), and the enqueue. The failure branch is moved to
handlePublishFailure and is still dispatched onto the executor, because
it can run on a wheel timer thread or inline on the caller's thread and
it re-enters maybeSendPublishRequests(); that async hop is what 88dd127
("Use whenCompleteAsync when handling PublishResponse") was protecting,
and it is retained.

Idempotence: processPublishResponse now compares the received sequence
number against the expected one using the modular helpers in
SequenceNumbers. A message that is neither expected nor ahead of
expected has already been accounted for, so it is acknowledged -- it was
genuinely received, and Part 4 5.14.5.2 makes an acknowledgement a
statement about receipt -- and then discarded: no gap detection, no
delivery, and lastSequenceNumber is left alone. Its pending-publish
permit is released so the Publish pipeline does not stall, via the new
releasePendingPublish helper that also replaces the two open-coded
copies of that decrement-and-refill pair.

PublishResponseOrderingTest covers both halves. ReorderedInsideTheClient
injects an executor that deterministically inverts two dispatches from a
single ordered source and asserts that Republish is never invoked and
that each NotificationMessage reaches the application exactly once.
StaleNotificationMessage scripts the Server to send sequence 3 before 2
and asserts that the correct Republish for 2 is the only one, and again
that nothing is delivered twice. Two controls guard the fixture: a
genuine gap must still be recorded by the Republish responder, and the
injecting executor must really invert. ScriptableSubscriptionServiceSet
gains getParkedRequestCount() so a test can wait for the client's
Publish pipeline to fill rather than sleep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Republish recovery ran on the queue that processes PublishResponses and
blocked it for a network round trip per missing NotificationMessage --
on a queue shared by every Subscription on the client:

  - PublishingManager.java:78 built one processingQueue for the whole
    client from the transport executor. TaskQueue's default concurrency
    limit is 1, so PublishResponses for every Subscription were
    processed serially on it.
  - PublishingManager.java:434, on that queue, called
    OpcUaClient.republish(), which is republishAsync(...).get()
    (OpcUaClient.java:2621-2628): an unbounded blocking wait.
  - AbstractUascClientTransport.handleResponse completes anything that
    is not a PublishResponse via config.getExecutor().execute(...)
    (AbstractUascClientTransport.java:149) -- the same executor the
    processing queue runs on.

With a single-threaded executor -- an ordinary application choice,
OpcTcpClientTransportConfigBuilder.setExecutor exists for it -- the only
thread that could complete the RepublishResponse was the one waiting for
it, and the deadlock was permanent rather than merely slow:
handleResponse cancels the request timeout
(AbstractUascClientTransport.java:144) before queueing the completion,
so the wheel timer that rescues a Server that never answers cannot
rescue a Server that does. With threads to spare there was no deadlock,
but each missing message still stalled the processing of PublishResponses
for every other Subscription for a full round trip.

Recovery also spent those round trips on NotificationMessages the Server
had just said it no longer holds: it walked the numeric gap and ignored
the availableSequenceNumbers of the response that revealed it.

Recovery is now an asynchronous, per-Subscription state machine.

Each Subscription owns its processing queue, so one Subscription's
recovery no longer holds up another's PublishResponses. The ordering
established by d52122e is preserved: the completion handler still
enqueues inline on the transport's serial PublishResponse queue, and
each per-Subscription queue is serial, so wire order still survives into
processing. While a gap is being recovered that Subscription's queue is
paused rather than blocked, which keeps the recovered NotificationMessages
ahead of the one that revealed the gap and stops a later PublishResponse
overtaking the recovery and rolling the sequence accounting forward
under it.

The Republish requests are chained one at a time through
republishAsync(), and each recovered NotificationMessage is handed to the
delivery queue as it arrives; nothing waits for a round trip. Part 4
5.14.1.1: "In the case of a retransmission queue overflow, the oldest
sent NotificationMessage gets deleted", so a missing sequence number
older than the oldest the Server advertises in availableSequenceNumbers
is gone for good and is reported as lost data instead of costing a round
trip that can only be answered Bad_MessageNotAvailable. A Republish that
does fail with Bad_MessageNotAvailable no longer ends the pass -- the
queue is purged oldest-first, so the messages after it may well still be
there -- but any other failure is the service call itself failing and
abandons the sequence numbers not yet requested rather than repeating a
request that can only fail the same way. The bound on how large a gap is
worth trying to recover, added in 3fbb57c, is unchanged.

RepublishRecoveryTest covers all three defects:

  - BlockedByItsOwnRecovery drives a gap of three NotificationMessages
    through a client whose transport executor has one thread, against a
    Server that answers every Republish immediately, and asserts that all
    three are requested and that the message that revealed the gap is
    delivered. Its control runs the identical script on a cached pool, so
    the executor is isolated as the cause.
  - RecoveryOnTheSharedProcessingQueue holds a Republish response open and
    asserts that a PublishResponse for a second, healthy Subscription is
    still processed while that recovery is in flight.
  - AvailableSequenceNumbers asserts that a Server advertising 3, 4 and 5
    is asked to retransmit 3 and 4 only, and -- as the control against a
    fix that simply stops Republishing -- that one advertising 2, 3, 4
    and 5 is asked for all three missing messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in how PublishingManager handles SubscriptionAcknowledgements once
it has queued them. Line numbers are from the parent commit, b6faed4.

WHAT THE DEFECTS WERE

1. An acknowledgement was abandoned permanently if the PublishRequest carrying
   it failed. PublishingManager.java:143-162 drained every queued sequence
   number into the outgoing request and cleared the queue
   (PublishingManager.java:160). Nothing ever put them back: the failure path,
   PublishingManager.java:234 -> handlePublishFailure at
   PublishingManager.java:254, released the pending-publish permit and re-armed
   the pipeline without them, and the synchronous failure path,
   PublishingManager.java:237, did the same.

   Trace: NotificationMessage 1 arrives, PublishingManager.java:323 queues an
   acknowledgement for it, the next PublishRequest drains it, and that request
   fails -- a timeout, a dropped connection, a transient ServiceFault. The
   client has now decided, silently, never to acknowledge sequence 1. Part 4
   §5.14.7.1: "The Client should acknowledge all Messages in this list for which
   it will not request retransmission." The Server is never told, so it keeps
   message 1 in its retransmission queue and re-advertises it in
   availableSequenceNumbers on every subsequent PublishResponse until the queue
   evicts it. Not data loss, but an unbounded-until-eviction leak on the Server
   and a growing availableSequenceNumbers on every response.

   This was a regression introduced by 3fbb57c. Before it, the ack block
   refilled the queue wholesale from the response's availableSequenceNumbers,
   which incidentally re-queued anything lost this way. 3fbb57c correctly
   removed that refill -- availableSequenceNumbers is diagnostic data, not proof
   of receipt, and copying it acknowledged messages the client never received --
   but it removed the resync without replacing it.

2. The PublishResponse results array was never read. Part 4 §5.14.5.2 defines
   it as the "List of results for the acknowledgements", whose "size and order
   ... matches the size and order of the subscriptionAcknowledgements request
   parameter". It is how a Server reports Bad_SequenceNumberUnknown or
   Bad_SubscriptionIdInvalid for one acknowledgement while the Publish call
   itself succeeds. PublishingManager had no reference to getResults() at all,
   so an acknowledgement the Server refused was indistinguishable from one it
   accepted, and nothing was logged.

HOW IT WAS FIXED

sendPublishRequest now records what it drained, per Subscription, in a list of
DrainedAcknowledgements, and restoreAcknowledgements() puts them back on both
failure paths: at the top of handlePublishFailure, before the permit is released
and before maybeSendPublishRequests() runs, so the next PublishRequest carries
them; and in the synchronous catch block alongside the existing permit release.
Restored sequence numbers go back at the head of the queue, ahead of anything
queued since, because they are older, and a sequence number already queued again
is not added twice.

The invariant 3fbb57c established is preserved: the only things that ever
enter the queue are the received message when it is not a keep-alive
(PublishingManager.java:323) and each message recovered by Republish
(PublishingManager.java:655). Nothing is copied from availableSequenceNumbers.
Restoring only moves an entry back to where it was.

Re-queuing is safe in the weaker failure case, where the request did reach the
Server and only the response was lost: a repeated acknowledgement is answered
with a per-acknowledgement result, not a fault. Milo's own Server does exactly
that -- SubscriptionManager.java:1416 calls Subscription.acknowledge(), which
returns Bad_SequenceNumberUnknown (Subscription.java:802) when the message is no
longer in availableMessages -- and that result is now logged rather than
silently dropped.

The drain loop also no longer discards acknowledgements for a Subscription whose
subscriptionId is absent. The old code cleared the queue inside the lambda
whether or not ifPresent() had produced anything, which is the same defect in
miniature.

reportRefusedAcknowledgements() pairs response.getResults() positionally with
the acknowledgements the request carried and logs each bad result at WARN with
its subscriptionId and sequenceNumber. A length mismatch is logged at DEBUG and
only the common prefix is paired, since a Server that returns a short or empty
results array is not something to warn about on every response.

A refused acknowledgement is reported and then forgotten, never re-queued. Both
codes a Server uses here are statements that there is nothing left to
acknowledge -- Bad_SequenceNumberUnknown means it is not holding a
NotificationMessage with that sequence number, Bad_SubscriptionIdInvalid that
the Subscription is gone -- so re-queuing could only be refused the same way,
forever, while keeping the entry alive in the client's queue indefinitely.

WHAT THE TESTS ASSERT

PublishAcknowledgementTest (integration-tests) drives a real client against a
scripted Server.

AcknowledgementLostWithItsPublishRequest arms a service set that fails one
PublishRequest -- the one carrying the acknowledgement for sequence 1 -- with
Bad_Timeout before super.onPublish records anything, so the model is the strong
one: the request and its acknowledgement were genuinely lost in flight and the
Server never saw them, which keeps getReceivedAcknowledgements() a sound record
of what the client managed to acknowledge. It then asserts the acknowledgement
for sequence 1 does eventually reach the Server. Its sibling is the control:
the identical script with nothing failed, so the assertion cannot pass
vacuously.

AcknowledgementResults scripts a PublishResponse whose results array refuses the
acknowledgement for sequence 1 with Bad_SequenceNumberUnknown, positionally, and
asserts the client reports it. There is no listener, status, or other API
surface on which a refused acknowledgement could be observed, so it asserts on
log output, with a WARN probe through the same SLF4J binding as a control that
the capture is live. That makes it a weaker test than the others, and it
constrains the diagnostic to WARN or above; it is the best available without
adding API surface for it.

The synchronous restore, in sendPublishRequest's catch block, has no seam to
drive from a test and is covered by inspection only.

ScriptableSubscriptionServiceSet gains a buildPublishResponse overload taking an
explicit StatusCode[] results; the existing method delegates to it with a
GOOD-filled array, so no existing script changes behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in PublishingManager#handlePublishFailure, both about when the
Publish pipeline is refilled once a PublishRequest comes back as a fault
rather than a PublishResponse.

Bad_NoSubscription could strand the pipeline for good
-----------------------------------------------------

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
SubscriptionManager.

An application that deletes its last Subscription and immediately creates
another therefore takes that whole de-queued burst *after* its Subscription
set is non-empty again, and the client had nothing left that would send a
PublishRequest for the new Subscription:

  - OpcUaSubscription#delete() calls deleteSubscriptions and then reset(),
    which calls removeSubscription(); subscriptionDetails is now empty, so
    getMaxPendingPublishes() returns 0 and that refill is a no-op. The two
    permits taken by the in-flight requests are still held.
  - create() calls addSubscription(), whose refill loop
    (PublishingManager.java:118, "for (long i = pendingCount.get(); i <
    maxPendingPublishes; i++)") runs with pendingCount already at 2 and a
    target of min(1 + 1, maxPendingPublishRequests) = 2, so it sends nothing.
  - the de-queued requests then fail with Bad_NoSubscription, and
    handlePublishFailure (PublishingManager.java:391) decremented the count
    but deliberately skipped maybeSendPublishRequests() for that status code.

With pendingCount driven to 0 and no request in flight, none of the remaining
callers of maybeSendPublishRequests() can fire again, and the watchdog only
reports the silence — notifyWatchdogTimerElapsed() invokes the listener and
performs no recovery. The Subscription is created, the Server publishes for
it, and the application never receives anything.

The suppression itself is worth keeping: replacing a request the Server
answered Bad_NoSubscription with another one for the same Subscription set
could only be answered the same way, in a hot loop. What was missing is that
the answer describes the Subscription set the request was sent for. A
subscriptionGeneration counter, bumped whenever a Subscription is registered
or unregistered and recorded by each PublishRequest as it is sent, now tells
the two apart: the refill is suppressed only while the set is unchanged. The
refilled requests carry the new generation, so a Server that keeps answering
Bad_NoSubscription still cannot be hammered.

Bad_TooManyPublishRequests refilled the whole deficit at once
------------------------------------------------------------

Part 4 §5.14.5.1: after this error a Client "shall not issue another Publish
request before one of its outstanding Publish requests is returned". Not
replacing the failed request on the spot already satisfied the letter of
that, but the target stayed at subscriptionCount + 1, so the deficit the
fault opened was still there and the refill loop closed all of it the moment
the next PublishResponse was delivered. With one Subscription: the fault
takes pendingCount 2 -> 1, the next delivered response takes it 1 -> 0, and
two new requests go out — one request returning, two issued, restoring
exactly the outstanding count that had just drawn the fault. The result is a
steady-state oscillation of one extra request and one ServiceFault per
delivery; the pipeline does not stall and no data is lost.

handlePublishFailure now also learns a ceiling from the fault — the number of
requests the Server was in fact holding, i.e. the outstanding count after the
failed one is accounted for, floored at one so the ceiling can never mean
"never send another PublishRequest" — and getMaxPendingPublishes() applies it
on top of the existing target. Recovery is deliberately conservative rather
than timed: the ceiling is cleared when a Subscription is added, because the
client now wants a deeper pipeline than when the ceiling was learned, and on
Session activation, because a new Session has its own queue.

Note that the same clause 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 at all. This matters only against a Server
with an off-by-one or a fixed cap well below the Subscription count, which is
also why the fix is scoped to a learned ceiling rather than a redesign of the
pipelining.

Tests
-----

PublishPipelineRefillTest drives both through 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.

DeletingTheLastSubscription fills the pipeline with two parked requests,
deletes the only Subscription, creates another, then fails the parked
requests with Bad_NoSubscription and asserts that Publish requests are sent
again — once mechanically, and once as what the application sees, by asserting
the re-created Subscription actually receives a NotificationMessage. A control
runs the identical sequence with Bad_InternalError, which the failure handler
never suppressed, proving the fixture can observe a refill at all.

TooManyPublishRequests faults one of two parked requests, asserts no
replacement is sent for it (the control, which passed before this change and
is also the barrier that makes the failure accounted for before the next
response is delivered), then delivers a keep-alive on the survivor and asserts
that exactly one replacement follows rather than two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in the pending-deletion queue OpcUaSubscription keeps for
MonitoredItems that have been removed from the Subscription but not yet
deleted from the Server. They are coupled — retaining failed deletions makes
the second defect strictly more likely to fire — so both are fixed together.

A failed DeleteMonitoredItems dropped the pending deletion
----------------------------------------------------------

deleteMonitoredItems() (OpcUaSubscription.java:672-685) cleared
this.itemsToDelete *before* the service call. The private overload can then
fail three ways without ever reaching applyDeleteResult(): serverState == null
(OpcUaSubscription.java:694), no resolvable MonitoredItemIds
(OpcUaSubscription.java:722), and the service fault catch
(OpcUaSubscription.java:760). In each case the item kept its serverState,
ClientHandle and SyncState.SYNCHRONIZED while being in neither monitoredItems
— removeMonitoredItem() (OpcUaSubscription.java:432) had already taken it out
— nor itemsToDelete. Fully orphaned.

The consequences compound. The Server-side item keeps publishing, and the
notification handler resolves its ClientHandle to nothing. Every queued
deletion having been dropped, isMonitoredItemsSynchronized()
(OpcUaSubscription.java:998) reports true, so the failure is invisible to
callers that poll sync state instead of inspecting the returned results. And
the item cannot be added back: addMonitoredItem()
(OpcUaSubscription.java:386) sees a ClientHandle whose entry in monitoredItems
is not this item, itemsToDelete.remove(item) returns false, and the item falls
into the "handle from a different context, ignore" branch and is silently
dropped. Short of deleting the whole Subscription there was no way for an
application to recover.

deleteMonitoredItems() now snapshots the queue rather than clearing it, and
afterwards removes only the items for which the Server reported an
operation-level result. Part 4 §5.13.6.4, Table 77 defines exactly one
operation-level result code for DeleteMonitoredItems besides Good:
Bad_MonitoredItemIdInvalid, which means the item is already gone. Either way
the item no longer exists on the Server and applyDeleteResult() has detached
it, so dequeuing is correct. Anything without an operation-level result — a
service fault, a missing serverState, an unresolvable MonitoredItemId — leaves
the item on the Server, so it stays queued for the next attempt. Items still
in SyncState.INITIAL were never created on the Server and are still dropped
without a service call.

reset() replayed stale MonitoredItemIds against the new Subscription
--------------------------------------------------------------------

reset() (OpcUaSubscription.java:1424) reset monitoredItems.values() but never
touched itemsToDelete. OpcUaMonitoredItem.reset() is what clears serverState,
and therefore the MonitoredItemId, so items sitting in the queue kept both a
stale MonitoredItemId and SyncState.SYNCHRONIZED across the reset. After a
subsequent create(), deleteMonitoredItems() still selected them (SyncState !=
INITIAL) and sent those ids against the new subscription id.

MonitoredItemIds are scoped to a Subscription, and Milo's own Server assigns
them from an AtomicLong created per Subscription and starting at 1
(sdk-server Subscription.java:75), so the ids restart for every new
Subscription. A replayed id of 1 therefore usually maps to a real, unrelated
item in the recreated Subscription: the delete does not fail, it silently
removes the wrong item. Separately, isMonitoredItemsSynchronized() stayed
false indefinitely while the ghosts remained queued.

reset() now resets the queued items and clears the queue: the Subscription
they belonged to no longer exists, so neither do they, and there is nothing
left to delete. Their ClientHandle is cleared too, so an item that outlives
its Subscription can be added to one again instead of hitting the same
"handle from a different context, ignore" branch as the first defect.

Tests
-----

MonitoredItemDeletionTest drives a real Server whose MonitoredItem service set
is wrapped to record the MonitoredItemIds of every DeleteMonitoredItems
request and to fail scripted ones with a service fault:

  - failedDeleteStaysQueuedForTheNextDelete: after a service fault the
    Subscription reports itself unsynchronized, the next
    deleteMonitoredItems() retries the same item, and the Server sees the
    deletion attempted twice.
  - itemWhoseDeleteFailedCanBeAddedBack: an item whose delete failed is
    restored to the Subscription, still SYNCHRONIZED, when added back.
  - staleMonitoredItemIdIsNotDeletedFromTheNewSubscription: pins the collision
    by asserting both the old and the new item are assigned MonitoredItemId 1,
    then asserts no DeleteMonitoredItems reaches the wire after the
    Subscription is recreated and that the item holding the recycled id can
    still be deleted itself.
  - newSubscriptionIsSynchronizedAfterAnItemWasQueuedForDeletion: the
    recreated Subscription reports itself synchronized.

Three controls guard against vacuous fixes: a Good delete must still dequeue
(so "never dequeue" is not a valid fix), an item added back while queued must
cancel its pending deletion, and a delete/create cycle with no pending
deletion must still require its items to be created again.

DelegatingMonitoredItemServiceSet is new test infrastructure mirroring the
existing DelegatingSubscriptionServiceSet; there was no delegating
MonitoredItem service set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An OpcUaSubscription object and the Subscription it represents did not have
the same lifetime, and nothing in the client said so. reset() cleared the
object's ServerState and create() installed a new one, while every piece of
PublishingManager work registered for the previous Subscription kept asking
that same mutable object what its *current* SubscriptionId was. Work belonging
to one Subscription was therefore applied to another.

The defects

1. Stale work from a previous incarnation. SubscriptionDetails carried only
   the OpcUaSubscription object. The Bad_Timeout branch of
   deliverNotificationMessage (PublishingManager.java:897-909) asked the live
   object for its id and removed *that* map entry, then called
   notifyStatusChanged, which resets the live object
   (OpcUaSubscription.java:1634-1643). Delivery runs on the Subscription's
   deliveryQueue, a final field (OpcUaSubscription.java:114) that reset()
   neither drains nor replaces (OpcUaSubscription.java:1443-1467), so a task
   queued behind an application callback that has not returned survives into
   the next incarnation. Traced outcome: a Bad_Timeout StatusChangeNotification
   received for Subscription A de-registers and tears down Subscription B,
   created after it and never timed out, leaving B running on the Server with
   no ServerState left for delete() to name it. The same interleaving with a
   DataChangeNotification delivered a value received on A to the application as
   though a MonitoredItem of B had reported it: reset() leaves the
   monitoredItems map and its ClientHandles intact, so the stale value still
   resolves to an item.

2. Acknowledgements built from the wrong SubscriptionId. The acknowledgement
   loop iterated subscriptionDetails.values() and built each
   SubscriptionAcknowledgement from the live object's current id
   (PublishingManager.java:220) rather than the id its entry was registered
   under. Part 4 section 5.14.5.2 pairs a sequenceNumber with the Subscription
   the NotificationMessage was "received on" and lets the Server delete that
   message from that Subscription's retransmission queue, so the client both
   acknowledged a message the named Subscription never sent and left the one it
   did receive unacknowledged in the Server's queue.

3. A leaked Server-side Subscription. create() was a check of syncState around
   a blocking CreateSubscription call with nothing serializing it
   (OpcUaSubscription.java:157-197), so two concurrent calls both passed the
   check and both created a Subscription. The object kept only the ServerState
   written last; the other SubscriptionId could never be passed to delete() and
   the Subscription ran on the Server until its lifetime expired. create() also
   published syncState = SYNCHRONIZED before serverState, so getSyncState()
   could report SYNCHRONIZED while getSubscriptionId() was still empty.

The fix

SubscriptionDetails now holds the SubscriptionId it was registered under as a
final field, plus a registered flag that is cleared when the entry is
unregistered. Acknowledgements are built from that id. The Bad_Timeout branch
unregisters the entry by its own id. deliverNotificationMessage drops a message
whose entry is no longer registered, which covers data, event, keep-alive and
status delivery in one place; the entry is unregistered the moment its
Subscription is, so a live Subscription's own work is never affected.
removeSubscription now unregisters by object identity rather than by the
object's current id.

OpcUaSubscription serializes create/modify/delete/setPublishingMode/reset on a
private lifecycle lock held across the service call, so each check-then-act is
atomic: a second concurrent create() is answered Bad_InvalidState instead of
creating a Subscription nothing can name, and addSubscription always registers
under the id create() just installed. create() now writes serverState before
syncState. Both classes gained a class Javadoc stating the threading contract,
which previously existed nowhere.

The tests

SubscriptionIdentityTest drives both ways an entry's id and its Subscription's
current id can disagree. StaleWorkFromAPreviousIncarnation blocks the delivery
queue inside onDataReceived, queues a second NotificationMessage behind it,
resets and recreates the Subscription, then releases the queue: it asserts the
recreated Subscription is neither reset nor de-registered by the stale
Bad_Timeout, and that the stale DataValue is not delivered. ConcurrentCreate
holds two CreateSubscription requests inside the Server handler and releases
them in a controlled order: it asserts an acknowledgement carries the id its
NotificationMessage was received under, and that delete() leaves no
Subscription running on the Server. Five controls fix the assertions in place -
that reset()+create() alone leaves a usable Subscription, that a Bad_Timeout
does reset the Subscription it was received for, that a queued
DataChangeNotification is otherwise delivered, and that acknowledgement and
delete work after a single create().

With create() serialized, ConcurrentCreate's second call is now rejected
client-side, so its acknowledgement test no longer exercises a divergent id;
the invariant it asserts is defended by the immutable id, which the
StaleWorkFromAPreviousIncarnation tests still exercise. The
syncState-before-serverState ordering has no test: the two statements are
adjacent with no interposable call between them and every reader of serverState
null-checks it, so it is verified by code order only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Part 4 §6.7 tells a Client what to do with a Subscription once it has
re-established a connection: "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. After the Republish returns Bad_MessageNotAvailable
the Client shall start sending Publish requests with the normal Publish
handling. This sequence ensures that the lost NotificationMessages queued in
the Server are not overwritten by new Publish responses."

The client already did the rest of §6.7 correctly -- it re-establishes the
SecureChannel, re-activates or re-creates the Session, and calls
TransferSubscriptions with sendInitialValues -- and omitted only that drain.

The defects

1. Publish resumed before Republish, on both reconnect paths.
   PublishingManager's SessionActivityListener went straight from
   onSessionActive to maybeSendPublishRequests
   (PublishingManager.java:125-135). Outside the public republish* API the only
   Republish call site in the client SDK was PublishingManager.java:868-879,
   reached only from processPublishResponse's reactive gap repair, i.e. after a
   PublishResponse had already come back and revealed a gap. Publish resumption
   is gated on State.Active (SessionFsmFactory.java:664-717), and both paths --
   Creating -> Activating -> Transferring -> Initializing -> Active, and
   Active -> ReactivatingWait -> Reactivating -> Initializing -> Active --
   reach it with no Republish in between, so the first request the client sent
   for a Subscription after a reconnect was a PublishRequest.

   What that costs is a loss window rather than guaranteed loss: the reactive
   repair does recover the gap when the first post-reconnect PublishResponse
   reveals it, and fails when the Server's retransmission queue -- whose spec
   minimum is only twice the Publish requests per Session, Part 4 §5.14.5.1 --
   has already evicted the missed messages, which is exactly what letting
   Publish go first makes possible. Part 4 §5.14.1.1: "In the case of a
   retransmission queue overflow, the oldest sent NotificationMessage gets
   deleted." The client then falls through to notifyNotificationDataLost(), so
   the data is reported only as lost.

2. TransferResult.availableSequenceNumbers was discarded. SessionFsmFactory's
   transfer handling read only result.getStatusCode()
   (SessionFsmFactory.java:1512-1527). Part 4 §5.14.7.1 gives each successful
   TransferResult "the sequence numbers of the NotificationMessages that are
   available for retransmission" and asks that "the Client should acknowledge
   all Messages in this list for which it will not request retransmission" -- a
   should, not a shall, so this half is an improvement rather than a
   conformance fix. The list is nevertheless exactly the input §6.7's drain
   needs: it says which NotificationMessages can still be collected.

The fix

PublishingManager gates PublishRequests on per-Subscription reconnect recovery.
sessionActivations counts Session activations and recoveredActivations records
the highest one whose recovery has finished; maybeSendPublishRequests sends only
while the second has caught up with the first. That test is made once the
Session is in hand rather than on the way in, so a caller that was parked on
getSessionAsync cannot send the instant a Session arrives either.

Each Subscription is recovered on its own processing queue, paused for the
duration exactly as the reactive gap repair pauses it, which is what orders the
NotificationMessages the loop collects ahead of any PublishResponse for the same
Subscription. lastSequenceNumber advances as they are recovered -- otherwise the
next PublishResponse re-detects the gap the loop has just closed and delivers
them a second time -- and each is queued for acknowledgement.

Where a transfer occurred, SessionFsmFactory now hands the
availableSequenceNumbers of each good TransferResult to the PublishingManager,
inline, while that Session is still on its way to Active, and the loop follows
the list: it starts at the oldest sequence number the Server said it holds that
the client is missing, reports anything older than that as lost data, and ends
where the list does rather than spending a round trip to be told what the Server
has already said. Where the Session was merely re-activated there is no such
list and the loop is the one §6.7 describes, incrementing until
Bad_MessageNotAvailable and bounded by DEFAULT_MAX_RECOVERABLE_GAP. Requests are
built against the Session that has just become Active rather than made through
OpcUaClient.republishAsync, which resolves the Session again for every call: a
recovery that outlives its Session must not have its remaining requests
re-issued on the next one, nor park waiting for a Session that does not exist
yet. A Subscription whose transfer failed is not recovered --
notifyTransferFailed resets it, which unregisters its entry, and the loop stops
on an unregistered entry.

Trading a loss window for a permanent stall is the risk in gating Publish at
all, so every branch reaches resumePublishing: no registered Subscriptions
completes at once; a processing queue that refuses the task completes at once;
the loop body throwing is caught, the queue resumed and the recovery reported
finished; every Republish failure, Bad_MessageNotAvailable included, ends that
Subscription's loop instead of failing its future, so no per-Subscription future
ever completes exceptionally; recoverSubscriptions throwing synchronously is
caught and publishing resumes anyway; and recoveredActivations is a maximum, so
the newest activation's recovery opens the pipeline whatever became of an older
one it superseded.

The tests

PublishReconnectRecoveryTest drives both reconnect paths against a Server whose
Publish, Republish and TransferSubscriptions responses are scripted and whose
retransmission queue is modelled with a bounded capacity, so an eviction happens
only when a Publish request is answered -- the causal link the ordering
requirement is about. Three of its eight tests failed before this change.

It asserts, on the re-activation path, that the first request to reach the
Server after the Session becomes Active again is the Republish for the next
expected
sequence number rather than a Publish; on the transfer path, that every sequence
number the TransferResult advertised is requested before the first Publish; and,
with a retransmission queue that the next PublishResponse overflows, that the
NotificationMessages generated while the Session was down still reach the
application exactly once and in order with nothing reported lost -- the
consequence the ordering exists to prevent. Its controls prove the fixture
really resumes Publish on both paths, that TransferSubscriptions is reached, and
that the same script against a roomier queue loses nothing even without the fix,
which isolates eviction as the cause. Two further tests hold the pipeline to
account when recovery cannot complete: Publish resumes after every Republish
fails with a service error, and after Republish reports the Subscription is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defect

ca62339 made each of OpcUaSubscription's lifecycle transitions atomic by
synchronizing the whole method body on a new lifecycleLock, so the lock was held
across the blocking service call: create() (OpcUaSubscription.java:193-236, call
at 204), modify() (266-317, call at 283), delete() (380-400, call at 388),
setPublishingMode() (994-1022, call at 1004), and reset() (1501-1525), which
makes no call of its own.

reset() is not only an application entry point. PublishingManager's Bad_Timeout
branch calls notifyStatusChanged (PublishingManager.java:1408-1414), which
resets the Subscription (OpcUaSubscription.java:1693-1696) while running on that
Subscription's deliveryQueue, and the Session FSM calls notifyTransferFailed
(SessionFsmFactory.java:1546, 1569), which resets it too
(OpcUaSubscription.java:1719-1720). Both therefore waited out a full request
timeout - 60s by default - behind an unrelated in-flight create(), modify(),
delete() or setPublishingMode(): notification delivery for that Subscription
stopped for the duration, and so did the state machine that owns reconnection.

On a bounded transport executor it was not a stall but a deadlock. The delivery
queue is backed by that executor and so is response completion, so with a
single-threaded executor - which OpcTcpClientTransportConfigBuilder.setExecutor
exists to allow - the only thread that could complete the in-flight call's
response was the one blocked inside reset() waiting for the lock that call held,
and handleResponse had already cancelled the request timeout that would
otherwise have broken the cycle.

The fix

lifecycleLock now guards the state transitions only, never a service call, which
is what the original finding called for. A transition validates the current
state under the lock, claims the right to change it by setting a private
transitionInFlight flag, captures the private incarnation counter and releases
the lock; it makes its service call; it takes the lock again to apply the
result, and clears the claim in a finally. reset() takes the lock, does its
purely local work and returns: it never waits for the Server, and never waits
for a transition that is waiting for the Server.

SyncState is unchanged. It is public API returned by getSyncState(), so the
transitional state lives in transitionInFlight instead. getSyncState() reports
the state the Subscription had when the in-flight call was made - INITIAL while
it is being created, UNSYNCHRONIZED while it is being modified - which is what
remains true of it until the Server answers, and is why a failed transition has
no state to roll back. That is now documented on getSyncState().

incarnation is incremented by every reset() and re-compared when a response
arrives. A change means the Subscription the call was made for is gone, so the
result is discarded rather than applied to whatever the object holds now, and
the call fails Bad_InvalidState. For create() that alone would leak: the Server
has already created a Subscription, and Part 4 5.13.8 gives DeleteSubscriptions
the SubscriptionId as its only handle on one, so a superseded create() now
issues a best-effort deleteSubscriptionsAsync for the id it is not going to
install and warns if that fails. reset()'s documented guarantee is preserved
rather than revised: a reset still either discards the Subscription that existed
before it, or the one an in-flight create() goes on to create; what it no longer
does is wait for that call to finish first.

Everything else the lock protected is kept. Transitions remain serialized
against each other - a second one waits on the monitor, which releases it,
instead of blocking inside a synchronized body - so a concurrent create() is
still answered Bad_InvalidState and cannot create a second Subscription nothing
can name. create() still installs serverState before syncState, and still
registers with the client and the PublishingManager inside the critical section,
so the PublishingManager entry is bound to the id create() just installed.
modify()'s failure path still restores the pending Modifications diff, unless a
reset() cleared them on purpose. The watchdog is still created by create() and
still re-armed only after modify() has installed the revised parameters.

The tests

SubscriptionLifecycleContentionTest parks a create() or a setPublishingMode()
inside the Server handler and asserts that work which does no I/O of its own
still completes within 2s: reset() and notifyTransferFailed() on their own
threads, and a scripted Bad_Timeout StatusChangeNotification reaching
onStatusChanged through the delivery queue - the last of those both on an
unbounded cached pool, the shape of the default executor, and on a
single-threaded executor, where it also asserts the parked setPublishingMode()
returns once the Server has answered rather than deadlocking. One test releases
the gate and asserts the Server is left running no Subscription the client
cannot name, which is the create()-superseded-by-reset() leak. Four controls fix
the timing assertions in place: reset() with nothing in flight, the same
Bad_Timeout script with nothing in flight on both executors, and a guard that a
second create() made while the first is in flight still fails Bad_InvalidState
and still leaves exactly one Subscription on the Server.

modify() and delete() holding the lock across their own service call have no
dedicated test: they are the same statement shape as setPublishingMode(), and a
third and fourth gated-service variant would assert the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defect

createAsync() (OpcUaSubscription.java:323-334), modifyAsync() (436-447),
deleteAsync() (542-553) and setPublishingModeAsync(boolean) (1255-1266) were
each their own blocking counterpart handed to FutureUtils.supplyAsyncCompose
(FutureUtils.java:29-32) with the transport executor, i.e.
CompletableFuture.supplyAsync(blockingCall, transportExecutor). The blocking
call therefore ran on a transport executor thread and waited there for a service
response - and that response is completed by the transport executor:
AbstractUascClientTransport.handleResponse dispatches everything that is not a
PublishResponse via config.getExecutor().execute(...)
(AbstractUascClientTransport.java:149).

So each of these calls occupied one thread of the pool that had to do the work
it was waiting for. With a single-threaded executor - which
OpcTcpClientTransportConfigBuilder.setExecutor exists to allow - the only thread
that could complete the response was the one blocked waiting for it, and the
wedge was permanent rather than merely slow: handleResponse cancels the request
timeout (:143) before dispatching, so the wheel timer that rescues a Server
which never answers cannot rescue a Server which does. On any bounded executor
it was the same arithmetic: n outstanding calls occupied n threads, and once
they occupied all of them none of their responses could be completed. Two
concurrent createAsync() calls were enough on a two-thread pool.

The fix

Each transition is now implemented once, as its asynchronous form, composed from
client.createSubscriptionAsync, modifySubscriptionAsync,
deleteSubscriptionsAsync and setPublishingModeAsync; the blocking form is that
stage awaited, so the two cannot drift apart in how they handle the lifecycle
state. Nothing is dispatched onto the transport executor to wait there. A
blocking form is only ever entered from an application thread: no SDK code calls
create(), modify(), delete() or setPublishingMode(boolean), whose only callers
outside this class are the client examples and the tests. reset() does have SDK
callers on the delivery queue, on the Session FSM and in the watchdog, but it
makes no service call and is unchanged.

The machinery 3358c5d introduced is reused rather than duplicated: a
transition claims the transition slot, makes its call with lifecycleLock
released, and applies the result under the lock, comparing the incarnation it
captured against the current one. What changed is how a transition waits for
its turn. Waiting on the lifecycleLock monitor would have reintroduced the same
defect one level down - a queued asynchronous call would still hold a transport
executor thread - so transitionWaiters holds one incomplete CompletableFuture
per waiting transition, and endTransition() hands the slot straight to the one
that has waited longest by completing it. Nothing blocks, and no transition
needs a thread of its own.

Behaviour is preserved. A concurrent create() is still answered
Bad_InvalidState; a create() superseded by a reset() still deletes the
Subscription the Server made for it rather than leaking it, which Part 4
§5.13.8 makes the only thing that can, since the SubscriptionId is
DeleteSubscriptions' only handle on one; modify()'s failure path still restores
the pending Modifications diff unless a reset() cleared it on purpose; the
watchdog is still created by create() and still re-armed only after the revised
parameters are installed; the PublishingManager entry is still registered in the
same critical section that installed the SubscriptionId it binds to. The four
...Async() methods keep their signatures and fail with the UaException the
blocking form throws - await() rethrows it rather than re-wrapping it, so the
StatusCode and the message are the same either way.

The tests

SubscriptionAsyncLifecycleTest asserts that each of the four stages completes
within 5s against a Server that answers immediately on a single-threaded
transport executor, and that each one had its effect: the SubscriptionId
installed and SYNCHRONIZED; the revised PublishingInterval installed from an
UNSYNCHRONIZED precondition; isPublishingEnabled() false; INITIAL with no
Subscription left on the Server. A fifth drives two concurrent createAsync()
calls on a two-thread pool. A failure there manifests as a timeout because
there is nothing else to observe - the stage neither completes nor fails - so
seven controls hold those assertions in place: the same four calls on an
unbounded cached pool, which is the shape of the default executor; two
concurrent createAsync() calls there; client.createSubscriptionAsync on the same
single-threaded executor, which proves one thread is enough for the round trip
and that spending it on a blocking wait is what was not; and createAsync() on a
Subscription that already exists still failing with Bad_InvalidState, which pins
the exceptional-completion contract the rewrite routes an error through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes from an adversarial review of this branch. Each defect is a window
or failure path in machinery the branch introduced; each fix preserves
the behavior the branch's tests assert.

Publish suspension gate. isPublishingAllowed() compared two counters
that only the onSessionActive callbacks advance, but the Session FSM
completes the session future in a separate executor task submitted
before the callback fan-out (SessionFsmFactory.java:668 vs :713), so a
callback parked on getSessionAsync() could send PublishRequests on the
new Session before recoverAndResumePublishing() counted the activation,
bypassing the Part 4 §6.7 ordering this branch introduced. The gate now
also records which Session the newest finished recovery ran on
(RecoveredActivation), and onSessionInactive revokes it — closing both
the counter window and the reactivation window, where the FSM hands back
the same Session object.

Watchdog during recovery. WatchdogTimer.onSessionActive re-armed the
timer the moment the Session became active, but no PublishResponse — the
only event that feeds it — can arrive while the gate is shut, so a
recovery longer than the watchdog delay fired a spurious
onWatchdogTimerElapsed on a healthy Subscription. The timer is now
re-armed by resumePublishing(), when Publish traffic can actually flow.

Stale work and identity. processPublishResponse re-resolved its entry
from the registry by SubscriptionId, so a task queued for one
registration could be applied to a different one when the Server reuses
an id; it now receives the entry resolved at receipt and discards the
response if that entry has been unregistered. The Bad_Timeout teardown
no longer resets the live object unconditionally: it runs only if the
entry is still the registered one — unregister() now reports that — and
only if the object still has the incarnation the entry was registered
under (resetIfIncarnation), so a stale Bad_Timeout delivered behind a
slow application callback cannot tear down a replacement Subscription.
Messages already received, and possibly already acknowledged, that the
registration guard discards now raise onNotificationDataLost instead of
disappearing at debug level.

Pipeline stalls and permit leaks. A Bad_TooManyPublishRequests that
returned the last outstanding request left nothing in flight to trigger
a refill, halting Publish traffic until an unrelated event; the refill
now runs when outstanding reaches zero. handlePublishFailure NPE'd in
UaException.extract(ex) when the future completed with a
non-PublishResponse (ex is null there), leaking the permit. Both
rejected-task paths — processingQueue.execute() at receipt and the
delivery queue submit() — now release the permit too, matching
recoverSubscription's handling of the same case.

Sequence accounting. A NotificationMessage further behind the expected
number than any plausible duplicate — beyond
max(DEFAULT_MAX_RECOVERABLE_GAP, the advertised
availableSequenceNumbers) — is now treated as the Server's numbering
having regressed (e.g. a restart that renumbered a restored
Subscription): delivered and resynchronized to, where before it was
discarded silently and indefinitely until the numbering caught back up.
A gap given up on as lost data now acknowledges the advertised sequence
numbers it abandons (Part 4 §5.14.7.1: acknowledge what will not be
requested), so the Server's retransmission queue drains instead of
holding them for the life of the Subscription.

Gap repair session binding. The reactive gap repair went through
client.republishAsync, which re-resolves the Session per call and parks
on the one being established — the routing republish(session, ...)
exists to avoid. A parked repair holds the paused processingQueue, and
with it the reconnect recovery and every Subscription's Publish traffic,
shut. It now binds to the Session in hand when the gap is found and
treats no-Session-in-hand as lost data.

Lifecycle. The parameter setters did their check-then-act on
modifications and syncState without lifecycleLock, so a setter racing
reset() could leave syncState UNSYNCHRONIZED with serverState null — a
state every transition answers Bad_InvalidState. They now take the lock
for exactly that, never across a service call. endTransition() handed
the slot to the next waiter by completing its future inline, so N queued
synchronously-completing transitions unwound with recursion depth N, and
a StackOverflowError left the slot claimed forever; the hand-off now
goes through the executor.

Not changed: the pendingPublishCeiling clamp after
Bad_TooManyPublishRequests still lasts until a Subscription is added or
a Session activates. PublishPipelineRefillTest pins
one-replacement-per-return, so probing the ceiling back up would violate
the branch's stated intent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onPublish's direct (non-parked) path invoked responder.respondTo(request)
bare, while the parked path's fulfill() converts a RuntimeException into
a failed future. A scripted responder that threw on the direct path
escaped into the server's async dispatch, where the response future is
never completed and the client's PublishRequest hangs until its
timeoutHint expires — so the same script passed or failed depending on
whether the request happened to be parked first. Both paths now apply
the same guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression tests for the three most serious defects 93bedb3 fixed. Each
one was proved RED against the pre-fix code and GREEN at HEAD, twice
over: once with both files of 93bedb3 reverted wholesale, and once with
only the hunks belonging to the defect under test reverted, so that no
failure is attributable to a different fix in the same commit.

PublishSuspensionGateRaceTest. Pins the Publish suspension gate that
af8d8da introduced to impose the Part 4 6.7 Republish-before-Publish
ordering. Before 93bedb3 the gate compared two counters that only the
onSessionActive callbacks advance, but SessionFsmFactory completes the
Session future in one executor task and fans the activation callbacks out
in another submitted after it, so a caller already parked on
getSessionAsync() ran before recoverAndResumePublishing() had counted the
activation and the gate answered it with the previous activation's
recovery. 93bedb3 also records which Session the newest finished
recovery ran on and revokes that record in onSessionInactive, because a
re-activation hands back the same Session object.

The window is forced, not raced: the client's transport executor has a
single thread, so the task completing the Session future runs to
completion -- parked continuations included -- before the activation
callbacks run. The parked caller is a real one: a PublishRequest returned
with Bad_UnexpectedError while the Session is down makes PublishingManager
try to replace it and wait for the Session being established, and the test
returns only once that failure handler has run, so the caller is provably
parked. Two tests assert that the first request the Server sees after the
reconnect is the Republish 6.7 requires -- one on the re-activation path,
where the same Session object comes back and only the revocation closes
the gate, and one on the replacement-Session path, where the ActivateSession
of the new Session is held so a caller can park on the Session future the
FSM will complete for it. A third test asserts the precondition on its own:
a parked caller really is released before any activation callback runs.

Observed pre-fix: request log [Publish, Publish, Republish:3] for both,
expected Republish:3 first.

SubscriptionWatchdogRecoveryStarvationTest. Pins the watchdog arming point
93bedb3 moved. WatchdogTimer.onSessionActive re-armed the timer the
moment the Session became Active, but the only event that feeds the
watchdog is a PublishResponse and none can arrive while the recovery gate
is shut, so a recovery longer than the watchdog delay fired
onWatchdogTimerElapsed on a healthy Subscription. The timer is now re-armed
by resumePublishing(), when Publish traffic can actually flow.

The Server holds the Republish that begins the reconnect drain until the
test releases it, so the recovery lasts as long as the test says rather
than as long as timing allows. With a 333ms watchdog delay (111ms
publishing interval, MaxKeepAliveCount 1, multiplier 1.5) the drain is held
3s -- nine delays -- and the test asserts no expiry is reported. The
control releases the held drain and asserts the expiry then arrives, which
is also the assertion that resumePublishing() arms it.

Observed pre-fix: the watchdog elapsed during the held drain.

GapRepairSessionBindingTest. Pins the Session the reactive gap repair
sends on. The repair went through client.republishAsync, which re-resolves
the Session per call and parks on the one being established -- what the
routing form republish(session, ...) exists to avoid. A parked repair holds
its Subscription's processingQueue paused, and the Part 4 6.7 reconnect
recovery is queued on that same queue, so the recovery of every
Subscription is incomplete, the suspension gate never opens, and
Subscriptions with nothing missing get no Publish traffic either.

The Session outage is bounded by the test rather than by timing: the Server
holds the reconnect's ActivateSession until the test releases it. A gap of
three NotificationMessages is revealed on one of two Subscriptions, the
first Republish is held, the Session is faulted, and the held Republish is
then answered while no Session is available. The first test asserts the
repair's remaining requests reach the Server and the whole gap plus the
message that revealed it is delivered, with the reconnect still held. The
second stops answering the repair's outstanding requests at the moment the
reconnect completes -- so a repair already finished is unaffected and one
that resumes on the new Session is held -- and asserts the other
Subscription still receives its notifications. The control runs the same
reconnect with no repair in flight.

Observed pre-fix: only Republish 1:2 ever reached the Server, and the
second Subscription received nothing after the reconnect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four tests pinning four of the fixes in 93bedb3 ("Fix races and stall paths
in publish recovery and lifecycle"). Each was proven RED by reverting both
files that commit touched -- OpcUaSubscription.java and PublishingManager.java
-- to 93bedb3~1 and running the new tests against the current test tree. The
wholesale revert compiled; every control test passed under it, and all five
pre-existing PublishPipelineRefillTest tests stayed green under it, so each
failure below is attributable to the fix its test is about.

PublishPermitLeakTest, method
publishRequestsResumeAfterAnAnswerThatIsNotAPublishResponse

  Pins the handlePublishFailure NPE. A PublishRequest that completed with a
  response that was not a PublishResponse reached the failure path with a null
  exception, and UaException.extract(null) threw before the pending-publish
  permit was released. The answer is fabricated by a transport-level
  interception, via the new TestClient.createWithTransport, because the type of
  a Publish answer is fixed by SubscriptionServiceSet.onPublish and the one
  wrong-typed message a Server can send -- a ServiceFault -- is turned into an
  exception by the client's channel before the SDK sees it. With
  maxPendingPublishRequests = 1 the leaked permit is the whole pipeline, so the
  test asserts a further PublishRequest reaches the Server. Pre-fix the Server
  saw no Publish request at all, and the run logged the defect itself:
  "NullPointerException: Cannot invoke Throwable.getCause() because ex is null"
  at UaException.extract -> PublishingManager.handlePublishFailure. The control
  answers the same interception with a real service failure -- the same path
  with an exception to take a StatusCode from -- and passes either way.

PublishPermitLeakTest, method
publishRequestsResumeWhenTheDeliveryQueueCannotAcceptTheNotificationMessage

  Pins the delivery-queue half of the rejected-task permit leak.
  deliverAndReleasePendingPublish releases the permit from the delivery
  callback, so a delivery queue whose submit() returns null -- shut down, or
  its max queue size exceeded -- left nothing to release it. The test shuts the
  Subscription's delivery queue down before the PublishResponse arrives and
  asserts Publish traffic continues. Pre-fix: "the client sent no further
  PublishRequest after a NotificationMessage its delivery queue could not
  accept". The control leaves the queue running.

  The commit's other rejected-task path, processingQueue.execute() returning
  false at receipt, is not pinned: the processing queue is created inside
  SubscriptionDetails with an unbounded max queue size, and nothing in the SDK
  shuts it down or exposes it, so no test can make it reject work.

PublishPipelineRefillTest, method
publishRequestsResumeWhenBadTooManyPublishRequestsReturnsTheLastOne

  Pins the Bad_TooManyPublishRequests stall. A fault that returned the last
  outstanding request left nothing in flight whose completion would refill the
  pipeline, so Publish halted until an unrelated event. Exactly one outstanding
  is reached with maxPendingPublishRequests = 1, which makes the target
  min(1 + 1, 1) = 1 and the ceiling the fault installs max(1, 0) = 1, i.e. the
  target the client already had. Pre-fix: "the Publish pipeline was left
  empty". The control faults the same one-deep pipeline with Bad_InternalError.
  This does not contradict TooManyPublishRequests, which pins
  one-replacement-per-return with two requests outstanding and stayed green
  throughout; the fixture grew a per-instance pipeline target so that both
  depths can be scripted.

SubscriptionStaleDeliveryTest, method
aStaleBadTimeoutDoesNotTearDownTheSubscriptionCreatedWhileItWasBeingDelivered

  Pins the Bad_Timeout teardown guard. deliverNotificationMessage checks that
  the message's Subscription still exists once, before the first callback, and
  then walks the NotificationData in order, so a DataChangeNotification ahead
  of a Bad_Timeout StatusChangeNotification puts an application callback of
  arbitrary duration between the check and the teardown. The test blocks in
  that callback, resets and re-creates the Subscription, and asserts the
  replacement is intact. Pre-fix the teardown was applied unconditionally: "a
  Bad_Timeout StatusChangeNotification received on Subscription 16 tore down
  Subscription 17, which was created while that message was being delivered and
  has never timed out". The control runs the same two-notification message with
  no reset in the middle of it and requires the teardown to still happen. This
  is the mid-message window, not the one SubscriptionIdentityTest covers, where
  the whole message is already stale when its delivery begins.

  Only the unregister()-reports-it half of that guard is pinned. The
  resetIfIncarnation half is reachable only when unregister() wins a race
  against reset()'s own removeSubscription() -- reset() bumps the incarnation
  before it unregisters anything -- which a test cannot drive deterministically.

SubscriptionStaleDeliveryTest, method
aDiscardedNotificationMessageIsReportedAsLostData

  Pins the reporting of notifications the registration guard discards. The
  client acknowledges a NotificationMessage when it is received, so a message
  discarded on its way to the application is data the Server may already have
  deleted; it disappeared at debug level. The test queues a data change behind
  a blocked callback, resets and re-creates the Subscription, and asserts
  onNotificationDataLost. Pre-fix "the application was never told". The control
  asserts that the baseline reports no loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regression tests for four defects 93bedb3 repaired: two in the
NotificationMessage sequence accounting and two in the OpcUaSubscription
lifecycle. Each was proved RED against the pre-fix code and GREEN at HEAD,
twice over: once with both files of 93bedb3 reverted wholesale, and once
with only the hunks belonging to the defect under test reverted. In each
per-hunk run the other three tests stayed GREEN, which is the isolation
proof.

PublishSequenceRegressionTest. Pins the boundary between a duplicate and
the Server's numbering having regressed. Before 93bedb3 every
NotificationMessage behind the expected sequence number was discarded as a
duplicate, so a Server that restarts and renumbers a restored Subscription
-- Part 4 5.14.1.1 numbers NotificationMessages per Subscription from 1 --
has every one of its messages dropped, silently, until the numbering
catches back up to where it was, which for a renumbering is never. A
message further behind than max(DEFAULT_MAX_RECOVERABLE_GAP, the
advertised availableSequenceNumbers) is now treated as a regression:
delivered, and resynchronized to.

Four tests, each scripting PublishResponses whose DataChangeNotification
carries its own sequence number as its value, so the delivered values say
exactly which messages arrived. lastSequenceNumber is seeded reflectively,
as PublishSequenceRecoveryTest does for the rollover boundary, because it
only ever advances one message at a time and there is no wire path to a
value hundreds ahead of where the numbering restarts; everything after the
seed is the production path. One test drives a message 1000 behind and
asserts it and the two contiguous messages after it are all delivered, in
order, with no Republish and no lost-data report. Two more sit on either
side of the boundary: 64 behind is still a duplicate and must not be
delivered, 65 behind is a regression and must be. The fourth is the
control with no seeding at all -- a retransmitted copy of a message
received a moment ago is still discarded.

Observed pre-fix: "the NotificationMessage carrying sequence 1 was never
delivered", and the same for the 65-behind message. Both duplicate
controls passed, so the fix did not turn every duplicate into a resync.

AbandonedGapAcknowledgementTest. Pins the acknowledgement 93bedb3 added
when a gap is given up on as lost data. Part 4 5.14.7.1: "The Client
should acknowledge all Messages in this list for which it will not request
retransmission." Nothing in an abandoned gap will ever be requested, and
an unacknowledged NotificationMessage stays in the Server's retransmission
queue -- re-advertised in every PublishResponse, holding its memory -- for
the life of the Subscription.

NotificationMessage 1 is received, then 10 arrives advertising only 2, 3
and 10: an eight-message gap inside a two-message retransmission queue, so
missingSequenceNumbers abandons it without requesting any of it. The test
asserts 2 and 3 are acknowledged, and that the gap really was the
abandoned kind -- lost data reported, no Republish issued.

This deliberately acknowledges messages that were not received, which
pulls against the rule 30a3661 established that the client never
acknowledges what it did not receive. The two are kept consistent by the
second test, which is the companion control: a one-message gap inside the
same two-message queue is recoverable, Republish(2) is attempted and
fails, and sequence 2 must remain unacknowledged. The distinction is not
whether the message arrived but whether the client might still ask for it.
The existing acknowledgement tests -- PublishAcknowledgementTest and
PublishSequenceRecoveryTest.InitialKeepAlive -- both cover recoverable
gaps and are untouched.

Observed pre-fix: "sequence 2 was advertised as available for
retransmission and the client has decided never to request it ... expected
<true> but was <false>", with the log confirming the abandoned path was
taken: "Gap of 8 NotificationMessage(s) starting at sequenceNumber=2
exceeds the 3 the Server can retransmit".

SubscriptionTransitionHandoffTest. Pins the transition hand-off, a defect
introduced by 3358c5d / d5e515a. endTransition() handed the transition
slot to the next waiter by completing its future inline, on the stack of
the transition that was finishing. A transition that waits for the Server
unwinds that stack, but modifyAsync() with nothing pending to send returns
an already-completed stage, so N of those queued behind one another
unwound with recursion depth N -- and a StackOverflowError there leaves the
slot claimed with nobody left to release it, freezing the lifecycle for
good. The hand-off now goes through the transport executor.

A create() parked inside the Server's CreateSubscription handler holds the
slot while 10,000 modifyAsync() calls queue behind it. The test asserts
they all complete, that none of them reached the Server -- the premise,
since synchronous completion is what recurses -- and that a real modify()
made afterwards still runs, i.e. the slot was released. N comes from
measurement: pre-fix, 500 drained cleanly and 1000 did not, with the
hand-off stopping after 883 and 916 transitions, so the threshold on this
JVM is around 900 and 10,000 is an order of magnitude past it.

Observed pre-fix: "883 of 10000 queued transitions completed within
30000ms" (923 of 10000 in the per-hunk run). The StackOverflowError itself
is swallowed by the CompletableFuture machinery, which is precisely why
the freeze is silent.

SubscriptionParameterResetRaceTest. Pins the lock the parameter setters
now take. They did their check-then-act on the pending modifications and
the SyncState without lifecycleLock, while reset() holds it throughout, so
a setter that read the state before a reset and wrote it after left the
object UNSYNCHRONIZED with no ServerState: create() answers
Bad_InvalidState because the state is not INITIAL, modify() and delete()
because there is no ServerState to name a Subscription with. The object is
then permanently unusable. reset() is not only an application call --
PublishingManager makes it on a Bad_Timeout StatusChangeNotification and
the Session FSM on a failed transfer -- so this is ordinary SDK behavior
racing an ordinary application call.

Not a deterministic interleaving, and it cannot be made one: the window is
the setter's own read-modify-write, and a setter performs no I/O and takes
no callback, so there is no seam a test could park it in. It is a bounded
stress loop instead -- 40 rounds of create, race, reset, two loopback round
trips each -- which provokes the window efficiently because the window is
nearly the whole of the setter's body, so a thread calling it in a loop is
inside it a large fraction of the time. The resulting state is sticky too:
every later setter call re-marks it. Each round stops and joins the setter
threads before asserting, so nothing is read while a background thread
races to update it, and the Server-side Subscription the reset orphaned is
deleted so the loop leaks nothing.

Observed pre-fix: round 2 of 40 -- round 1 in the per-hunk run -- left the
Subscription "UNSYNCHRONIZED with SubscriptionId Optional.empty". The
whole test takes about 0.25s and passed five consecutive runs at HEAD.

Revert method: both, for all four tests. The wholesale revert of
OpcUaSubscription.java and PublishingManager.java to 93bedb3~1 compiled
against the current test tree and failed exactly the five expected tests of
the eight in these four classes, and nothing else. The per-defect reverts
applied individual hunks of 93bedb3 in reverse: PublishingManager hunk 12
of 25 for the sequence regression, hunks 14 and 15 for the abandoned-gap
acknowledgement, OpcUaSubscription hunk 3 of 11 for endTransition, and
hunks 4 through 8 for the five parameter setters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Part 4 §5.14.5.1: once a Server answers Bad_TooManyPublishRequests a Client
"shall not issue another Publish request before one of its outstanding Publish
requests is returned", and PublishingManager also learns a ceiling from the
fault — the number of requests the Server was in fact holding — so that the next
returning request does not restore the very outstanding count that drew it.

That clamp used to last until a Subscription was added or a Session activated,
and neither need ever happen again in the life of a long-lived client. A merely
transient condition on the Server — a queue briefly full, a cap raised again a
second later — therefore cost such a client a permanently shallower Publish
pipeline, i.e. throughput, for no reason. The same clause requires a Server to
accept at least subscriptionCount + 1 queued Publish requests, which is exactly
what the client aims for, so a ceiling only ever describes a Server that is
non-conformant or momentarily overloaded; in the second case the client ought to
find out that the moment has passed.

The ceiling now recovers, driven by the Server's own answers rather than by a
clock: releasePendingPublish(), the one place that knows a PublishRequest went
out and came back without being refused, counts successful Publish round trips
against it. Counting successes rather than elapsed time means a Subscription
that is publishing pays for its probes, a silent one does not probe at all, and
the policy is deterministically testable without a clock.

  * Recovery is incremental: a probe raises the ceiling by one, never straight
    back to min(subscriptionCount + 1, maxPendingPublishRequests), because
    closing the whole deficit at once would send the Server exactly the burst
    that drew the fault. The natural target is never exceeded.

  * A probe costs a cooldown of successful round trips —
    PROBE_COOLDOWN_BASE_SUCCESSES = 8 to begin with. Eight is comfortably clear
    of the one-returning-request window §5.14.5.1's rule is measured in, so the
    immediate response to the fault is untouched, while still being few enough
    NotificationMessages that a Subscription publishing once a second reaches
    its first probe in under ten seconds. The cooldown is also what keeps a
    single probe in flight: the one extra request a raise adds is answered long
    before another whole cooldown of requests has been.

  * A probe the Server refuses re-clamps the ceiling and multiplies the next
    cooldown by PROBE_COOLDOWN_GROWTH = 2, up to PROBE_COOLDOWN_MAX_SUCCESSES =
    512. The cap is there because the alternative to a bounded backoff is one
    that gives up; at 512 a Subscription publishing once a second probes about
    every eight minutes in the worst case, which costs a Server that will never
    accept more essentially nothing.

The anti-hammering guarantee this buys, stated exactly: a Server that always
answers Bad_TooManyPublishRequests sees a number of probes that grows only
logarithmically in the number of responses it delivers — they land at roughly 8,
24, 56, 120, 248, ... successful round trips, so doubling the traffic adds one
probe rather than doubling the probes, and no interval between two probes is
ever shorter than the interval before it.

The state is one immutable value (PendingPublishCeiling) behind a single
AtomicReference rather than four fields, so a refusal cannot interleave with a
raise. Its "probing" flag is deliberately not cleared by the first successful
round trip after a raise: a refused PublishRequest and a delivered
NotificationMessage are handled on paths of different lengths, and in practice
the delivery wins, so a flag the delivery could clear would leave the refusal
behind it uncharged — which is precisely what a Server that always refuses
produces, and would have it probed at a constant rate forever. The flag is
instead cleared by the refusal charged to it, or by the raised ceiling standing
for a whole cooldown, which also stops unrelated transient conditions far apart
in time from ratcheting the cooldown up to its cap.

Tests
-----

PublishCeilingRecoveryTest drives a ScriptableSubscriptionServiceSet subclass
that models a Server which will not queue more than a settable number of
PublishRequests, answering one parked request at a time, so the number of
requests the Server is left holding after each answer is the client's ceiling,
observed rather than inferred. Each fixture states its natural target rather
than inheriting one: maxPendingPublishRequests is configured equal to it, so one
Subscription targets min(1 + 1, 2) = 2 and three target min(3 + 1, 4) = 4.

  * Recovery: one refusal while filling a two-deep pipeline clamps the ceiling
    to one; once the cap is lifted, sustained keep-alive traffic brings the
    outstanding count back to the natural target. Incrementality is asserted
    separately, against a four-deep pipeline clamped to two by two refusals,
    whose recovery must be observed as three outstanding and then four rather
    than four at once.

  * Anti-hammering, the important one: against a Server that answers
    Bad_TooManyPublishRequests to anything above a fixed cap, 256 delivered
    NotificationMessages must draw at least two and at most ten refused probes,
    with the intervals between them never shrinking. Measured: five, at 8, 25,
    58, 123 and 252 round trips.

  * No regression of the immediate behavior: over the first four returning
    requests after the clamp each still buys exactly one replacement, and the
    ceiling does not move. PublishPipelineRefillTest, which pins
    one-replacement-per-return for the first of them, is unchanged and green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kevinherron
kevinherron changed the base branch from main to integration/1.2 July 25, 2026 11:36
@kevinherron
kevinherron changed the base branch from integration/1.2 to main July 25, 2026 11:36
kevinherron and others added 3 commits July 25, 2026 05:35
PublishingManager ran the Part 4 6.7 Republish drain on every Session
activation, including the client's first. recoverSubscriptions() snapshots
subscriptionDetails when the onSessionActive callbacks run, and connect()
returns when the Session future completes -- which happens in a task submitted
before the callback fan-out (SessionFsmFactory.java:668 vs :713). A
Subscription created immediately after connect() can therefore register itself
inside that window and be included in a recovery that has nothing to recover:
the Session and the Subscription were both created moments earlier, so the
Server cannot be holding a NotificationMessage the client has not collected,
and every Republish the drain sends can only be answered
Bad_MessageNotAvailable.

The cost is not only the wasted round trips. The drain holds the publish gate
shut until it finishes, see isPublishingAllowed(), so it also delays the first
PublishRequest of every Subscription caught in the window -- and a drain whose
Republish is slow to be answered holds all Publish traffic with it.

Recovery is now skipped unless a Session has previously become inactive, or a
Subscription carries the availableSequenceNumbers a transfer named. Both are
evidence that the Server may hold something the client has not seen; the first
activation of a Session the client just created is not. The narrower test
"lastSequenceNumber == 0" was deliberately not used: a Subscription that lived
through a disconnect having received nothing still needs the drain.

This was reaching CI as unrelated flakes. With the whole integration-tests
module in one JVM fork on two CPUs the window opens often enough to produce a
spurious Republish in PublishSequenceRecoveryTest and
PublishResponseOrderingTest, a reset ceiling in PublishCeilingRecoveryTest
(onSessionActive clears it by design), and a Publish that is never sent in
SubscriptionWatchdogRecoveryStarvationTest, whose held Republish responder kept
the gate shut so the Bad_SessionIdInvalid fault that test scripts was never
delivered and its reconnect never happened. That last one is the failure seen
on CI. Three runs of that configuration are green with this change; two of two
reproduced a failure without it.

PublishFirstActivationRecoveryTest asserts that no Republish is requested on
the first activation and, as the control, that one is requested after the
Session is re-activated. It is a guard rather than a reproduction: entering the
window needs the calling thread to beat the fan-out task, which it loses on an
idle machine, so the class also passes against the unfixed code. That is
recorded in its Javadoc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rdening

# Conflicts:
#	opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/TestServer.java
Two tests asserted on state reached before the event they were waiting for, and
failed under the contention of the whole integration-tests module running in
one fork -- and now, since forkCount=2 landed on main, of two forks sharing a
machine. Both are test defects; neither changes production behaviour.

SubscriptionStaleDeliveryTest waited for the Subscription to reach INITIAL and
then asserted the Bad_Timeout had been reported. notifyStatusChanged() resets
the Subscription before it tells the application, so INITIAL is reached first
and the wait can return with statusChanges() still empty -- observed failing
with the whole assertion taking 22ms. It now waits for the report, which is the
later of the two, and checks INITIAL afterwards without a wait, since the reset
has necessarily already happened by then.

PublishCeilingRecoveryTest asserted that every interval between refused probes
was at least as long as the one before it. The interval is measured in round
trips a refusal was recorded against, and a refusal and the delivery it is
attributed to are separately timed: the fault path takes two executor hops the
response does not, so which round trip a refusal lands against jitters by one
either way. An observed [8, 16, 1, 32, 64, 128] is that jitter, not a cooldown
that failed to grow. The assertion now pins the direction -- the window ends
with a longer wait than it began with -- and leaves the guarantee itself to the
bound on the probe count above it, which is unaffected by attribution.

Four runs of the Subscription tests in one fork are green, where the previous
assertions failed in one of two, and the full build passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
transferSubscriptions() built the id array by flatMapping only the
Subscriptions that currently hold a SubscriptionId, but the failure
fan-out indexed the unfiltered snapshot by TransferResult index. A
Subscription reset() concurrently -- after the snapshot was taken but
before the ids were collected -- shifted the pairing by one, so a bad
result could be applied to the wrong Subscription and
notifyTransferFailed() would reset one that had transferred
successfully. A Server returning more results than requested ids threw
IndexOutOfBoundsException instead, skipping every remaining
onTransferFailed notification.

The ids sent and the Subscriptions they came from are now captured
together in one pass, and both result loops index that aligned list,
bounds-guarded the way the good-result loop already was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kevinherron and others added 4 commits July 29, 2026 04:58
resetWatchdogTimer() armed unconditionally, so a create or modify made
while the reconnect recovery held the publish gate shut -- and the
finish of a stale recovery that a newer activation had superseded --
armed a timer that no PublishResponse, the only event that feeds it,
could possibly reach. A recovery outlasting the watchdog delay then
fired onWatchdogTimerElapsed on a Subscription that was recovering
normally, and a typical application handler tears such a Subscription
down mid-recovery.

resetWatchdogTimer() now asks the PublishingManager whether Publish
traffic is suspended and defers arming while it is; the recovery that
ends the suspension re-arms every registered Subscription's timer, as
it already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bad_SessionClosed/Bad_SessionIdInvalid branch of
handlePublishFailure() paused every Subscription's watchdog with no
staleness check, unlike the Bad_NoSubscription branch beside it. A
straggling failure from a replaced Session could therefore run after
the new Session's recovery had re-armed the watchdogs and disarm them
all; if the new Server went quiet before the next PublishResponse
re-armed them, the stall the watchdog exists to report went unreported.

Each PublishRequest now records the activation count in effect when it
was sent, and the pause only happens while that count still describes
the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
addSubscription() registered with a plain put(), so a Server reusing a
SubscriptionId while an entry was still registered under it displaced
that entry without marking it unregistered. unregister() matches by
(key, value) and could never remove it again, leaving registered=true
forever: work still queued for the displaced entry was applied as if
its Subscription existed, indefinitely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
processPublishResponse() queued the acknowledgement before the
duplicate check and without the contains() guard that
restoreAcknowledgements() and acknowledgeAbandonedSequenceNumbers()
both apply. A duplicate copy arriving before the first acknowledgement
was drained queued the same (subscriptionId, sequenceNumber) twice; the
Server deletes the message on the first and answers the second
Bad_SequenceNumberUnknown, producing recurring spurious refused-
acknowledgement warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kevinherron
kevinherron marked this pull request as ready for review July 30, 2026 01:50
Keep lifecycle cleanup independent of caller cancellation, close watchdog and
monitored-item registration races, and provide ordered transfer cleanup that
cannot be delayed by an overridable notification.
Bind transfer inputs and Publish pipeline learning to the Session that produced
them, size blind recovery from the deepest permitted pipeline, and finish
failed-transfer cleanup before reconnect recovery can observe subscriptions.
Wait for reset to complete before releasing the gated create response so
the test always exercises superseded-create cleanup.
@kevinherron
kevinherron merged commit 586277d into main Aug 1, 2026
6 checks passed
@kevinherron kevinherron added this to the 1.1.7 milestone Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant