@@ -153,15 +153,19 @@ public class QwpWebSocketSender implements Sender {
153153 private final List <Endpoint > endpoints ;
154154 // Global symbol dictionary for delta encoding
155155 private final GlobalSymbolDictionary globalSymbolDictionary ;
156- // Guards the connect walk (see buildAndConnect): the shared
157- // QwpHostHealthTracker round/health state, roundSeq,
158- // roundConnectAttemptSeq, and the foreground lifecycle commits
159- // (currentEndpointIdx, hasEverConnected, cap-derived sizing). A
160- // dedicated lock rather than the sender monitor so that (a) unrelated
161- // synchronized sender methods (setDrainerListener, startOrphanDrainers)
162- // never contend with a connect walk's network I/O, and (b) background
163- // walks can use tryLock to yield to the foreground instead of queuing
164- // on an unfair monitor.
156+ // Serializes FOREGROUND connect walks only (see buildAndConnect): the
157+ // shared-round state in hostTracker (pickNext/beginRound/attempted
158+ // bits), roundSeq, roundConnectAttemptSeq, and the foreground lifecycle
159+ // commits (currentEndpointIdx, hasEverConnected, cap-derived sizing)
160+ // all have exactly one writer -- the foreground walk -- and foreground
161+ // walks cannot overlap by construction (the I/O loop is single-threaded
162+ // and the user-thread initial connect completes before the loop
163+ // starts); the lock is cheap insurance for that invariant. Background
164+ // (drainer) walks take NO lock at all: they walk a private
165+ // QwpHostHealthTracker.RoundCursor and record health-only results, so
166+ // no network I/O ever runs under a sender-wide lock for background
167+ // work, and neither the foreground's reconnect nor close() can queue
168+ // behind a drainer's endpoint walk.
165169 private final ReentrantLock connectWalkLock = new ReentrantLock ();
166170 private final QwpHostHealthTracker hostTracker ;
167171 private final CharSequenceObjHashMap <QwpTableBuffer > tableBuffers ;
@@ -968,8 +972,9 @@ public QwpWebSocketSender charColumn(CharSequence columnName, char value) {
968972 * native connect — bounded by {@code connect_timeout}, or by the
969973 * OS SYN-retry deadline (60-130s on Linux) when the default
970974 * {@code 0} is in effect. Background drainer walks never delay
971- * this stop: they yield the connect-walk lock to the foreground
972- * (see {@link #buildAndConnect});</li>
975+ * this stop: they run lock-free on private round cursors and
976+ * never hold anything the foreground waits on (see
977+ * {@link #buildAndConnect});</li>
973978 * <li>drainer pool: drainers still in their connect-retry phase are
974979 * stop-signaled immediately (exit within ~50ms); drainers actively
975980 * replaying frames get a 2.5s grace window plus a 0.5s stop window
@@ -2585,13 +2590,9 @@ private WebSocketClient newWebSocketClient() {
25852590
25862591 /**
25872592 * Multi-endpoint connect walk shared by the foreground sender and the
2588- * background orphan drainers. One invocation walks every endpoint of the
2589- * current round, performing a TCP/TLS connect plus a WebSocket upgrade
2590- * per endpoint, all while holding {@link #connectWalkLock} (the lock
2591- * protects the shared {@link QwpHostHealthTracker} round/health state,
2592- * the round counters, and the foreground lifecycle commits).
2593- * <p>
2594- * Worst-case lock hold per walk is
2593+ * background orphan drainers. One invocation sweeps the endpoint list,
2594+ * performing a TCP/TLS connect plus a WebSocket upgrade per endpoint;
2595+ * worst-case sweep duration is
25952596 * {@code endpoints x (connect timeout + upgrade timeout)}:
25962597 * <ul>
25972598 * <li>foreground walk: {@code connect_timeout} verbatim -- the default
@@ -2604,33 +2605,30 @@ private WebSocketClient newWebSocketClient() {
26042605 * {@link #effectiveConnectTimeoutMs(boolean, int)}.</li>
26052606 * </ul>
26062607 * <p>
2607- * Lock policy -- the foreground has absolute priority. Foreground walks
2608- * (the producer's initial connect and the I/O loop's reconnects) block
2609- * on {@code lock()} and can therefore only ever wait behind another
2610- * foreground walk. Background walks use {@code tryLock()} and treat
2611- * contention as a transport-shaped transient: the thrown
2612- * {@link LineSenderException} lands in the drainer's indefinite
2613- * capped-backoff retry (Invariant B) -- both in
2614- * {@code BackgroundDrainer.connectWithDurableAckRetry()} and in
2615- * {@code CursorWebSocketSendLoop.connectLoop}'s Throwable branch -- so a
2616- * drainer sweep is deferred, never failed, and the foreground's
2617- * reconnect and {@code close()} paths are never queued behind a
2618- * drainer's endpoint walk.
2608+ * Concurrency policy -- no network I/O under a sender-wide lock for
2609+ * background work. FOREGROUND walks (the producer's initial connect and
2610+ * the I/O loop's reconnects) hold {@link #connectWalkLock} across the
2611+ * sweep: they own the shared round state and the lifecycle commits, and
2612+ * can only ever wait behind another foreground walk (which cannot
2613+ * happen by construction -- the lock is insurance). BACKGROUND (drainer)
2614+ * walks take NO lock: each sweeps a private
2615+ * {@link QwpHostHealthTracker.RoundCursor} -- full sweep, claim-at-pick,
2616+ * ordered by the live shared health state -- and records results with
2617+ * the health-only overloads ({@code markRoundAttempted=false}), so
2618+ * concurrent drainer sweeps proceed in parallel with each other and
2619+ * with the foreground, share health observations, and can neither
2620+ * consume nor poison the foreground's round. The foreground's
2621+ * reconnect and {@code close()} paths are therefore never queued
2622+ * behind a drainer's endpoint walk.
26192623 */
26202624 private WebSocketClient buildAndConnect (ReconnectSupplier ctx ) {
26212625 if (ctx .isBackground ()) {
2622- if (!connectWalkLock .tryLock ()) {
2623- // Transport-shaped on purpose: every drainer retry loop
2624- // treats an untyped connect failure as transient and backs
2625- // off (Invariant B) -- the sweep is deferred, never failed,
2626- // and no .failed sentinel can result from lock contention.
2627- throw new LineSenderException (
2628- "connect walk lock is busy (another connect walk is in progress); "
2629- + "background drainer will retry after backoff" );
2630- }
2631- } else {
2632- connectWalkLock .lock ();
2626+ // Lock-free: the walk below touches only internally-synchronized
2627+ // hostTracker health state and walk-local/cursor-local state on
2628+ // the background path.
2629+ return connectWalk (ctx );
26332630 }
2631+ connectWalkLock .lock ();
26342632 try {
26352633 return connectWalk (ctx );
26362634 } finally {
@@ -2640,7 +2638,10 @@ private WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
26402638
26412639 private WebSocketClient connectWalk (ReconnectSupplier ctx ) {
26422640 // Background (drainer) factories share this connect walk -- endpoint
2643- // list, hostTracker health and round state -- but must stay INVISIBLE
2641+ // list and hostTracker HEALTH state (never the shared round: a
2642+ // background sweep walks its own RoundCursor and records with
2643+ // markRoundAttempted=false, so it cannot consume the foreground's
2644+ // round or skew roundSeq) -- but must stay INVISIBLE
26442645 // in the foreground sender's observable state. SenderConnectionEvents
26452646 // describe the FOREGROUND connection's lifecycle, and the cap-derived
26462647 // sizing (serverMaxBatchSize / effectiveAutoFlushBytes) guards the
@@ -2651,6 +2652,12 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
26512652 // (oversize batch -> ws-close[1009] -> producer-terminal HALT caused
26522653 // by background activity).
26532654 final boolean background = ctx .isBackground ();
2655+ // Private full-sweep cursor for background walks: claim-at-pick over
2656+ // cursor-local attempted bits makes the pick -> record pair safe
2657+ // without any walk-wide lock, and guarantees every sweep tries every
2658+ // endpoint exactly once regardless of concurrent walkers.
2659+ final QwpHostHealthTracker .RoundCursor cursor =
2660+ background ? hostTracker .newRoundCursor () : null ;
26542661 int previousIdx = ctx .previousIdx ;
26552662 if (previousIdx >= 0 ) {
26562663 // Mid-stream wire failure -- the I/O loop just observed the active
@@ -2673,7 +2680,10 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
26732680 hostTracker .recordMidStreamFailure (previousIdx );
26742681 ctx .previousIdx = -1 ;
26752682 }
2676- if (hostTracker .isRoundExhausted ()) {
2683+ // Shared-round lifecycle is foreground-only: a background walk must
2684+ // not advance the round (or roundSeq, which numbers foreground
2685+ // events) under the foreground's feet.
2686+ if (!background && hostTracker .isRoundExhausted ()) {
26772687 roundSeq ++;
26782688 hostTracker .beginRound (true );
26792689 }
@@ -2693,7 +2703,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
26932703 if (ctx .isAborted ()) {
26942704 throw new LineSenderException (ctx .abortMessage ());
26952705 }
2696- int idx = hostTracker .pickNext ();
2706+ int idx = background ? cursor . next () : hostTracker .pickNext ();
26972707 if (idx < 0 ) break ;
26982708 Endpoint ep = endpoints .get (idx );
26992709 lastEndpoint = ep ;
@@ -2717,7 +2727,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
27172727 newClient .close ();
27182728 if (classified instanceof QwpIngressRoleRejectedException ) {
27192729 QwpIngressRoleRejectedException re = (QwpIngressRoleRejectedException ) classified ;
2720- hostTracker .recordRoleReject (idx , re .isTransient ());
2730+ hostTracker .recordRoleReject (idx , re .isTransient (), ! background );
27212731 lastError = re ;
27222732 lastRoleReject = re ;
27232733 if (!background ) {
@@ -2749,7 +2759,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
27492759 && !((WebSocketUpgradeException ) classified ).isRoleMismatch ()))) {
27502760 terminalUpgradeError = classified ;
27512761 }
2752- hostTracker .recordTransportError (idx );
2762+ hostTracker .recordTransportError (idx , ! background );
27532763 lastError = classified ;
27542764 if (!background ) {
27552765 dispatchConnectionEvent (
@@ -2760,7 +2770,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
27602770 continue ;
27612771 } catch (Exception e ) {
27622772 newClient .close ();
2763- hostTracker .recordTransportError (idx );
2773+ hostTracker .recordTransportError (idx , ! background );
27642774 lastError = e ;
27652775 if (!background ) {
27662776 dispatchConnectionEvent (
@@ -2790,7 +2800,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
27902800 }
27912801 if (requestDurableAck && !newClient .isServerDurableAckEnabled ()) {
27922802 newClient .close ();
2793- hostTracker .recordRoleReject (idx , false );
2803+ hostTracker .recordRoleReject (idx , false , ! background );
27942804 QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException (
27952805 ep .host , ep .port , null );
27962806 if (terminalUpgradeError == null ) {
@@ -2805,7 +2815,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
28052815 }
28062816 continue ;
28072817 }
2808- hostTracker .recordSuccess (idx );
2818+ hostTracker .recordSuccess (idx , ! background );
28092819 ctx .previousIdx = idx ;
28102820 if (background ) {
28112821 // Walk bookkeeping only: recordSuccess feeds the shared health
0 commit comments