SessionFsm Hardening - #1825
Merged
Merged
Conversation
kevinherron
force-pushed
the
session-fsm-hardening
branch
from
July 30, 2026 01:48
c683e43 to
27dc037
Compare
kevinherron
marked this pull request as ready for review
July 30, 2026 01:52
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
force-pushed
the
session-fsm-hardening
branch
from
August 1, 2026 14:23
dc6ce35 to
8714e74
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Corrects client-side Session establishment, reactivation, keep-alive accounting, and teardown in
SessionFsmandSessionFsmFactory, 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-machineandnetty-channel-fsmsources 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
StrictMachinesets 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 thewhenCompletethat 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.CloseSessionis shelved rather than acted on inCreating,Activating,Transferring,InitializingandReactivating, and a shelved event's future completes only when a later transition drains the shelf, so a wedge takesdisconnect()with it:disconnectAsync()has anexceptionallybut no timeout, and a future that never completes is not an exceptional one.activateSessionandreactivateSessionwere already total for this reason.createSessionandcloseSessionnow are too.createSessionhad two synchronous throw sites reachable before the request is sent —endpoint.getServer().getGatewayServerUri()NPEs when the configuredEndpointDescriptioncarries no server, andgetSessionName().get()invokes a user-suppliedSupplieroutside any guard — and its body is now wrapped the way the other two already wrap theirs, routing the originalThrowableinto the existingwhenCompleteand on toCreatingWaitas any other CreateSession failure would.Transferringwas exposed by a different mechanism:transferSubscriptionsreturnstransferFuturebut completes it from inside awhenCompletewhose own result future is discarded, so anything the callback throws is captured by a future nobody observes. The reachable throw isrequireNonNull(tsr.getResults())against a Server answering Good with no Results array. Anexceptionallyon that discarded chain now guaranteestransferFutureis always completed.Sessions abandoned on the Server
CloseSessionRequestwas built in one place, whose only caller was theClosingentry action — and that action readsKEY_SESSION, a context key written only on entry toActive. Once CreateSession had succeeded, every establishment failure that followed dropped straight back toCreatingWaitviahandleFailureToOpenSession, which did nothing but fail the pending Session future. TheActivating,TransferringandInitializingfailure edges all abandoned a Session that exists on the Server with no way left to name it, since the failure events carry only aThrowable. 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 increatedSessions. With a 120s default revised timeout and theCreatingWaitbackoff 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_SESSIONnow names the state the FSM had no word for: a Session that exists on the Server but is not yet reachable viaKEY_SESSION. It holds theCreateSessionResponse, is set on theCreating --CreateSessionSuccess--> Activatingedge where both the sessionId and the authentication token are in hand, and is removed on entry toActivebesideKEY_SESSION.set(), so exactly one of the two keys ever refers to a live Session.handleFailureToOpenSessionremoves 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. TheReactivating --ReactivateSessionFailure--> CreatingWaitedge 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
deleteSubscriptionsfalse, so an abandoned Session's Subscriptions survive for the replacement Session'sTransferSubscriptionsto recover; anabandonSessionoverload names that intent besidecloseSession. TheClosingentry action — the explicit client-shutdown path, and the only CloseSession the base branch ever sent — still closes with deletion and is otherwise untouched.Reactivation
Reactivatinghad one edge toCreatingWait, guarded by a predicate asking whetherUaException.extract(failure)produced aUaServiceFaultException. Every otherReactivateSessionFailuretook the catch-all edge back toReactivatingWait, 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 plainUaExceptioncarrying the same StatusCode —InboundUascResponseHandlerbuilds it andfailAndClearPendingcompletes the in-flight ActivateSession with it — soBad_SessionIdInvalid,Bad_SessionClosedandBad_SessionNotActivateddelivered 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_ERRORpredicate naming the three definitive session-level statuses is hoisted out ofSessionFaultListenerto a top-level constant with its contents unchanged. Failures carrying no such status —Bad_TimeoutorBad_ConnectionClosedfrom a request timer or a dropped channel — are connectivity problems and deliberately keep the indefiniteReactivatingWaitretry: 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)andsetPersistent(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 whoseGetChannelcompletes immediately with a plainException("not connected"). An application callinggetTransport().disconnect()while a Session is Active parks the transport somewhere nothing will move it from, and the Session did not notice — it leftActiveforReactivatingWaiteither at once via the transition listener or one keep-alive interval later, and every reactivation attempt then failed with a bareExceptionthat carries no StatusCode and is not aUaException, so no escalation predicate could match. The FSM cycledReactivatingWait⇄Reactivatingat the 16s cap for the life of the client with every caller blocked ingetSession()left hanging.Reactivatingnow has a terminal edge toInactive, guarded by the ordinary reactivation-failure predicate plus a check that the transport is anOpcTcpClientTransportwhose ChannelFsm readsNotConnected— 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, dropsKEY_SESSION, logs one warning, and callshandleFailureToOpenSessionso pending futures fail with the "not connected" cause instead of waiting forever. Leaving the FSM inInactivekeeps both exits open: the existingonTransitionTo(Inactive)action drains the shelf, so adisconnect()whose CloseSession was shelved still completes, andInactive --OpenSession--> Creatingmeans a subsequentconnect()builds a fresh Session. The new edge is declared before the escalation toCreatingWaitbecauseStrictMachinetakes the first matching transition, and escalating over a dead transport would only relocate the identical endless loop.Keep-alive
The
Event.KeepAliveinternal transition captured the machine-wideActionContext, and itswhenCompleteread-modify-wrote a single machine-wide failure count with nothing tying the completion back to the Session epoch the Read was sent on. LeavingActivecancels the scheduled future, which stops new keep-alives but does not cancel one already sent, and theActive --ServiceFault--> ReactivatingWaitedge never takes the channel down, sofailAndClearPendingnever 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 defaultkeepAliveFailuresAllowedof 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 firesEvent.KeepAliveFailureoutright 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
AtomicLonginstalled on entry toActive, 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 notActive, ifKEY_SESSIONno 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 isActiveagain, so only the per-epoch instance separates them.The
RevisedSessionTimeoutthe Server answers CreateSession with was stored on theOpcUaSessionand exposed asUaSession.getSessionTimeout(), but nothing in sdk-client read it back. The keep-alive was scheduled straight fromOpcUaClientConfig.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: theBad_SessionIdInvalidon the next keep-alive becomesEvent.ServiceFault, a new Session is created and activated, and all of it is at DEBUG — the WARN needs the failure count to exceedkeepAliveFailuresAllowedand the ServiceFault edge pre-empts it. The backoff does not escalate either, since the wait time is cleared on entry toActive, so the client churnsActive → ReactivatingWait → Creating → Activefor 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
keepAliveFailuresAllowedalready assumes. The outerMath.minmeans 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.TransitionListenerthat turns a secure channel drop intoEvent.ConnectionLostis constructed and registered inside theInitializing --> Activetransition action, and it reacts only to the edge leavingConnected. netty-channel-fsm dispatches listeners synchronously over the snapshot taken while the event is being evaluated, so aConnected --> ReconnectWaitedge evaluated before the listener was added is lost permanently: the rest of the reconnect cycle contains no further transition originating fromConnected, and that is the only shape a drop takes for a non-lazy persistent FSM. The listener is also removed on everyActive --> non-Activetransition, so nothing is watching the channel while the Session is being established.The window is wide rather than a narrow race.
runSequentiallywraps each initializer with.exceptionally(ex -> Unit.VALUE), so a request failed withBad_ConnectionClosedis swallowed andEvent.InitializeSuccessfires anyway; the ChannelFsm learns of the loss from a pipeline handler on the netty thread, whereas the SessionFsm can only enqueueInitializeSuccessafter 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 inActiveholding a Session bound to a secure channel that no longer exists, with noonSessionInactivereported and application requests failing until the first request on the new channel drawsBad_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 afteraddTransitionListenerand, if the ChannelFsm is no longerConnected, firesEvent.ConnectionLostitself. 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 intoActivecompletes, so it is consumed by theActive --> ReactivatingWaitedge; a later duplicate from the listener is harmless, sinceConnectionLostis consumed only inActive.If the drop and the subsequent reconnect both complete before the action runs, the ChannelFsm is back in
Connectedand 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 theBad_SecureChannelIdInvalidpath.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 synchronoushandleTransferFailurecleanup.What is new beyond #1823 is the early return on the filtered list: a snapshot of entirely id-less Subscriptions previously still sent a
TransferSubscriptionsRequestcarrying no SubscriptionIds.Behavior changes
getSessionTimeout()keeps its current meaning as the raw revised value, and the configured value is not mutated.deleteSubscriptionsfalse, so the abandoned Session's Subscriptions remain transferable. The explicit client-shutdown close is unchanged and still deletes.Inactiveand fails pendinggetSession()/openSession()futures with the "not connected" cause, instead of cycling forever. A subsequentconnect()builds a fresh Session.createSessioncompletes the caller'sopenSession()future with that cause rather than wedging the FSM.TransferSubscriptionsrequest is sent when no Subscription in the snapshot holds an id.🤖 Originally generated with Claude Code; updated after subsequent code review.