@@ -2631,6 +2631,20 @@ public static int effectiveConnectTimeoutMs(boolean background, int configuredMs
26312631 * Production path delegates to {@link WebSocketClientFactory}; tests may
26322632 * install {@link #clientFactoryOverride} to substitute a stub.
26332633 */
2634+ /**
2635+ * Best-effort close for a client being abandoned because a JVM Error is
2636+ * about to be rethrown: under OOM {@code close()} itself can throw, and a
2637+ * secondary failure must not mask the original Error. {@code close()} is
2638+ * CAS-gated, so re-closing an already-closed client is a no-op.
2639+ */
2640+ private static void closeQuietlyOnError (WebSocketClient client ) {
2641+ try {
2642+ client .close ();
2643+ } catch (Throwable ignored ) {
2644+ // best-effort; the original Error is what must surface
2645+ }
2646+ }
2647+
26342648 private WebSocketClient newWebSocketClient () {
26352649 java .util .function .Supplier <WebSocketClient > override = clientFactoryOverride ;
26362650 if (override != null ) {
@@ -2851,77 +2865,90 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
28512865 // walk. Rethrow: every retry loop upstream (connectWithRetry,
28522866 // the cursor reconnect loop, BackgroundDrainer) rethrows Error
28532867 // rather than retrying, so this stays a loud one-shot failure.
2854- try {
2855- newClient .close ();
2856- } catch (Throwable ignored ) {
2857- // best-effort; the original Error is what must surface
2858- }
2868+ closeQuietlyOnError (newClient );
28592869 throw e ;
28602870 }
2861- if (requestDurableAck && !newClient .isServerDurableAckEnabled ()) {
2862- newClient .close ();
2863- hostTracker .recordRoleReject (idx , false , !background );
2864- QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException (
2865- ep .host , ep .port , null );
2866- if (terminalUpgradeError == null ) {
2867- terminalUpgradeError = ackErr ;
2871+ // Guard the post-upgrade tail: from here until newClient is
2872+ // returned, an escaping JVM Error would leak the CONNECTED
2873+ // client's fd and native buffers -- the same class the
2874+ // connect/upgrade catch (Error) arm above closes over. The
2875+ // success-event dispatch is the realistic trigger: it allocates
2876+ // the SenderConnectionEvent plus a deque node, and on a clean
2877+ // first connect it is also the dispatcher's first offer(), which
2878+ // lazy-starts the dispatcher thread (Thread.start() can itself
2879+ // fail with OOM). Same contract as the arm above: close quietly
2880+ // (a secondary failure must not mask the original Error) and
2881+ // rethrow. close() is CAS-gated, so re-closing after the
2882+ // durable-ack arm's own close is a no-op.
2883+ try {
2884+ if (requestDurableAck && !newClient .isServerDurableAckEnabled ()) {
2885+ newClient .close ();
2886+ hostTracker .recordRoleReject (idx , false , !background );
2887+ QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException (
2888+ ep .host , ep .port , null );
2889+ if (terminalUpgradeError == null ) {
2890+ terminalUpgradeError = ackErr ;
2891+ }
2892+ lastError = ackErr ;
2893+ if (!background ) {
2894+ dispatchConnectionEvent (
2895+ SenderConnectionEvent .Kind .ENDPOINT_ATTEMPT_FAILED ,
2896+ ep .host , ep .port , null , SenderConnectionEvent .NO_PORT ,
2897+ attemptNumber , roundSeq , ackErr );
2898+ }
2899+ continue ;
28682900 }
2869- lastError = ackErr ;
2870- if (!background ) {
2871- dispatchConnectionEvent (
2872- SenderConnectionEvent .Kind .ENDPOINT_ATTEMPT_FAILED ,
2873- ep .host , ep .port , null , SenderConnectionEvent .NO_PORT ,
2874- attemptNumber , roundSeq , ackErr );
2901+ hostTracker .recordSuccess (idx , !background );
2902+ ctx .previousIdx = idx ;
2903+ if (background ) {
2904+ // Walk bookkeeping only: recordSuccess feeds the shared health
2905+ // tracker and ctx.previousIdx arms this factory's own
2906+ // mid-stream-failure handling on its next reconnect. No
2907+ // lifecycle event, no CONNECTED/RECONNECTED/FAILED_OVER
2908+ // classification state, no producer batch re-sizing -- the
2909+ // drainer's lifecycle is observable via
2910+ // BackgroundDrainerListener and the drainer counters, never
2911+ // the foreground connection-event stream.
2912+ return newClient ;
28752913 }
2876- continue ;
2877- }
2878- hostTracker .recordSuccess (idx , !background );
2879- ctx .previousIdx = idx ;
2880- if (background ) {
2881- // Walk bookkeeping only: recordSuccess feeds the shared health
2882- // tracker and ctx.previousIdx arms this factory's own
2883- // mid-stream-failure handling on its next reconnect. No
2884- // lifecycle event, no CONNECTED/RECONNECTED/FAILED_OVER
2885- // classification state, no producer batch re-sizing -- the
2886- // drainer's lifecycle is observable via
2887- // BackgroundDrainerListener and the drainer counters, never
2888- // the foreground connection-event stream.
2889- return newClient ;
2890- }
2891- int previousLiveIdx = currentEndpointIdx ;
2892- currentEndpointIdx = idx ;
2893- // Classify the success. CONNECTED only fires once per sender
2894- // lifetime; subsequent successes are RECONNECTED (same endpoint
2895- // as before) or FAILED_OVER (different endpoint). hasEverConnected
2896- // is set after the classification so the very first success picks
2897- // CONNECTED before flipping the flag.
2898- SenderConnectionEvent .Kind successKind ;
2899- String prevHost = null ;
2900- int prevPort = SenderConnectionEvent .NO_PORT ;
2901- if (!hasEverConnected ) {
2902- successKind = SenderConnectionEvent .Kind .CONNECTED ;
2903- hasEverConnected = true ;
2904- } else if (previousLiveIdx == idx ) {
2905- successKind = SenderConnectionEvent .Kind .RECONNECTED ;
2906- } else {
2907- successKind = SenderConnectionEvent .Kind .FAILED_OVER ;
2908- if (previousLiveIdx >= 0 ) {
2909- Endpoint prevEp = endpoints .get (previousLiveIdx );
2910- prevHost = prevEp .host ;
2911- prevPort = prevEp .port ;
2914+ int previousLiveIdx = currentEndpointIdx ;
2915+ currentEndpointIdx = idx ;
2916+ // Classify the success. CONNECTED only fires once per sender
2917+ // lifetime; subsequent successes are RECONNECTED (same endpoint
2918+ // as before) or FAILED_OVER (different endpoint). hasEverConnected
2919+ // is set after the classification so the very first success picks
2920+ // CONNECTED before flipping the flag.
2921+ SenderConnectionEvent .Kind successKind ;
2922+ String prevHost = null ;
2923+ int prevPort = SenderConnectionEvent .NO_PORT ;
2924+ if (!hasEverConnected ) {
2925+ successKind = SenderConnectionEvent .Kind .CONNECTED ;
2926+ hasEverConnected = true ;
2927+ } else if (previousLiveIdx == idx ) {
2928+ successKind = SenderConnectionEvent .Kind .RECONNECTED ;
2929+ } else {
2930+ successKind = SenderConnectionEvent .Kind .FAILED_OVER ;
2931+ if (previousLiveIdx >= 0 ) {
2932+ Endpoint prevEp = endpoints .get (previousLiveIdx );
2933+ prevHost = prevEp .host ;
2934+ prevPort = prevEp .port ;
2935+ }
29122936 }
2937+ dispatchConnectionEvent (
2938+ successKind , ep .host , ep .port , prevHost , prevPort ,
2939+ attemptNumber , roundSeq , null );
2940+ // Refresh the cap-derived state before returning the new client so
2941+ // the producer thread observes the new endpoint's advertised
2942+ // X-QWP-Max-Batch-Size from the next sendRow onwards. Skipping this
2943+ // on a mid-stream failover leaves the sender sized for the prior
2944+ // endpoint's cap; an oversize row then escapes the producer-side
2945+ // guard and trips a wire-level ws-close[1009] downstream.
2946+ applyServerBatchSizeLimit (newClient .getServerMaxBatchSize ());
2947+ return newClient ;
2948+ } catch (Error e ) {
2949+ closeQuietlyOnError (newClient );
2950+ throw e ;
29132951 }
2914- dispatchConnectionEvent (
2915- successKind , ep .host , ep .port , prevHost , prevPort ,
2916- attemptNumber , roundSeq , null );
2917- // Refresh the cap-derived state before returning the new client so
2918- // the producer thread observes the new endpoint's advertised
2919- // X-QWP-Max-Batch-Size from the next sendRow onwards. Skipping this
2920- // on a mid-stream failover leaves the sender sized for the prior
2921- // endpoint's cap; an oversize row then escapes the producer-side
2922- // guard and trips a wire-level ws-close[1009] downstream.
2923- applyServerBatchSizeLimit (newClient .getServerMaxBatchSize ());
2924- return newClient ;
29252952 }
29262953 // Round walked every endpoint without a success. Surface
29272954 // ALL_ENDPOINTS_UNREACHABLE before any of the typed throws so a
0 commit comments