Fix WebSocket client wedging that made OpenICFWebSocketTest flaky#104
Fix WebSocket client wedging that made OpenICFWebSocketTest flaky#104vharseko wants to merge 2 commits into
Conversation
…enIdentityPlatform#102) - ClientRemoteConnectorInfoManager: always release the connection permit in onClosed - the inverted guard leaked the last permit of a single-connection client, leaving it unable to reconnect forever; self-heal a permit lost by a connect attempt that failed before the Grizzly Connection was created - WebSocketConnectionGroup/Holder: send the initial CONNECTOR_INFO request only after the holder has stored its RemoteOperationContext, so an early response can no longer be dropped with an NPE; guard trySubmitRequest returning null - OpenICFWebSocketTest: replace the unreachable 5-minute poll loop with real 20-second polling attempts - grizzly/jetty poms: raise forkedProcessTimeoutInSeconds to 900 so a single hung test fails itself instead of killing the whole fork
maximthomas
left a comment
There was a problem hiding this comment.
Self-heal can over-credit a permit for an in-flight connection (medium)
ClientRemoteConnectorInfoManager.java:209-219 — lastConnectAttempt is stamped before connectionHandler.connect(...) (:385), but the holder only reaches privateConnections in thenOnResult
(:401-410), i.e. after the WebSocket handshake. If TCP connects but the handshake stalls past 30s (the same failure class this PR is about), the heartbeat releases a permit a live pending connection
still holds — then that connection closes and its listener releases again. The cap bounds available, not available + live connections, so you can transiently exceed expectedConnectionCount.
Suggested: stamp in preConfigure() (where the close listener is registered) and heal only when the last attempt produced no Connection:
&& lastConnectionCreated.get() < lastConnectAttempt.get()That matches the invariant the comment describes, and a same-ms tie correctly declines to heal. Note the heal still only covers privateConnections.isEmpty(), so a permit leaked while other connections
are live never recovers — fine as a narrow fix, just not a complete one.
Nits
receivedConnectorInfois still non-volatile: written from callback threads (WebSocketConnectionGroup.java:139), read from the scheduler (:354). Pre-existing, but you're adding anAtomicBoolean
on the next line — cheap to fix while here.- The
CONNECTOR_INFOfactory is now built in three places; a small private helper would dedupe.
…onnection - Heal only when the last connect attempt never created a Connection (lastConnectionCreated < lastConnectAttempt): if preConfigure ran, the close listener is registered and will release the permit itself, so healing would over-credit while the connection is pending its handshake. - Make receivedConnectorInfo volatile: written from transport callbacks, read by the scheduler in checkIsActive(). - Dedupe the CONNECTOR_INFO request factory into a helper.
|
@maximthomas thanks for the careful review — all three points addressed in 0fd9082. Self-heal over-credit — fixed as you suggested: a new One detail from the Grizzly 4.0.2 sources that makes this guard tighter than it may look: in Agreed the heal remains narrow ( Nits — both taken: Verification re-run: |
Fixes #102
OpenICFWebSocketTest.testRemoteConnectorInfoManagerhung for a full 5 minutes on the macOS/JDK 26 CI runner and killed the wholeconnector-server-grizzlyfork ("There was a timeout in the fork"). The root causes are unrecoverable states in the WebSocket client, detailed in #102.Production code (
connector-framework-server)ClientRemoteConnectorInfoManageronClosed: always release the permit held by the closing connection. The invertedavailableConnectionPermitCount < 1guard skipped the release exactly when the closing connection held the last permit, leaving a single-connection client permanently unable to reconnect (heartbeatconnect(false)and the on-demand path are both permit-gated).tryReleaseConnectionPermit()is already capped atpermittedConnectionCount, so it cannot over-credit.failed()callback was deliberately not added: per Grizzly 4.0.2TCPNIOConnectorHandlersources, every failure path afterpreConfigurealso closes the connection, so the close listener fires and afailed()release would double-credit.Connectionexists, e.g. fd exhaustion — no close listener to release the permit): if there is no live connection, no free permit and no connect attempt for 30 s, restore the lost permit.WebSocketConnectionGroup/WebSocketConnectionHolder: the initialCONNECTOR_INFOrequest was sent from insidehandshake(), before the holder assigned itsRemoteOperationContext— a fast response was dispatched intosocket.getRemoteConnectionContext()==null→ NPE, silently dropping the connector info with no retry (the retry chain only reacts to promise failure). The request is now sent from a newhandshakeComplete()hook invoked byreceiveHandshakeafter the context is stored; this fixes the client, the Grizzly server and the Jetty server holders at once, since all three assign the context afterhandshake()returns. Also guardedtrySubmitRequest()returningnull(allowed by its contract) in the initial request and its retry.Tests / infra
OpenICFWebSocketTest: the poll loop was dead code —getOrThrowUninterruptiblynever returnsnull, so the test was a single 5-minute blocking wait. Replaced with real polling: 5 × 20-second attempts.forkedProcessTimeoutInSeconds300 → 900. The in-test wait (300 s) was equal to the fork timeout, so any single hang was converted into a fork kill for the whole module instead of one failed test.Verification
connector-framework-server: 24/24 passconnector-server-grizzly(incl.OpenICFWebSocketTest): 32/32 pass, two consecutive runs (~51 s each vs 350 s with the hang)connector-server-jetty: 30/30 passjavadoc:javadoc(strict doclint): clean