2828import io .questdb .client .std .Files ;
2929import io .questdb .client .std .ObjList ;
3030import io .questdb .client .std .QuietCloseable ;
31+ import org .jetbrains .annotations .TestOnly ;
3132
3233import java .util .concurrent .atomic .AtomicBoolean ;
3334import java .util .concurrent .locks .LockSupport ;
@@ -115,15 +116,28 @@ public final class CursorSendEngine implements QuietCloseable {
115116 // thread, JVM shutdown hooks, test cleanup). volatile + synchronized
116117 // close() makes the check-and-set atomic and gives readers a fence.
117118 private volatile boolean closed ;
118- // True once close() has run its full cleanup sequence. Stays false when
119- // a close attempt could not confirm manager-worker quiescence and had to
120- // leak the ring/watermark/slot lock — in that case a later close() call
121- // retries the cleanup (the worker may have exited by then). Guarded by
122- // the synchronized close() method; never read elsewhere.
119+ // True once close() has run its full cleanup sequence INCLUDING a
120+ // CONFIRMED slot-flock release — finishClose() publishes this strictly
121+ // after SlotLock.release() reports success, never before. Pool threads
122+ // treat the flip as "the slot dir is reusable" and free the slot index
123+ // the moment they observe it (QwpWebSocketSender.isSlotLockReleased ->
124+ // SenderPool.reprobeRetiredSlots), so publishing before the release
125+ // would let a replacement engine's SlotLock.acquire collide with the
126+ // still-open fd. Stays false when a close attempt could not confirm
127+ // manager-worker quiescence (or the flock release itself failed) and had
128+ // to leak the ring/watermark/slot lock — in that case a later close()
129+ // call retries the cleanup (the worker may have exited by then).
123130 // volatile: latched by finishClose(), but read lock-free by
124131 // isCloseCompleted() from pool threads re-probing a retired slot (see the
125132 // getter for why it must not synchronize).
126133 private volatile boolean closeCompleted ;
134+ // Test-only hook run by finishClose() between the terminal cleanup and
135+ // the flock release. Lets a test park the releasing thread inside the
136+ // cleanup/release window and assert that closeCompleted stays false —
137+ // i.e. that completion is never observable while the flock is still
138+ // held. volatile: finishClose may run on the manager worker's exit
139+ // thread while the hook is installed from a test thread.
140+ private volatile Runnable beforeFlockReleaseHook ;
127141 // Exactly-once claim on the terminal cleanup (finishClose). Contended by
128142 // close() and a worker-exit handoff (completeDeferredClose); whoever wins
129143 // the CAS runs the cleanup, the loser never touches ring/watermark/flock.
@@ -586,9 +600,17 @@ public synchronized void close() {
586600 // cleanup runs exactly once and the worker never blocks on the
587601 // engine monitor a retried close() holds while joining it.
588602 boolean handedOff = false ;
603+ boolean registrationFailed = false ;
589604 try {
590605 handedOff = manager .deferUntilWorkerExit (() -> completeDeferredClose (fullyDrained ));
591606 } catch (Throwable ignored ) {
607+ // Allocation failure (OOM building the cleanup lambda or
608+ // growing the manager's exitCleanups list). Unlike the exact
609+ // false return below, a throw carries NO worker-liveness
610+ // information — the two must never be conflated, or the
611+ // inline cleanup below would release worker-reachable
612+ // resources under a possibly-live worker.
613+ registrationFailed = true ;
592614 }
593615 if (handedOff ) {
594616 LOG .error ("SF manager worker did not quiesce during engine close; ring, watermark "
@@ -598,6 +620,23 @@ public synchronized void close() {
598620 + "the stale worker on slot {}" , sfDir == null ? "<memory>" : sfDir );
599621 return ;
600622 }
623+ if (registrationFailed ) {
624+ // The handoff never registered and the worker was never
625+ // observed — it must be presumed live and mid service pass.
626+ // Retain every worker-reachable resource (ring, watermark,
627+ // segment files, slot flock) and leave terminalCleanupClaimed
628+ // unclaimed and closeCompleted false, exactly like the
629+ // shared-manager leak branch: manager.close() above already
630+ // stopped the worker loop, so a retried close() converges via
631+ // isWorkerReaped() once the in-flight pass ends. The kernel
632+ // releases the slot flock on process exit regardless.
633+ LOG .error ("SF worker-exit handoff registration failed during engine close; "
634+ + "leaking the ring, watermark and slot lock so a possibly-live "
635+ + "worker cannot corrupt a future engine on slot {}. close() may be "
636+ + "invoked again to retry cleanup once the worker has exited." ,
637+ sfDir == null ? "<memory>" : sfDir );
638+ return ;
639+ }
601640 // Handoff rejected: the worker loop exited between the failed
602641 // bounded join and the registration attempt (both sides share the
603642 // manager's lock, so this observation is exact). A worker past
@@ -629,8 +668,13 @@ public synchronized void close() {
629668
630669 /**
631670 * Terminal cleanup: closes the ring and watermark, unlinks drained
632- * segment files, releases the slot flock, and latches
633- * {@link #closeCompleted}. The caller must hold the engine monitor and
671+ * segment files, releases the slot flock, and — only once the release
672+ * is <b>confirmed</b> — latches {@link #closeCompleted}. Publish order
673+ * is load-bearing: pools free the slot index the instant they observe
674+ * {@code closeCompleted} (via {@code isSlotLockReleased()}), so it must
675+ * never be visible while the flock fd is still open, or a replacement
676+ * engine races the release and fails acquisition on a live slot.
677+ * The caller must hold the engine monitor and
634678 * must have established that the manager worker can no longer touch the
635679 * slot directory (reaped, provably exited, or running this on its own
636680 * exit path) AND have won the {@link #terminalCleanupClaimed} CAS — the
@@ -669,21 +713,64 @@ private void finishClose(boolean fullyDrained) {
669713 } catch (Throwable ignored ) {
670714 }
671715 }
672- closeCompleted = true ;
673716 } finally {
674717 // Reaching finishClose at all requires established quiescence, so
675718 // releasing the flock is safe even if a step above threw. Leaking
676719 // it would strand the slot until process exit for no reason.
720+ //
721+ // ORDER MATTERS: release the flock FIRST, verify it, and only
722+ // then publish closeCompleted. Pools read isCloseCompleted() as
723+ // "the slot dir is reusable" and free the slot index the moment
724+ // it flips; publishing before Files.close(fd) completes would
725+ // open a window where a replacement engine's SlotLock.acquire
726+ // collides with the still-open fd and fails with a spurious
727+ // "slot already in use".
728+ Runnable hook = beforeFlockReleaseHook ;
729+ if (hook != null ) {
730+ try {
731+ hook .run ();
732+ } catch (Throwable ignored ) {
733+ // test-only; must never block the release
734+ }
735+ }
736+ boolean released ;
677737 if (slotLock != null ) {
678738 try {
679- slotLock .close ();
739+ released = slotLock .release ();
680740 } catch (Throwable ignored ) {
681- // best-effort; flock is also released by kernel on process exit
741+ released = false ;
682742 }
743+ } else {
744+ released = true ;
745+ }
746+ if (released ) {
747+ closeCompleted = true ;
748+ } else {
749+ // Unconfirmed release: the flock may still be held, so keep
750+ // closeCompleted false — the owning pool keeps the slot
751+ // retired (leakedSlots accounting) instead of reusing a
752+ // possibly-locked dir. The kernel releases the flock on
753+ // process exit regardless.
754+ LOG .error ("SF slot flock release failed during engine close; keeping "
755+ + "closeCompleted=false so the pool cannot reuse a possibly "
756+ + "still-locked slot dir; the kernel releases the flock on "
757+ + "process exit [slot={}]" , sfDir == null ? "<memory>" : sfDir );
683758 }
684759 }
685760 }
686761
762+ /**
763+ * Installs a hook that {@link #finishClose} runs between the terminal
764+ * cleanup and the slot-flock release. Test-only: makes the otherwise
765+ * microsecond-wide cleanup/release window deterministic so tests can
766+ * assert {@link #isCloseCompleted()} stays false until the release is
767+ * confirmed.
768+ */
769+ @ TestOnly
770+ public void setBeforeFlockReleaseHook (Runnable hook ) {
771+ this .beforeFlockReleaseHook = hook ;
772+ }
773+
687774 /**
688775 * Runs on the manager worker's exit path when {@link #close()} handed
689776 * cleanup ownership to the worker (see {@code deferUntilWorkerExit}).
@@ -705,10 +792,13 @@ private void completeDeferredClose(boolean fullyDrained) {
705792 }
706793
707794 /**
708- * Whether {@link #close()} completed all cleanup, including releasing the
709- * SF slot lock. A false value after close means manager-worker quiescence
710- * could not be confirmed and the worker-reachable resources were retained
711- * deliberately — either handed to the worker's exit path (owned manager),
795+ * Whether {@link #close()} completed all cleanup, including a
796+ * <b>confirmed</b> release of the SF slot lock — the flip is published
797+ * strictly after the flock fd is closed, so observing {@code true}
798+ * guarantees the slot dir is acquirable by a replacement engine. A false
799+ * value after close means manager-worker quiescence could not be
800+ * confirmed (or the flock release itself failed) and the
801+ * worker-reachable resources were retained deliberately — either handed to the worker's exit path (owned manager),
712802 * which flips this to true the moment the worker's in-flight pass
713803 * finishes, or leaked until a retried close() (shared manager). Owners
714804 * must not reuse the slot while this is false; pools may re-probe it to
0 commit comments