fix(qwp): fix a memory-access fault and worker leak during shutdown#65
Conversation
SegmentManager.close() joined the worker thread with a bounded t.join(5_000). When close() ran on a thread that already carried a pending interrupt -- the interrupted-teardown path that BackgroundDrainer deliberately leaves the status set on -- join() threw InterruptedException immediately, so the worker got no time to observe running=false and exit. close() then logged the "worker did not stop" warning and returned, leaking a live worker that still owned segment files. The leaked worker kept creating spares and trimming/unlinking sealed segments. A subsequent CursorSendEngine that mmapped one of those files raced the concurrent truncation/unlink and took a SIGBUS, surfacing as "java.lang.InternalError: a fault occurred in an unsafe memory access operation". This showed up as a flaky failure in BackgroundDrainerInterruptIsStopSignalTest. close() now clears the caller's pending interrupt before the join, waits the worker out to a deadline, and restores the interrupt on the way out so the rest of the interrupted-teardown protocol still observes it. The loop.close() latch await elsewhere stays interrupt-sensitive; only the worker join, which must reap a passive daemon that owns files, is made robust against a pending interrupt. Add testInterruptedCallerDoesNotAbandonReapableWorker, a deterministic regression that fails against the old code, and give SegmentManager a @testonly worker-join timeout seam so the existing timeout-branch test reaches its branch without the interrupt shortcut the fix removes.
Code review — level 3 (blocking, mission-critical pass)Scope: 2 files, +109/−7. The fix is correct. It does exactly what the commit claims: Verified against source and by executing the suite both ways: fixed code 3/3 green; with the production hunk reverted, the new regression test goes red at CriticalNone. ModerateNone. Minor1. 2. The modified test 3. 4. Downgraded (candidates dismissed after source verification)
Note (harness, cross-cutting)
SummaryVerdict: approve. A correct, narrowly-scoped fix for a real data-corruption bug (leaked worker → concurrent truncation/unlink → SIGBUS/
The only things worth changing before merge are the two test-code minors (#1 duplication, #2 which test carries the regression value), and both are optional. |
[PR Coverage check]😍 pass : 14 / 16 (87.50%) file detail
|
bluestreak01
left a comment
There was a problem hiding this comment.
Code review — PR #65, level 3 (blocking, mission-critical pass)
fix(qwp): fix a memory-access fault and worker leak during shutdown
Scope: exactly 2 files, +109/−7 — SegmentManager.java (production close() hardening) and SegmentManagerCloseRaceTest.java (one new regression test + one modified test). Committed-binary gate: PASS — the .so/.dylib/.dll entries that appeared in a raw git diff are an artifact of a stale local main; gh pr diff --name-only and git diff <base> <head> both confirm the real PR touches only those 2 Java files, and git ls-files shows the native libs are not tracked (CI builds them from source).
Verification method
I reproduced the bug and the fix by execution, not just reading:
- With the fix:
SegmentManagerCloseRaceTest3/3 green; the broaderSegmentManager*+BackgroundDrainerInterruptIsStopSignalTest(the previously-flaky test) +BackgroundDrainerInterruptedTeardownTest(pins the deliberately-preserved interrupt) = 18 tests, 0 failures. - With the production hunk reverted (temporarily restored the old
t.join(5_000)/catch):testInterruptedCallerDoesNotAbandonReapableWorkerfails atSegmentManagerCloseRaceTest.java:255— "interrupted close() abandoned a live worker instead of waiting" (total test time 0.077 s) — exactly as claimed. The production file was then restored byte-identical to PR HEAD (cleangit status).
The fix is correct. BackgroundDrainerPool.shutdownNow() interrupts the drainer runner thread and deliberately leaves the flag set (stopRequestedOrInterrupted() uses isInterrupted()); the runner's finally then calls engine.close() → manager.close() on that interrupted thread (drainer engines own their manager — CursorSendEngine(sfDir, size, maxTotalBytes, deadline) sets ownsManager=true). Pre-fix, t.join(5_000) threw InterruptedException immediately, the worker got zero time to see running=false, and close() returned early leaking a live worker that kept truncating/unlinking segment files → SIGBUS/InternalError in a later mmap. The fix saves+clears the interrupt, does a deadline-bounded join that actually reaps the worker, then restores the interrupt for the rest of the teardown protocol.
Critical
None.
Moderate
None.
Minor
1. Duplicated test scaffolding between the two blocked-worker tests (SegmentManagerCloseRaceTest.java, test-code quality — in-diff). The setBeforeInstallSyncHook(...) lambda (~lines 155–166 vs 226–237) and the setup preamble are byte-identical across testCloseDoesNotFreePathScratchWhenWorkerStillAlive and testInterruptedCallerDoesNotAbandonReapableWorker; the tests genuinely diverge only after the worker blocks. Extract a blockWorkerAtInstallHook(...) helper and keep the divergent bodies per-test. Not blocking.
2. deadlineNanos arithmetic is not overflow-hardened (SegmentManager.java:179, defensive — in-diff). System.nanoTime() + workerJoinTimeoutMillis * 1_000_000L can overflow only via the @TestOnly setWorkerJoinTimeoutMillis with an absurd value, or ~292-year JVM uptime; on overflow the worker gets a zero-wait early-return (degrades gracefully, no crash). Unreachable in production (the field keeps its 5 s default; verified no production caller). Optional guard/precondition on the setter only.
3. The 300 ms regression window is theoretically weak under pathological scheduling starvation (SegmentManagerCloseRaceTest.java:254-255, test robustness — in-diff). If the closer thread failed to reach manager.close() within 300 ms, assertFalse would false-pass against buggy code (masking the regression). It can never flaky-fail the fixed code (close() blocks in join until releaseWorker.countDown(), which the test calls only after the assert), and the measured buggy path failed at 77 ms ≪ 300 ms, so this is a note for the record, not a defect.
4. Metadata: PR has no labels; a Core/Bug label would match scope. Trivial.
Downgraded (false positives dismissed after source + execution verification)
- Busy-spin under repeated interrupt — not reachable.
join()clears the interrupt flag when it throws, so each interrupt delivery costs one loop iteration; the sole production interrupt source (BackgroundDrainerPool.shutdownNow) interrupts once, and the loop is deadline-bounded regardless. join(0)waits forever — theremainingMillis <= 0 → breakguard sits before thejoin, sojoinonly ever sees>= 1.- Non-volatile
workerJoinTimeoutMillisvisibility — production never writes it (stays at the 5 s field-init default); the cross-thread test establishes happens-before viacloser.start(). Harmless. - 5 s block under interrupted teardown regresses "leave now" — intended tradeoff, not an SF-invariant violation. This is a bounded teardown join, not a drainer reconnect budget (the drainer's reconnect in
CursorWebSocketSendLoopis untouched). It only materializes on a genuinely wedged worker, and only on the pool's daemon runner thread afterBackgroundDrainerPool.close()has already returned viashutdownNow()without awaiting — so it never blocks the user, the pool'sclose(), or JVM exit, and unacked data stays on disk for the next orphan scan (no data loss). - pathScratch double-free / wedged-worker leak — pre-existing and deliberate (
DirectByteSink.deflate()is idempotent; the wedged-worker early-return intentionally leaks the one 256-byte buffer to avoid use-after-free by the still-live worker). Not touched by this PR. testCloseDoesNotFreePathScratchWhenWorkerStillAliveis now the regression guard — false; verified by revert that it still passes without the fix (the old immediate-throw also lands in the same timeout-expiry branch). It legitimately exercises the newsetWorkerJoinTimeoutMillisseam / timeout branch, but the sole regression guard istestInterruptedCallerDoesNotAbandonReapableWorker. Consistent with the PR's own framing — informational.
Coverage map
| Change (symbol + branch) | Test (local) | Failure link | Dimensions | Verdict |
|---|---|---|---|---|
SegmentManager.close() — interrupt-robust reap: clear+restore interrupt, deadline-bounded join actually reaps worker under pending interrupt |
testInterruptedCallerDoesNotAbandonReapableWorker (local; proven by revert) |
assertFalse(closeReturned.await(300ms)) + assertNull(readWorkerThread) — reverting the hunk makes close() return instantly (worker abandoned) → assert fails (verified, 77 ms) |
happy ✓, interrupt-preserved ✓, resource cleanup/assertMemoryLeak ✓; concurrency ✓ (cross-thread closer); real-server N-A (in-JVM interrupt, no server observable) |
TESTED |
SegmentManager.close() — timeout branch: worker still alive past deadline → warn + early return, pathScratch not freed, workerThread retained |
testCloseDoesNotFreePathScratchWhenWorkerStillAlive (local; reaches branch via new setWorkerJoinTimeoutMillis(50L) seam) |
asserts worker still alive, pathScratch impl != 0, interrupt preserved after timed-out close |
timeout-branch ✓, interrupt-preserved ✓, native-mem-not-freed ✓, assertMemoryLeak ✓ |
TESTED (branch; not the interrupt-bug regression guard) |
New workerJoinTimeoutMillis field + WORKER_JOIN_TIMEOUT_MILLIS constant |
— | Default 5 s preserves the pre-fix bound; production never mutates it (verified) | no behavioral change | EXEMPT |
New @TestOnly setWorkerJoinTimeoutMillis(long) |
— | Test-only seam; rg confirms zero production callers |
no behavioral change | EXEMPT |
Tandem gate (Step 2.7): no tandem required — unit-testable-only. The change is a purely in-JVM thread-lifecycle reap on an in-process interrupt (shutdownNow), with no server-observable effect (no wire/protocol/handshake/flush-retry change) and no dependence on a kill -9. It is deterministically reproduced by the local regression test. Searches recorded: gh pr list --repo questdb/questdb --head fix-segmentmanager-interrupted-close and --repo questdb/questdb-enterprise both empty; no tandem/e2e links in the body — correctly, since none is required.
Summary
Verdict: approve. A correct, narrowly-scoped fix for a real crash-safety bug (interrupted-teardown → leaked file-owning worker → concurrent truncation/unlink → SIGBUS/InternalError). The fix strictly hardens close(); every traced callsite is unaffected or improved.
- Test & tandem gate: PASS. The fix ships with a regression test proven (by revert-execution) to fail without it; no UNTESTED-Critical rows; no required-but-missing tandem (unit-testable-only, verified).
- Zero-GC gate: N-A/PASS.
close()is a once-per-lifecycle teardown path, not a per-row/per-producer path; the new loop allocates nothing (primitives only). No hot-path allocation introduced. - Store-and-forward invariants: hold. Bounded teardown join (not a reconnect budget); no transport error propagated to any caller; no terminal latching; runs on an abandoned daemon; unacked data stays on disk.
- Java 8 floor / conventions: OK. Uses only Java 8 APIs; member ordering compliant (new static const, instance field, and method all alphabetical within their groups); Conventional-Commits title with
fix(qwp)scope; commit message plain-English < 50 chars with proper body. - Coverage map totals: 4 behavioral rows — 2 TESTED (0 via tandem), 2 EXEMPT (verified no-behavioral-change), 0 UNTESTED.
- Findings: 0 Critical, 0 Moderate, 4 Minor kept; 6 candidate findings dropped as false positives after source + execution verification.
- In-diff vs out-of-diff: all 4 kept findings in-diff; 0 out-of-diff — legitimate here, as every
SegmentManager.close()callsite (CursorSendEngineconstructor-fail catch ×2, primaryclose()reached from the foreground sender, the interrupted drainer runnerfinally, and the delegated I/O-thread exit) was walked and each resolves to SAFE under the new interrupt-robust join.
The only optional cleanups before merge are Minor #1 (test duplication) and, if you want defense-in-depth, Minor #2 (setter precondition). Neither blocks.
Summary
When the QWP client shuts down while a background drainer is still delivering a slot's buffered data, the drainer thread is interrupted as part of teardown.
SegmentManager.close()reaps its worker thread with a boundedt.join(...), but a pending interrupt on the calling thread makesjoin()throwInterruptedExceptionimmediately.close()then returned without the worker having stopped, leaking a live worker thread together with its native buffers.The leaked worker keeps creating hot spares and trimming/unlinking on-disk segment files. A later
CursorSendEnginethat memory-maps one of those files can race the concurrent truncation/unlink and take a SIGBUS, surfacing as:The fault is intermittent -- it only triggers when the leaked worker happens to be mutating the exact file being mapped -- and showed up as a flaky failure in
BackgroundDrainerInterruptIsStopSignalTest.Fix
SegmentManager.close()now saves and clears the caller's pending interrupt before joining the worker, waits the worker out to the same 5s deadline, then restores the interrupt so the rest of the interrupted-teardown protocol still observes it. The worker is a passive daemon that exits within microseconds ofrunning=false, so in the common caseclose()still returns immediately; the deadline is only consumed when the worker is genuinely wedged -- the same bound the original code intended before the interrupt collapsed it to zero.The change is deliberately narrow: only the worker join is hardened against a pending interrupt. The
loop.close()latch await on the same teardown path stays interrupt-sensitive by design, since it must throw under a wedged I/O thread.Tradeoffs
close()can now block up to the worker-join deadline (5s) where it previously returned at once. In practice the worker exits in microseconds; the wait only materializes when the worker is stuck, which is exactly the case where leaking it is unsafe.Tests
testInterruptedCallerDoesNotAbandonReapableWorker, a deterministic regression: it wedges the worker, callsclose()on an interrupted thread, and assertsclose()waits for and reaps the worker. It fails against the pre-fix code and passes with the fix.testCloseDoesNotFreePathScratchWhenWorkerStillAliveto reach the join-timeout branch via a new@TestOnlyworker-join-timeout seam instead of the interrupt shortcut the fix removes.SegmentManager*andBackgroundDrainer*suites pass, includingBackgroundDrainerInterruptedTeardownTest, which pins the deliberately-preserved interrupt status.