@@ -100,15 +100,34 @@ public final class SegmentManager implements QuietCloseable {
100100 // registered flag check inside the trim block closes for watermark writes
101101 // and totalBytes accounting.
102102 private volatile Runnable beforeTrimSyncHook ;
103+ // Entry currently being serviced by the worker thread, or null when the
104+ // worker is between service passes (or not running). Guarded by
105+ // {@link #lock}; cleared with lock.notifyAll() so awaitRingQuiescence can
106+ // block until an in-flight pass for a just-deregistered ring finishes.
107+ private RingEntry inService ;
103108 private long lastDiskFullLogNs ;
104109 private volatile boolean running ;
110+ // pathScratch free-exactly-once coordination between a timed-out close()
111+ // and the worker's exit path. All three are guarded by {@link #lock}.
112+ // When close() gives up on the join while the worker loop has not yet
113+ // exited, it hands scratch ownership to the worker
114+ // (scratchHandedToWorker=true) and the worker frees the buffer in its
115+ // exit block; in every other case close() frees it. Without the handoff,
116+ // a worker that outlives the bounded join leaks the native scratch
117+ // buffer forever, because nobody retries manager cleanup after close()
118+ // returns.
119+ private boolean scratchFreed ;
120+ private boolean scratchHandedToWorker ;
121+ private boolean workerLoopExited ;
105122 // Total bytes currently allocated across every segment owned by every
106123 // registered ring (active + sealed + hot-spare). Mutated by the manager
107124 // thread on provision/trim and by register/deregister callers under
108125 // {@link #lock}; the lock covers both paths so the counter stays
109126 // consistent across registration boundaries.
110127 private long totalBytes ;
111- private long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS ;
128+ // volatile: read by awaitRingQuiescence() from arbitrary caller threads
129+ // while the @TestOnly setter may run on another.
130+ private volatile long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS ;
112131 // volatile because wakeWorker() reads workerThread without holding the
113132 // monitor; the synchronized start()/close() pair handles the
114133 // start-vs-close ordering.
@@ -194,25 +213,114 @@ public synchronized void close() {
194213 }
195214 }
196215 if (t .isAlive ()) {
197- LOG .warn ("SegmentManager worker did not stop before close wait completed; "
198- + "leaving worker-owned resources allocated" );
199- return ;
216+ synchronized (lock ) {
217+ if (!workerLoopExited ) {
218+ // Hand pathScratch ownership to the worker: its exit
219+ // block frees the buffer under the same lock, so the
220+ // native allocation is reclaimed even though this
221+ // close() could not confirm termination. workerThread
222+ // stays set so isWorkerReaped() reports the incomplete
223+ // shutdown and a later close() can retry the join.
224+ scratchHandedToWorker = true ;
225+ LOG .warn ("SegmentManager worker did not stop before close wait completed; "
226+ + "worker frees its native scratch buffer on exit" );
227+ return ;
228+ }
229+ }
230+ // The worker loop has already run its exit block; the thread
231+ // is at most a few instructions from terminating and can no
232+ // longer touch manager state. Fall through and reap it.
200233 }
201234 workerThread = null ;
202235 }
203236 // Free the rotation-path native scratch buffer only after worker
204- // termination has been observed. The worker is the only thread that
205- // touches the buffer, but close() uses a bounded join; if the worker is
206- // still alive, leaking this one scratch allocation is safer than freeing
207- // native memory it may still read or write.
208- pathScratch .close ();
237+ // termination (or worker-loop exit) has been observed. The worker is
238+ // the only thread that touches the buffer; the scratchFreed flag
239+ // (shared with the worker's exit block) makes the free exactly-once
240+ // no matter which side runs last.
241+ synchronized (lock ) {
242+ if (!scratchFreed ) {
243+ scratchFreed = true ;
244+ pathScratch .close ();
245+ }
246+ }
247+ }
248+
249+ /**
250+ * Quiescence barrier for {@link #deregister(SegmentRing)}. Blocks until
251+ * the worker thread is provably no longer executing a service pass for
252+ * {@code ring}, or the worker-join timeout elapses. After this returns
253+ * {@code true}, the worker will never again touch the ring, its
254+ * watermark, or path names under its slot directory: deregister has
255+ * removed the entry from the registry, a stale snapshot entry that has
256+ * not started its pass is skipped by the registration check at the top
257+ * of {@link #serviceRing(RingEntry)}, and this method has observed the
258+ * end of any in-flight pass. Only then may the caller release dependent
259+ * resources (ring, watermark, segment files, slot lock).
260+ * <p>
261+ * Returns {@code true} immediately when no worker is running or when
262+ * called from the worker thread itself (test hooks inject deregister
263+ * calls there; waiting would self-deadlock). Returns {@code false} when
264+ * the in-flight pass did not finish within the timeout — the caller must
265+ * treat the worker as still live and leak rather than release.
266+ * <p>
267+ * A pending caller interrupt is preserved but does not abort the wait,
268+ * mirroring {@link #close()}.
269+ */
270+ public boolean awaitRingQuiescence (SegmentRing ring ) {
271+ Thread t = workerThread ;
272+ if (t == null || t == Thread .currentThread ()) {
273+ return true ;
274+ }
275+ long deadlineNanos = System .nanoTime () + workerJoinTimeoutMillis * 1_000_000L ;
276+ boolean interrupted = Thread .interrupted ();
277+ try {
278+ synchronized (lock ) {
279+ while (inService != null && inService .ring == ring ) {
280+ long remainingNanos = deadlineNanos - System .nanoTime ();
281+ if (remainingNanos <= 0 ) {
282+ return false ;
283+ }
284+ try {
285+ // Round up so a sub-millisecond remainder still waits
286+ // instead of spinning through wait(0) == wait-forever.
287+ lock .wait (Math .max (1L , remainingNanos / 1_000_000L ));
288+ } catch (InterruptedException ignored ) {
289+ interrupted = true ;
290+ }
291+ }
292+ }
293+ return true ;
294+ } finally {
295+ if (interrupted ) {
296+ Thread .currentThread ().interrupt ();
297+ }
298+ }
299+ }
300+
301+ /**
302+ * True when no manager worker thread can be running: either
303+ * {@link #start()} was never called, or a {@link #close()} confirmed
304+ * worker termination and reaped the thread. Owners use this as a
305+ * stronger fallback barrier when {@link #awaitRingQuiescence(SegmentRing)}
306+ * times out but a subsequent {@code close()} join succeeded.
307+ */
308+ public synchronized boolean isWorkerReaped () {
309+ return workerThread == null ;
209310 }
210311
211312 /**
212313 * Stop tracking {@code ring}. Pending spares for the ring are NOT
213314 * created after this returns, but already-installed spares stay with
214315 * the ring (the ring closes them on its own {@link SegmentRing#close}).
215316 * Idempotent; safe to call from any thread.
317+ * <p>
318+ * Non-blocking: a worker service pass already in flight for this ring
319+ * may still be running when this returns. Callers about to release
320+ * resources the worker can reach (the ring itself, its watermark, its
321+ * segment files, or the slot lock guarding its directory) MUST follow
322+ * up with {@link #awaitRingQuiescence(SegmentRing)} and only release on
323+ * a {@code true} result.
216324 */
217325 public void deregister (SegmentRing ring ) {
218326 synchronized (lock ) {
@@ -412,6 +520,32 @@ private String nextSparePath(String dir) {
412520 }
413521
414522 private void serviceRing (RingEntry e ) {
523+ // Claim the entry as in-service so deregister-side quiescence
524+ // barriers (awaitRingQuiescence) can wait for this pass to finish.
525+ // A stale snapshot entry deregistered before the pass starts is
526+ // skipped entirely: the deregistering thread may already be
527+ // releasing the ring / watermark / slot resources, so the worker
528+ // must not touch them at all. The registered check and the
529+ // in-service claim are atomic under `lock` — deregister flips
530+ // `registered` under the same lock, so it either prevents this
531+ // pass or the barrier observes it via `inService`.
532+ synchronized (lock ) {
533+ if (!e .registered ) {
534+ return ;
535+ }
536+ inService = e ;
537+ }
538+ try {
539+ serviceRing0 (e );
540+ } finally {
541+ synchronized (lock ) {
542+ inService = null ;
543+ lock .notifyAll ();
544+ }
545+ }
546+ }
547+
548+ private void serviceRing0 (RingEntry e ) {
415549 // 1. Provision a hot spare if the ring needs one AND we have headroom
416550 // under the disk-total cap. Cap check is per-tick; if we're capped
417551 // here, the ring stays in BACKPRESSURE_NO_SPARE until trim (step 2)
@@ -571,26 +705,40 @@ private void serviceRing(RingEntry e) {
571705 }
572706
573707 private void workerLoop () {
574- while (running ) {
575- // Snapshot the rings under the lock so we don't hold it through the
576- // (potentially slow) syscalls during creation/unlink. ringSnapshot
577- // is a thread-confined field — no per-tick allocation.
578- ringSnapshot .clear ();
579- synchronized (lock ) {
580- for (int i = 0 , n = rings .size (); i < n ; i ++) {
581- ringSnapshot .add (rings .getQuick (i ));
708+ try {
709+ while (running ) {
710+ // Snapshot the rings under the lock so we don't hold it through the
711+ // (potentially slow) syscalls during creation/unlink. ringSnapshot
712+ // is a thread-confined field — no per-tick allocation.
713+ ringSnapshot .clear ();
714+ synchronized (lock ) {
715+ for (int i = 0 , n = rings .size (); i < n ; i ++) {
716+ ringSnapshot .add (rings .getQuick (i ));
717+ }
582718 }
583- }
584- for (int i = 0 , n = ringSnapshot .size (); i < n ; i ++) {
719+ for (int i = 0 , n = ringSnapshot .size (); i < n ; i ++) {
720+ if (!running ) break ;
721+ serviceRing (ringSnapshot .getQuick (i ));
722+ }
723+ // Drop strong refs so a deregistered ring becomes collectable
724+ // before the next tick (otherwise the snapshot pins it for up
725+ // to pollNanos after deregister).
726+ ringSnapshot .clear ();
585727 if (!running ) break ;
586- serviceRing (ringSnapshot .getQuick (i ));
728+ LockSupport .parkNanos (pollNanos );
729+ }
730+ } finally {
731+ // If a timed-out close() abandoned the reap, it handed
732+ // pathScratch ownership to this thread (see close()). Freeing it
733+ // here reclaims the native buffer even when the worker outlives
734+ // every close() attempt — nobody else retries manager cleanup.
735+ synchronized (lock ) {
736+ workerLoopExited = true ;
737+ if (scratchHandedToWorker && !scratchFreed ) {
738+ scratchFreed = true ;
739+ pathScratch .close ();
740+ }
587741 }
588- // Drop strong refs so a deregistered ring becomes collectable
589- // before the next tick (otherwise the snapshot pins it for up
590- // to pollNanos after deregister).
591- ringSnapshot .clear ();
592- if (!running ) break ;
593- LockSupport .parkNanos (pollNanos );
594742 }
595743 }
596744
0 commit comments