Skip to content

SessionFsm Hardening - #1825

Merged
kevinherron merged 10 commits into
mainfrom
session-fsm-hardening
Aug 1, 2026
Merged

SessionFsm Hardening#1825
kevinherron merged 10 commits into
mainfrom
session-fsm-hardening

Conversation

@kevinherron

@kevinherron kevinherron commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Corrects client-side Session establishment, reactivation, keep-alive accounting, and teardown in SessionFsm and SessionFsmFactory, under entry actions that throw, a Server that revises the Session timeout below the configured keep-alive interval, a channel lost before the client is watching for it, a definitive session-level status delivered as a channel-level Error, and a transport deliberately disconnected out from under an Active Session.

This began as a review of the two files. Twelve findings were raised; each was verified against the source and against the strict-machine and netty-channel-fsm sources before anything was changed. Eight were real and are fixed here, every one with a regression test that fails without the fix. The other four did not survive verification and are deliberately not addressed.

FSM totality

StrictMachine sets the next state before it runs the matching transition actions, and it catches and merely logs whatever those actions throw. An entry action that throws before attaching the whenComplete that fires the corresponding Success/Failure event therefore leaves the FSM parked in the new state with no event pending, and no Session state has a timeout to kick it out again. CloseSession is shelved rather than acted on in Creating, Activating, Transferring, Initializing and Reactivating, and a shelved event's future completes only when a later transition drains the shelf, so a wedge takes disconnect() with it: disconnectAsync() has an exceptionally but no timeout, and a future that never completes is not an exceptional one.

activateSession and reactivateSession were already total for this reason. createSession and closeSession now are too. createSession had two synchronous throw sites reachable before the request is sent — endpoint.getServer().getGatewayServerUri() NPEs when the configured EndpointDescription carries no server, and getSessionName().get() invokes a user-supplied Supplier outside any guard — and its body is now wrapped the way the other two already wrap theirs, routing the original Throwable into the existing whenComplete and on to CreatingWait as any other CreateSession failure would.

Transferring was exposed by a different mechanism: transferSubscriptions returns transferFuture but completes it from inside a whenComplete whose own result future is discarded, so anything the callback throws is captured by a future nobody observes. The reachable throw is requireNonNull(tsr.getResults()) against a Server answering Good with no Results array. An exceptionally on that discarded chain now guarantees transferFuture is always completed.

Sessions abandoned on the Server

CloseSessionRequest was built in one place, whose only caller was the Closing entry action — and that action reads KEY_SESSION, a context key written only on entry to Active. Once CreateSession had succeeded, every establishment failure that followed dropped straight back to CreatingWait via handleFailureToOpenSession, which did nothing but fail the pending Session future. The Activating, Transferring and Initializing failure edges all abandoned a Session that exists on the Server with no way left to name it, since the failure events carry only a Throwable. The Server reaps such a Session only when its timeout elapses; nothing ties Session lifetime to the SecureChannel, and a rejected ActivateSession leaves the entry in createdSessions. With a 120s default revised timeout and the CreatingWait backoff capped at 16s, a persistent ActivateSession failure — wrong credentials, an untrusted X.509 identity — keeps a rolling set of roughly eight orphans alive indefinitely.

KEY_PENDING_SESSION now names the state the FSM had no word for: a Session that exists on the Server but is not yet reachable via KEY_SESSION. It holds the CreateSessionResponse, is set on the Creating --CreateSessionSuccess--> Activating edge where both the sessionId and the authentication token are in hand, and is removed on entry to Active beside KEY_SESSION.set(), so exactly one of the two keys ever refers to a live Session. handleFailureToOpenSession removes it and, when it is non-null, sends a best-effort CloseSession — one hook covering all three edges, and a no-op on the edges where nothing is pending. The Reactivating --ReactivateSessionFailure--> CreatingWait edge gets a close of its own, since what it abandons is the old Session rather than a pending one.

Both of these closes are new, and both send deleteSubscriptions false, so an abandoned Session's Subscriptions survive for the replacement Session's TransferSubscriptions to recover; an abandonSession overload names that intent beside closeSession. The Closing entry action — the explicit client-shutdown path, and the only CloseSession the base branch ever sent — still closes with deletion and is otherwise untouched.

Reactivation

Reactivating had one edge to CreatingWait, guarded by a predicate asking whether UaException.extract(failure) produced a UaServiceFaultException. Every other ReactivateSessionFailure took the catch-all edge back to ReactivatingWait, where the wait doubles and saturates at 16s. That made the decision to abandon a Session turn on how the Server chose to frame its answer rather than on what the answer said. A Server replying to ActivateSession with a channel-level Error message produces a plain UaException carrying the same StatusCode — InboundUascResponseHandler builds it and failAndClearPending completes the in-flight ActivateSession with it — so Bad_SessionIdInvalid, Bad_SessionClosed and Bad_SessionNotActivated delivered that way never escalated, and the client re-sent ActivateSession every lap for a Session the Server had already said it no longer has. Part 4 §6.7 requires a new Session when ActivateSession fails, and that obligation does not depend on the message type the status arrived in.

The guard now keys on the StatusCode as well as the exception class. The SESSION_ERROR predicate naming the three definitive session-level statuses is hoisted out of SessionFaultListener to a top-level constant with its contents unchanged. Failures carrying no such status — Bad_Timeout or Bad_ConnectionClosed from a request timer or a dropped channel — are connectivity problems and deliberately keep the indefinite ReactivatingWait retry: Part 4 §5.7.2.1 has the client establish a new connection and call ActivateSession again, and dropping to CreateSession while the channel is merely down throws away a Session whose Subscriptions could still be transferred. Behavior against a conformant Server, Milo's own included, is unchanged.

Milo builds its ChannelFsm with setLazy(false) and setPersistent(true), so a channel that drops unexpectedly reconnects without the Session doing anything. There is exactly one state that does not self-heal: NotConnected, reachable only through an explicit Disconnect, whose sole outbound edge is Connect and whose GetChannel completes immediately with a plain Exception("not connected"). An application calling getTransport().disconnect() while a Session is Active parks the transport somewhere nothing will move it from, and the Session did not notice — it left Active for ReactivatingWait either at once via the transition listener or one keep-alive interval later, and every reactivation attempt then failed with a bare Exception that carries no StatusCode and is not a UaException, so no escalation predicate could match. The FSM cycled ReactivatingWaitReactivating at the 16s cap for the life of the client with every caller blocked in getSession() left hanging.

Reactivating now has a terminal edge to Inactive, guarded by the ordinary reactivation-failure predicate plus a check that the transport is an OpcTcpClientTransport whose ChannelFsm reads NotConnected — false for any other transport, so websocket and https behavior is untouched. The edge clears the wait time so a later Session starts from a fresh backoff, drops KEY_SESSION, logs one warning, and calls handleFailureToOpenSession so pending futures fail with the "not connected" cause instead of waiting forever. Leaving the FSM in Inactive keeps both exits open: the existing onTransitionTo(Inactive) action drains the shelf, so a disconnect() whose CloseSession was shelved still completes, and Inactive --OpenSession--> Creating means a subsequent connect() builds a fresh Session. The new edge is declared before the escalation to CreatingWait because StrictMachine takes the first matching transition, and escalating over a dead transport would only relocate the identical endless loop.

Keep-alive

The Event.KeepAlive internal transition captured the machine-wide ActionContext, and its whenComplete read-modify-wrote a single machine-wide failure count with nothing tying the completion back to the Session epoch the Read was sent on. Leaving Active cancels the scheduled future, which stops new keep-alives but does not cancel one already sent, and the Active --ServiceFault--> ReactivatingWait edge never takes the channel down, so failAndClearPending never drains it. The Read stayed parked in the transport across the whole reactivation cycle and, when it finally failed, incremented the new epoch's count. With the default keepAliveFailuresAllowed of 1 that spends an interval of the new epoch's budget on an observation about a Session that is already gone; with two such stale failures it fires Event.KeepAliveFailure outright and closes a still-healthy channel on a Session the Server had just accepted an ActivateSession for. The read-modify-write was also non-atomic — a get under the read lock and a set under the write lock — so completions racing on the shared executor could lose an increment.

The count is now a per-epoch AtomicLong installed on entry to Active, so the instance itself identifies the epoch. The action captures that instance before sending, and the callback returns before touching shared state if the current state is not Active, if KEY_SESSION no longer holds the event's session, or if the key no longer holds the captured instance. The last check is load-bearing: on the ServiceFault route the session and the channel are the same and the next epoch is Active again, so only the per-epoch instance separates them.

The RevisedSessionTimeout the Server answers CreateSession with was stored on the OpcUaSession and exposed as UaSession.getSessionTimeout(), but nothing in sdk-client read it back. The keep-alive was scheduled straight from OpcUaClientConfig.getKeepAliveInterval(), so the one mechanism that keeps an otherwise idle Session alive was driven entirely by a number the client picked and never reconciled with the number the Server granted. Part 4 §5.7.2.2 makes that number binding. When the configured interval lands at or above the revised timeout the Session dies between keep-alives, and the recovery machinery hides it: the Bad_SessionIdInvalid on the next keep-alive becomes Event.ServiceFault, a new Session is created and activated, and all of it is at DEBUG — the WARN needs the failure count to exceed keepAliveFailuresAllowed and the ServiceFault edge pre-empts it. The backoff does not escalate either, since the wait time is cleared on entry to Active, so the client churns Active → ReactivatingWait → Creating → Active for as long as it stays connected, with fresh Subscription transfers each lap and nothing in the log saying why.

The interval is now derived from the Session actually established: the configured value is bounded at half the revised timeout, with a 1000 ms floor on the derived value so a Server revising to something very small cannot turn the keep-alive into a request flood. Half leaves room for exactly one keep-alive round trip to be lost before the Session expires, the same tolerance keepAliveFailuresAllowed already assumes. The outer Math.min means the clamp only ever lowers the interval, so it can only send more keep-alives than before, never fewer, and cannot break a configuration that was already working. A WARN naming all three numbers is emitted only when the clamp changes the value, once per Session epoch. With Milo defaults nothing changes at all.

Connection loss during establishment

The ChannelFsm.TransitionListener that turns a secure channel drop into Event.ConnectionLost is constructed and registered inside the Initializing --> Active transition action, and it reacts only to the edge leaving Connected. netty-channel-fsm dispatches listeners synchronously over the snapshot taken while the event is being evaluated, so a Connected --> ReconnectWait edge evaluated before the listener was added is lost permanently: the rest of the reconnect cycle contains no further transition originating from Connected, and that is the only shape a drop takes for a non-lazy persistent FSM. The listener is also removed on every Active --> non-Active transition, so nothing is watching the channel while the Session is being established.

The window is wide rather than a narrow race. runSequentially wraps each initializer with .exceptionally(ex -> Unit.VALUE), so a request failed with Bad_ConnectionClosed is swallowed and Event.InitializeSuccess fires anyway; the ChannelFsm learns of the loss from a pipeline handler on the netty thread, whereas the SessionFsm can only enqueue InitializeSuccess after the failed request's callback runs on the transport executor, so the ChannelFsm edge is evaluated first essentially every time. The result is a SessionFsm parked in Active holding a Session bound to a secure channel that no longer exists, with no onSessionInactive reported and application requests failing until the first request on the new channel draws Bad_SecureChannelIdInvalid. That fallback makes this delayed recovery rather than a permanently dead Session, but the delay is a keep-alive interval plus reconnect backoff, and it is paid in failed requests.

The action now re-reads channelFsm.getState() immediately after addTransitionListener and, if the ChannelFsm is no longer Connected, fires Event.ConnectionLost itself. The Session can only have been created and activated over a Connected channel, so a non-Connected state at that point means the channel was lost after the Session was established. The synthesized event is evaluated after the transition into Active completes, so it is consumed by the Active --> ReactivatingWait edge; a later duplicate from the listener is harmless, since ConnectionLost is consumed only in Active.

If the drop and the subsequent reconnect both complete before the action runs, the ChannelFsm is back in Connected and the state check cannot see it. Recognizing that would mean recording which channel instance the Session was bound to and comparing identities, a larger change than this is scoped for; that case remains covered by the Bad_SecureChannelIdInvalid path.

TransferSubscriptions indexing

#1823 independently fixed the misindexing this branch was originally written for, capturing the ids sent and the Subscriptions they came from in one pass and bounding both result loops against that aligned list. The two fixes conflicted, and the pairing is kept here in record form rather than as two parallel ArrayLists: the failure mode both address is a pairing that has silently shifted, and a single list of (subscription, id) cannot shift against itself, whereas two lists have to be maintained in lockstep to stay aligned. The ids array is derived from that list, so the request and the results are indexed against the same thing by construction, and the debug-logging zip streams that same array rather than deriving the ids from the snapshot a third time — which was a third independently racy read. The failure loop keeps #1823's double bound and its synchronous handleTransferFailure cleanup.

What is new beyond #1823 is the early return on the filtered list: a snapshot of entirely id-less Subscriptions previously still sent a TransferSubscriptionsRequest carrying no SubscriptionIds.

Behavior changes

  • The keep-alive interval is bounded by the revised Session timeout — half of it, with a 1000 ms floor, and a WARN when the clamp changes the configured value. The clamp only ever lowers the interval. getSessionTimeout() keeps its current meaning as the raw revised value, and the configured value is not mutated.
  • A Session abandoned during establishment, or abandoned in favor of a replacement, is closed on the Server rather than left to expire on its Session timeout. Both closes send deleteSubscriptions false, so the abandoned Session's Subscriptions remain transferable. The explicit client-shutdown close is unchanged and still deletes.
  • Reactivation escalates on the StatusCode, so a definitive session-level status delivered as a channel-level Error now creates a new Session instead of retrying indefinitely.
  • Reactivation over a deliberately disconnected transport ends in Inactive and fails pending getSession()/openSession() futures with the "not connected" cause, instead of cycling forever. A subsequent connect() builds a fresh Session.
  • A synchronous throw from createSession completes the caller's openSession() future with that cause rather than wedging the FSM.
  • No TransferSubscriptions request is sent when no Subscription in the snapshot holds an id.

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

@kevinherron
kevinherron force-pushed the session-fsm-hardening branch from c683e43 to 27dc037 Compare July 30, 2026 01:48
@kevinherron
kevinherron marked this pull request as ready for review July 30, 2026 01:52
@kevinherron kevinherron changed the title SessionFsm hardening SessionFsm Hardening Jul 30, 2026
kevinherron and others added 10 commits August 1, 2026 07:17
publishing-manager-hardening independently fixed the TransferSubscriptions
misindexing this commit was originally written for. 5f848b9 captures the
ids sent and the Subscriptions they came from in one pass and bounds both
result loops against that aligned list, so rebasing onto it left the two
fixes conflicting in SessionFsmFactory.transferSubscriptions: each had
restructured the same block a different way.

The pairing is kept in the record form rather than as the two parallel
ArrayLists 5f848b9 used. The failure mode both fixes address is a pairing
that has silently shifted, and a single list of (subscription, id) cannot
shift against itself, whereas two lists have to be maintained in lockstep
to stay aligned. The ids array is derived from that list, so the request
and the results are indexed against the same thing by construction; the
failure loop keeps the success loop's double bound and reads
transferable.get(i).subscription(); and the debug-logging zip streams that
same array rather than deriving the ids from the snapshot a third time,
which was a third independently racy read.

What is new here beyond 5f848b9 is the isEmpty() early return on the
filtered list: a snapshot of entirely id-less Subscriptions previously
still sent a TransferSubscriptionsRequest carrying no SubscriptionIds.

Deliberately not done: the executor task body is not wrapped in a
catch-all, since bounding the loop removes the only source of the
exception. The transfer-not-supported branch still notifies every
Subscription in the snapshot, which the comment there depends on --
subsequent runs through the FSM skip the transfer precisely because
transferFailed() has been called for all of the existing Subscriptions.

TransferSubscriptionsResultBoundsTest pins the half of the original defect
that is deterministically reachable. A scripted Server answers the transfer
with one Good result per requested id plus one surplus Bad one, and the
client is required to throw nothing out of its transport executor and to
report no Subscription as transfer-failed on account of a result that
belongs to none. It runs the client's transport on its own recording pool
so anything thrown is attributable to the test instead of being logged and
lost in the shared executor. The misindexing itself is a two-statement
window with no seam to interpose on and is not covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StrictMachine sets the next state before it runs the matching transition
actions, and it catches and merely logs whatever those actions throw. An entry
action that throws before it has attached the whenComplete that fires the
corresponding Success/Failure event therefore leaves the SessionFsm parked in
the new state with no event pending -- and no Session state has a timeout to
kick it out again. CloseSession is shelved rather than acted on in Creating,
Activating, Transferring, Initializing and Reactivating (SessionFsmFactory.java
:357, :421, :486, :583, :1050), and a shelved event's future only completes
when a later transition drains the shelf, so a wedge takes disconnect() with
it: OpcUaClient.disconnectAsync() has an .exceptionally but no timeout and a
future that never completes is not an exceptional one, and disconnect() calls
get() with no timeout at all. activateSession and reactivateSession were
already total for exactly this reason; createSession and transferSubscriptions
were not.

createSession has two synchronous throw sites reachable before the request is
ever sent. endpoint.getServer().getGatewayServerUri() NPEs when the configured
EndpointDescription carries no server -- the shape SessionFsmTest itself builds
-- and client.getConfig().getSessionName().get() invokes a user-supplied
Supplier outside any guard. Its body is now wrapped the way
activateSession/reactivateSession already wrap theirs, returning a failed
future, which routes the original Throwable into the existing whenComplete,
fires CreateSessionFailure and takes the FSM to CreatingWait as any other
CreateSession failure would; the caller's openSession() future is completed
with that same cause via handleFailureToOpenSession. closeSession gets the same
guard for symmetry. It has no reachable trigger today -- KEY_SESSION is set at
:628 and never removed, Closing is only reachable from Active and
ReactivatingWait, and neither newRequestHeader nor sendRequestMessage throws
synchronously -- but the Closing entry action binds the caller's CloseSession
future to closeFuture before it calls closeSession, so a throw there would
strand a disconnect with nothing left in the FSM to complete it.

Transferring is exposed by a different mechanism. transferSubscriptions returns
transferFuture but completes it from inside a whenComplete whose own result
future is discarded, so anything the callback throws is captured by a future
nobody observes and transferFuture is never completed at all. The reachable
throw is requireNonNull(tsr.getResults()) against a Server that answers Good
with no Results array; Part 4 5.14.7.2 defines results as the list of results
for the subscriptions to transfer, so an absent one is a Server defect the
client is nonetheless the party that has to survive. An .exceptionally on that
discarded chain now guarantees transferFuture is always completed. It is a
no-op whenever the callback completed transferFuture itself, which is every
failure the callback already handles, so the only behaviour it adds is turning
a throw into TransferSubscriptionsFailure and starting over via CreatingWait.

Deliberately not done: transferSubscriptions' synchronous prologue is not
wrapped, since it has no known throw site and a full-body wrap would be pure
re-indentation. The Initializing path is untouched -- OpcUaClient registers two
built-in SessionInitializers in its constructor (OpcUaClient.java:440, :485),
so every third-party initializer runs from inside the .thenCompose at
SessionFsmFactory.java:1639, where CompletableFuture converts a synchronous
throw into a failed future and InitializeFailure fires normally; only the first
initializer could wedge by throwing, and that is always the built-in
NamespaceTable reader. No watchdog timeout was added to any FSM state, and no
orTimeout to disconnectAsync(); both would paper over a wedge rather than
remove one.

Two regression tests are included, and all three cases fail against the unfixed
code. CreateSessionActionFailureTest pins both createSession throw sites and
needs no Server at all, since createSession throws before it touches the
transport; it asserts openSession() is completed with the original cause rather
than left waiting, and against HEAD both cases time out at their 10s bound.
TransferSubscriptionsNullResultsTest scripts a Server that answers the first
TransferSubscriptions Good with a null results array and every subsequent one
normally, so a client that treats the first as a failure and starts over
recovers and only a wedged one never becomes Active again; its load-bearing
waits are latches with 30s bounds, per the TransferSubscriptionsResultBoundsTest
idiom beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CloseSessionRequest was built in exactly one place, the private closeSession()
helper, whose only caller was the onTransitionTo(State.Closing) entry action --
and that action reads KEY_SESSION, a context key written only on entry to
Active (SessionFsmFactory.java:633). Closing is in turn reachable only from
Active and ReactivatingWait. So once CreateSession had succeeded, every
establishment failure that followed dropped straight back to CreatingWait via
handleFailureToOpenSession, which did nothing but fail the pending
SessionFuture: the Activating --ActivateSessionFailure--> CreatingWait,
Transferring --TransferSubscriptionsFailure--> CreatingWait and Initializing
--InitializeFailure--> CreatingWait edges all abandoned a Session that exists on
the Server with no way left to name it, since the failure events carry only a
Throwable. The Server reaps such a Session only when its timeout elapses
(Session.checkTimeout); nothing ties Session lifetime to the SecureChannel, and
a rejected ActivateSession leaves the entry in createdSessions. With a 120s
default revised timeout and the CreatingWait backoff capped at MAX_WAIT_SECONDS
= 16, a persistent ActivateSession failure -- wrong credentials, an untrusted
X.509 identity -- keeps a rolling set of roughly eight orphans alive
indefinitely.

KEY_PENDING_SESSION now names the state the FSM previously had no word for: a
Session that exists on the Server but is not yet reachable via KEY_SESSION. It
holds the CreateSessionResponse, is set in the Creating --CreateSessionSuccess-->
Activating entry action, where both the sessionId and the authentication token
are in hand, and is removed on entry to Active beside KEY_SESSION.set(), so
exactly one of the two keys ever refers to a live Session.
handleFailureToOpenSession removes it and, when it is non-null, sends a
best-effort CloseSession. That one hook covers all three edges because they all
route through it, and it is a no-op on the edges where nothing is pending --
CreateSessionFailure, CreatingWait --CloseSession--> Inactive, and
ReactivatingWait --CloseSession--> Closing, the last of which already sends its
own CloseSession from the Closing entry action, so nothing is closed twice. The
whole CreateSessionResponse is kept rather than just the authentication token
because closeSession's MDC logging wants the sessionId as well;
closeSession(ctx, client, session) is now a thin overload over
closeSession(ctx, client, sessionId, authToken) so the request building is
shared rather than duplicated.

The Reactivating --ReactivateSessionFailure(ServiceFault)--> CreatingWait edge
is fixed separately, since what it abandons is the old Session rather than a
pending one: it removes KEY_SESSION and closes it best-effort. That Session may
well still exist -- the fault might have been Bad_IdentityTokenRejected rather
than Bad_SessionIdInvalid -- and a Bad_SessionIdInvalid answer to the close is
swallowed like every other outcome. Removing the key is safe because
reactivateSession() reads it under an assert and Reactivating is reachable only
from ReactivatingWait, which is reachable only from Active, which always sets it
again first.

The close is best-effort in the idiom already there: the returned future is
dropped, the request goes out with a 5s timeout, and both success and fault are
swallowed by the existing whenCompleteAsync, so no failure path is delayed or
has its outcome altered. Deliberately not done: the Server's SessionManager is
untouched -- it is entitled to hold a created Session until its timeout, and the
client is the party that knows it has abandoned one -- and the backoff schedule
is unchanged.

SessionFsmTest#testSessionClosedWhenActivateSessionFails pins the Activating
edge, which is the one reachable without a scripted Server: an X509IdentityProvider
carrying a certificate the Server does not trust gets past CreateSession and is
rejected at ActivateSession. It waits on SessionListener notifications for the
Session the retry creates, proving the first attempt was abandoned, then waits
up to 30s for the first Session to be reported closed; against the unfixed code
it fails at that bound. The Transferring, Initializing and Reactivating edges
remain uncovered: the first two would need a scripted service set, and
Event.InitializeFailure is in any case effectively unreachable today because
runSequentially swallows initializer failures -- a separate issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Event.KeepAlive internal transition (SessionFsmFactory.java:753) captures
the machine-wide ActionContext, and its whenComplete read-modify-wrote a single
machine-wide KEY_KEEP_ALIVE_FAILURE_COUNT with nothing tying the completion back
to the Session epoch the Read had been sent on. Leaving Active cancels
KEY_KEEP_ALIVE_SCHEDULED_FUTURE (:687), which stops new keep-alives from being
scheduled but does not cancel one that has already gone out, and the Active
--ServiceFault--> ReactivatingWait edge (:598) never takes the channel down, so
failAndClearPending -- which runs only on channel error or inactive
(AbstractUascClientTransport.java:192) -- never drains it. The Read therefore
stays parked in the transport across the whole ReactivatingWait -> Reactivating
-> Initializing -> Active cycle, and when it finally failed its callback
incremented the *new* epoch's count. With the default keepAliveFailuresAllowed
of 1 that spends an interval of the new epoch's budget on an observation about a
Session that is already gone, so a single subsequent real failure ends the epoch
one interval early; with two such stale failures it fires Event.KeepAliveFailure
outright and closes the channel on a Session the Server had just accepted an
ActivateSession for. The channel closed is the same still-healthy one throughout
-- reactivateSession (:1442) reuses the same OpcUaSession over the same
transport, so no reconnect happens on this route -- which is what makes the
teardown gratuitous rather than merely mistimed. The read-modify-write was also
non-atomic, a get under the read lock and a set under the write lock as two
separate operations, so completions racing on the shared cached executor could
lose an increment and a late success could zero a newer failure's count.

The failure count is now a per-epoch object rather than a machine-wide value:
KEY_KEEP_ALIVE_FAILURE_COUNT becomes an FsmContext.Key<AtomicLong> and entry to
Active installs a fresh AtomicLong (:622) instead of setting 0L, so the instance
itself identifies the epoch. The action captures that instance before calling
sendKeepAlive, and the whenComplete returns -- before touching any shared state
-- if ctx.currentState() is not Active, if KEY_SESSION no longer holds
event.session, or if the key no longer holds the captured instance. The last
check is the load-bearing one: on the ServiceFault route the session and the
channel are the same and the next epoch is Active again, so the other two would
both pass and only the per-epoch instance separates the epochs. They are kept
because they do catch a callback landing while the FSM is in ReactivatingWait,
Closing or Inactive, where the counter has not been replaced yet. Success and
failure then act on the captured instance via set(0L) and incrementAndGet(),
which makes the increment atomic and confines both the reset and the increment
to the epoch that observed them.

Deliberately not done: nothing cancels or fails an in-flight keep-alive when
Active is left, and the ServiceFault route still leaves the channel up. A Read
parked at the Server is harmless once its answer is discarded, and failing
pending requests from the session layer would reach into requests the channel
owns. Keep-alive scheduling and keepAliveTimeout semantics are unchanged.

StaleKeepAliveEpochTest#keepAliveSentOnAPreviousEpochDoesNotFaultTheCurrentOne
pins the behaviour with a gating AttributeServiceSet: it holds one keep-alive
Read of Server_ServerStatus_State at the Server, provokes the ServiceFault route
with a Read the Server answers Bad_SessionIdInvalid, waits on a
SessionActivityListener until the Session has gone inactive and become active
again over that same channel, and only then releases the held Read as a failure.
keepAliveFailuresAllowed is set to 0 so that one stale failure decides the
outcome, and the test asserts the re-activated Session is not taken back out of
Active; against the unfixed code it is. Nothing in it is timing-dependent -- the
Read is held until the test releases it, and it is not released until the next
epoch is established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ChannelFsm.TransitionListener that turns a secure channel drop into
Event.ConnectionLost is constructed and registered inside the Initializing -->
Active transition action (SessionFsmFactory.java:671), and it reacts only to the
edge leaving Connected (:656). netty-channel-fsm dispatches its listeners
synchronously, from a TransitionAction, over the CopyOnWriteArrayList snapshot
taken while the event is being evaluated, so a Connected --> ReconnectWait edge
evaluated before the listener was added is lost permanently: the remainder of
the reconnect cycle -- ReconnectWait --> Reconnecting --> Connected -- contains
no further transition originating from Connected, and Milo configures the FSM
non-lazy and persistent, so that is the only shape a drop takes. The listener is
also removed again on every Active --> non-Active transition (:710), so nothing
at all is watching the channel while the Session is being established.

The window that matters is the session-initializer phase, and it is wide rather
than a narrow race. runSequentially wraps each initializer with
.exceptionally(ex -> Unit.VALUE) (:1714), so a request failed with
Bad_ConnectionClosed by the drop is swallowed and Event.InitializeSuccess fires
anyway; the ChannelFsm meanwhile learns of the loss from a pipeline handler on
the netty thread, whereas the SessionFsm can only enqueue InitializeSuccess
after failAndClearPending has run the failed request's callback on the transport
executor, so the ChannelFsm edge is evaluated first essentially every time. The
result is a SessionFsm parked in Active holding an OpcUaSession bound to a
secure channel that no longer exists -- each reconnect builds a new one -- with
no onSessionInactive reported and application requests failing until the first
request on the new channel draws Bad_SecureChannelIdInvalid from the Server and
SessionFaultListener converts it into Event.ServiceFault. That fallback makes
this delayed recovery rather than a permanent dead Session, but the delay is a
keep-alive interval plus reconnect backoff, and it is paid in failed requests.

The fix closes the registration window at its source: immediately after
addTransitionListener the action re-reads channelFsm.getState() and, if the
ChannelFsm is no longer Connected, fires Event.ConnectionLost itself (:681).
The Session can only have been created and activated over a Connected channel,
so a non-Connected state at this point means the channel was lost after the
Session was established, which is exactly what the listener exists to report.
The synthesized event is enqueued and evaluated after the transition into Active
completes, so it is consumed by the Active --> ReactivatingWait edge (:600); a
later duplicate from the listener itself is harmless, since ConnectionLost is
consumed only in Active and is dropped everywhere else.

Deliberately not done: if the drop and the subsequent reconnect both complete
before the action runs, the ChannelFsm is back in Connected and the state check
cannot see it. Recognizing that would mean recording which channel instance the
Session was bound to and comparing identities, a larger change than this one is
scoped for; that case remains covered by the Bad_SecureChannelIdInvalid -->
Event.ServiceFault path. runSequentially still swallows initializer failures --
that is a separate defect -- and where the Session binds to the channel is
unchanged.

SessionFsmTest#testConnectionLostDuringInitializationIsNotMissed pins the
behaviour without depending on winning a race. A SessionInitializer added last,
so it runs after the built-ins, attaches its own ChannelFsm.TransitionListener,
closes the channel, and does not complete until that listener has observed the
edge leaving Connected -- which orders the drop strictly before the SDK
registers its listener. keepAliveInterval is set to 60s so the fallback cannot
mask the result, and the test issues no requests of its own, so the only thing
that can take the FSM out of Active within the assertion's 10s bound is the
synthesized event. Against the unfixed code it fails at that bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RevisedSessionTimeout the Server answers CreateSession with was read out of
the response and stored on the OpcUaSession (SessionFsmFactory.java:1469), and
exposed on the public API as UaSession.getSessionTimeout()
(OpcUaSession.java:71), but nothing in sdk-client ever read it back. The
keep-alive was scheduled straight from OpcUaClientConfig.getKeepAliveInterval()
on the single Initializing --InitializeSuccess--> Active edge, so the one
mechanism that exists to keep an otherwise idle Session alive was driven
entirely by a number the Client picked and never reconciled with the number the
Server actually granted. Part 4 §5.7.2.2 makes that number binding: the Server
terminates the Session if the Client issues no request within the revised
timeout, and it is free to revise the request down to whatever it likes.

When the configured interval lands at or above the revised timeout the Session
therefore dies between keep-alives, and the recovery machinery hides it. The
Bad_SessionIdInvalid on the next keep-alive is turned into Event.ServiceFault by
SessionFaultListener, Active goes to ReactivatingWait, Reactivating fails and
drops to CreatingWait, and a new Session is created and activated -- all of it
at DEBUG. The WARN at :848 does not fire, because it needs the keep-alive
failure count to exceed keepAliveFailuresAllowed and the ServiceFault edge
pre-empts it. The backoff does not escalate either, since KEY_WAIT_TIME is
cleared on entry to Active, so the client simply churns Active ->
ReactivatingWait -> Creating -> Active for as long as it stays connected, at
roughly the keep-alive interval plus two seconds a lap, with a fresh Session and
fresh subscription transfers each time and nothing in the log saying why.

The interval is now derived from the Session that was actually established
rather than from config alone (:631): the configured value is bounded at half
the revised timeout, with MIN_KEEP_ALIVE_INTERVAL = 1000ms (:115) as a floor on
the derived value so that a Server revising to something very small cannot turn
the keep-alive into a request flood. Half leaves room for exactly one keep-alive
round trip to be lost before the Session expires, which is the same tolerance
keepAliveFailuresAllowed already assumes. The outer Math.min means the clamp
only ever lowers the interval -- a deliberately configured 100ms keep-alive is
still 100ms, and the floor never raises anything -- so it can only send more
keep-alives than before, never fewer, and cannot break a configuration that was
already working. A WARN naming all three numbers is emitted through the existing
putInstanceId/putSessionId MDC idiom when, and only when, the clamp changes the
value; the action runs once per entry to Active and Active is the only place
keep-alives are scheduled, so it is once per Session epoch. With Milo defaults
(5000ms keep-alive, 120000ms revised) nothing changes at all.

Deliberately not done: the configured value is not mutated, the clamp is local
to the scheduling call, and getSessionTimeout() keeps its current meaning as the
raw revised value. The KEY_WAIT_TIME resets that flatten the backoff on this
route are left alone -- that is a defect in its own right, and fixing it here
would change the timing of every other recovery path.

SessionFsmTest#testKeepAliveIntervalHonorsRevisedSessionTimeout pins the
behaviour end to end rather than the arithmetic. Milo's Server floors its
revision at 5000ms (SessionManager.java:242), so a client asking for 1000ms gets
exactly 5000ms; configuring a 20s keep-alive against it puts the Session past
the expiry boundary. The test registers a server-side SessionListener, issues no
application traffic at all, and asserts the Session is not reported closed
within 12s -- two revised timeouts, and well short of the first configured
keep-alive, so the only thing that can keep it alive is the clamped interval.
Against the unfixed code the latch trips at about 6s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reactivating had exactly one edge to CreatingWait, guarded by a predicate that
asked UaException.extract(failure) whether the result was a
UaServiceFaultException (SessionFsmFactory.java:1054). Every other
ReactivateSessionFailure took the catch-all edge back to ReactivatingWait
(:1090), where the wait doubles and saturates at MAX_WAIT_SECONDS = 16 (:108).
That made the decision to abandon a Session turn on how the Server chose to
frame its answer rather than on what the answer said. A Server that replies to
ActivateSession with a channel-level Error message produces a plain UaException
carrying the same StatusCode -- InboundUascResponseHandler.java:60 builds
`new UaException(statusCode, reason)` and hands it to handleChannelError, whose
failAndClearPending completes the in-flight ActivateSession with it -- so
Bad_SessionIdInvalid, Bad_SessionClosed and Bad_SessionNotActivated delivered
that way never escalated. The Client then sat in Reactivating <->
ReactivatingWait at the 16s cap for as long as it stayed connected, re-sending
ActivateSession every lap for a Session the Server had already told it, in as
many words, that it no longer has. Part 4 §6.7 says the Client shall create a
new Session if ActivateSession fails, and that obligation does not depend on
the message type the status arrived in.

The guard now keys on the StatusCode as well as the exception class. The
SESSION_ERROR predicate that already names the three definitive session-level
statuses is hoisted out of SessionFaultListener to a top-level constant (:121)
with its contents unchanged -- the listener still uses it, now via the
enclosing class -- and the predicate is renamed to
isReactivateSessionFailureFatal, since it is no longer about the exception
type. Failures that carry no such status, e.g. Bad_Timeout or
Bad_ConnectionClosed from a request timer or a dropped channel, are
connectivity problems and deliberately keep the indefinite ReactivatingWait
retry: Part 4 §5.7.2.1 has the Client establish a new connection and call
ActivateSession again, and dropping to CreateSession while the channel is
merely down throws away a Session whose Subscriptions could still be
transferred. Behaviour against a conformant Server is unchanged, Milo's own
included -- SessionManager.java:697 answers a stale ActivateSession with a
Bad_SessionIdInvalid ServiceFault, which already matched the first disjunct.
This is the same defective-Server class the file already guards against on the
TransferSubscriptions path (:1721).

Deliberately not done: no bounded reactivation attempt count. CreatingWait
retries forever with the same cap, so a cap here would relocate the loop rather
than remove one, and it would abandon a recoverable Session during a plain
outage. The ClassCastException that `.thenApply(ActivateSessionResponse.class::cast)`
in reactivateSession can raise is also left on the retry branch; extract()
returns empty for it, so no StatusCode-based guard can see it.

ReactivateSessionEscalationTest pins the contrast rather than the predicate.
Both methods arm the transport to answer every ActivateSession with
Bad_SessionIdInvalid and differ only in how it is delivered -- as a
UaServiceFaultException in the control, as a plain UaException in the case that
was broken -- so the only variable is the framing. The failure is injected at
the transport because a conformant Server cannot be made to emit a
channel-level Error through a SessionServiceSet. Arming happens after connect
and strictly before the channel is dropped, and both assertions are eventual
polls on counters the transport keeps, so nothing races: the test asserts a
reactivation was attempted and then that a CreateSessionRequest follows.
Against the unfixed code the channel-level case fails at its 15s bound, four
reactivation attempts in, while the control passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Milo builds its ChannelFsm with setLazy(false) and setPersistent(true)
(OpcTcpClientTransport.java:83,86), so a channel that drops unexpectedly is
reconnected without the Session doing anything. There is exactly one state that
does not self-heal: NotConnected, reachable only through an explicit Disconnect
event, whose sole outbound edge is Connect and whose GetChannel is an internal
transition that completes immediately with a plain Exception("not connected").
An application that calls client.getTransport().disconnect(), or
getChannelFsm().disconnect(), while a Session is Active therefore parks the
transport somewhere nothing will move it from.

The Session did not notice. It left Active for ReactivatingWait either at once
-- the transition listener installed on the Active edge fires Event.ConnectionLost
for any transition out of Connected, and Connected --Disconnect--> Disconnecting
is one (:711, :623) -- or one keep-alive interval later via Event.KeepAliveFailure
(:867), since the keep-alive goes over the same dead channel. Every reactivation
attempt then failed with that bare Exception. It carries no StatusCode and is not
a UaException at all, so UaException.extract() returns empty and the escalation
predicate at :1054 could never match; the failure fell through to the catch-all
edge at :1120 and went back to ReactivatingWait. The FSM cycled
ReactivatingWait <-> Reactivating, doubling the wait to the MAX_WAIT_SECONDS = 16
cap (:108) and staying there for the life of the client, re-sending
ActivateSession over a channel whose getChannel() had already failed, with every
caller blocked in getSession() left hanging.

Reactivating now has a terminal edge to Inactive (:1073) guarded by
isReactivateSessionFailureTerminal, which is the ordinary reactivation-failure
predicate plus isTransportDisconnectedForGood() (:1190): true only when the
transport is an OpcTcpClientTransport whose ChannelFsm reads NotConnected, false
for any other transport, so websocket and https behaviour is untouched -- the
ChannelFsm is only reachable through OpcTcpClientTransport, exactly as the
existing listener installation already assumes. The edge clears KEY_WAIT_TIME so
a later Session starts from a fresh backoff, drops KEY_SESSION, logs one warning,
and calls handleFailureToOpenSession() so pending getSession()/openSession()
futures fail with the "not connected" cause instead of waiting forever. The
Session is deliberately not closed on the Server: there is no channel to send
CloseSession over, so it is left to expire on its Session timeout (Part 4
§5.6.2). Leaving the FSM in Inactive keeps both exits open -- the existing
onTransitionTo(Inactive) action calls processShelvedEvents, so a client.disconnect()
whose CloseSession was shelved during Reactivating still completes, and
Inactive --OpenSession--> Creating means a subsequent client.connect() builds a
fresh Session.

The new edge is declared before the escalation to CreatingWait at :1096 because
StrictMachine takes the first matching transition (StrictMachine.java:173-177),
and escalating over a dead transport would only relocate the identical endless
loop into CreatingWait <-> Creating.

Deliberately not done: the alternative of teaching the transition listener to
ignore com.digitalpetri.netty.fsm.Event.Disconnect. That only postpones the loop
by a keep-alive interval or two, since the keep-alive path reaches it
independently, and in the meantime it would leave the Session reporting Active
over a secure channel that no longer exists. Guarding at the reactivation-failure
edge instead covers every route into the retry cycle -- listener, keep-alive and
ServiceFault alike. The CreatingWait <-> Creating loop has the same shape of trap
and is also left alone: it is entered only because the application asked for a
Session, where continuing to retry is the more defensible reading, and the
ordering above means the reactivation path can no longer feed it. One race is
accepted by construction: a disconnect() immediately followed by connect() can
have a reactivation failure land inside the NotConnected window and abandon the
Session, which is the intended reading of a deliberate disconnect, and connect()
creates a new one from Inactive regardless.

SessionFsmTest.testDeliberateTransportDisconnectDoesNotReactivateForever pins
this. It disconnects the ChannelFsm out from under an Active Session, asserts the
transport really did reach NotConnected, then observes the SessionFsm for 15s and
requires it to be in neither ReactivatingWait nor Reactivating. The bound is
sound rather than merely generous: no event can reconnect the ChannelFsm and no
reactivation failure over a dead channel can escalate, so nothing after the
window could end the cycle. The keep-alive interval is set to 1s so the
keep-alive route gets several chances inside it. Against the unfixed code the
test fails at the bound with state=ReactivatingWait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PublishCeilingRecoveryTest's anti-hammering run failed intermittently under the
contention of the whole integration-tests module in two forks, most recently
with intervals [8, 16, 33, 65, 129, 1] against an assertion that the window ends
with a longer wait than it began with. Two separate defects in the harness
produced that, and neither is a property of the client under test.

The positions were inferred rather than observed. deliverAndRecordRefusals()
sampled getRejectionCount() after each delivery and attributed whatever it found
to the round trip it had just answered, so a refusal was recorded against the
round trip during which the test happened to notice it. The only thing holding a
probe inside its own round trip was the BURST_SETTLE_MILLIS window, five
milliseconds, and a probe that arrived after it was charged to the next delivery
and shifted every interval after itself -- visible as the schedule drifting away
from the base-8 doubling it should follow, 56 -> 57, 120 -> 122, 248 -> 251, one
round trip at a time. The Server now records the round trip itself, under the
same lock and in the same critical section that increments the rejection count,
so a position no longer depends on when any other thread looked. noteDelivery()
is called before the answer is enqueued rather than after, so a probe that
arrives while enqueueKeepAlive() is still returning is charged to the round trip
that provoked it.

The trailing interval of 1 is the second defect and survives the first fix: it
is a real refusal, not a misattributed one. A refused request returns to the
client like any other and the client replaces it; that replacement meets the
same full queue and is refused in its turn, so one question the client asked
costs the Server two refusals. Both observed failures show them at 251 and 252 —
one round trip apart, which no geometric cooldown produces. The two are one
probe. coalesceProbes() folds refusals within QUIET_PREFIX_DELIVERIES round
trips of the one that opened the run into it, which is not a tolerance: this
class already requires the cooldown to be longer than that, and
ImmediateResponseToTheFault is what pins it.

The assertions are unchanged. Nothing here weakens what the test requires of the
client: the probe-count bounds, the direction of the cooldown, and the quiet
prefix all still say exactly what they said.

Honest limitation: this does not come with a demonstration that the old harness
fails and the new one does not. The failure needs the whole module running in
two forks and I could not provoke it on demand -- thirty-two spinning cores and
a load average of sixty leave both versions passing four runs out of four. What
is offered instead is that both mechanisms above are derived from the recorded
failures rather than guessed at, that the drift they predict is exactly the
drift the two failing runs show, and that the schedule is now stable at
[8, 24/25, 57, 122, 251] across repeated runs where it previously wandered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing a Session abandoned in favor of a replacement must leave its
Subscriptions available for TransferSubscriptions. Keep deletion for
explicit client shutdown, and exercise the retry against a real
server-side transfer so destructive cleanup cannot pass unnoticed.
@kevinherron
kevinherron force-pushed the session-fsm-hardening branch from dc6ce35 to 8714e74 Compare August 1, 2026 14:23
@kevinherron
kevinherron changed the base branch from publishing-manager-hardening to main August 1, 2026 14:23
@kevinherron
kevinherron merged commit d069c25 into main Aug 1, 2026
1 check 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