Serialize database destruction with concurrent opens - #787
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces robust database lifecycle management for RocksDB JS bindings. It implements a timed-wait mechanism (lifecycleWaitSeconds) for open, destroy, and shutdown operations to prevent concurrent lifecycle conflicts. It also introduces a "quarantine" state for database paths when a native close, flush, compaction, or physical directory cleanup fails, preventing subsequent opens until the cleanup is retried via destroy() or shutdown(). Additionally, it ensures that in-flight operations (like backups and checkpoints) are safely awaited before destruction, and that thread-affine N-API references are cleaned up safely. There are no review comments, so I have no feedback to provide.
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit ae746b7 |
| napi_value Database::GetCount(napi_env env, napi_callback_info info) { | ||
| NAPI_METHOD_ARGV(2); | ||
| UNWRAP_DB_HANDLE_AND_OPEN(); | ||
| ACQUIRE_OPERATIONS_LOCK(); |
There was a problem hiding this comment.
Low: getCount() is the remaining unbounded OperationGuard holder — the cancellation added for compactRange does not cover it
The new compactCancelRequested handshake (db_descriptor.cpp:308/:1822) fixes the compactSync() half of this: a manual compaction now aborts when finishClose() starts its untimed drain. getCount() is the other half and is untouched.
This guard is held across the full-range scan on line 949 (while (itHandle->iterator->Valid()) { ++count; itHandle->iterator->Next(); }), which has no isClosing() check and no cancellation. finishClose() still drains with a bare operationsInFlight.wait() (db_descriptor.cpp:307), so a getCount() over a large range blocks destroy()/shutdown() for its full duration — and the blocked thread holds the path gate, so concurrent OpenDB() calls for that path time out with Timed out opening database ... destruction is still in progress.
The comment added at db_descriptor.cpp:304 states compaction is "the one in-flight op that can run unboundedly", which is not quite right while this scan exists.
Suggested fix: break the loop when descriptor->isClosing() becomes true (returning an aborted status rather than a partial count), so the guard is released promptly. Then the comment at db_descriptor.cpp:304 holds.
—
Generated by Barber AI
|
Reviewed Re-review of the one new commit since
Also checked and cleared: the dropped Verification: full suite 55 files, 773 passed / 1 skipped / 0 failed; targeted destroy + ranges 64/64 including the new One merge-ordering note, not a defect in this PR: — |
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
f24ef7a to
5b459e4
Compare
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
5b459e4 to
542b058
Compare
|
Reviewed Re-review of the one new commit since
Also re-verified at object-code level:
— |
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
542b058 to
3cdd9f9
Compare
| @@ -689,24 +961,86 @@ void DBRegistry::ReleaseParkTimeoutsByEnv(napi_env env) { | |||
| */ | |||
| void DBRegistry::Shutdown() { | |||
There was a problem hiding this comment.
High: full-suite process exit reproducibly fails in DBRegistry::Shutdown() -- native I/O error + a raw pthread lock: Invalid argument diagnostic
Ran pnpm test (full suite, CI=1, --expose-gc) twice against this head (3cdd9f94) in a clean worktree. Both times, after all 838/839 tests reported passing, the process printed at exit:
rocksdb-js database registry cleanup failed: Failed to flush database during close: IO error: While open a file for appending: /.../rocksdb-js-tests/testdb-<hash>/000010.log: Permission denied
pthread lock: Invalid argument
[ELIFECYCLE] Test failed. See above for more details.
000010.log and Permission denied were consistent across both runs (different temp-dir hashes, same log-file number and same error). This comes from the cleanup("database registry", ...) wrapper in binding.cpp around DBRegistry::Shutdown(). The second line, pthread lock: Invalid argument, is not a C++ exception -- it's the raw diagnostic macOS libpthread prints when a mutex/condvar operation is attempted on an invalid or already-destroyed primitive, printed after the caught-and-reported flush failure, i.e. during continued teardown of this function or a subsequent one in the same cleanup chain.
Ran the same full suite against the true merge base (06961b5f, main without this PR) in an identical clean worktree: 0/1 runs showed this -- clean exit, no stderr noise, exit 0. Shutdown() itself is substantially rewritten by this PR (this diff hunk), including the new shutdownMutex / lifecycleCondition / destroyingPaths machinery that didn't exist on main. Narrowing to just test/shutdown.test.ts + test/destroy.test.ts + test/concurrent-teardown.test.ts alone does NOT reproduce it (clean exit in 24s) -- it requires the full 61-file suite's cumulative state, i.e. some earlier, unrelated test leaves a database registered but unflushed, and only the final process-wide Shutdown() pass (this PR's new code) touches it and fails.
Confidence: high that this is a real, reproducible regression on this branch (2/2 dirty on head, 1/1 clean on base, isolated repro attempt inconclusive due to needing full-suite state). Not yet bisected to the exact introducing hunk or the specific earlier test that leaves the dangling descriptor -- that would need either a bisection across the 61 test files or added logging in Shutdown()/finishClose() to identify which path is still registered at final teardown.
Suggested next step: reproduce with ROCKSDB_JS_DEBUG/DEBUG_LOG enabled around Shutdown()'s descriptor enumeration to log which path is still in instance->databases at process exit, and check whether the OS temp-file cleaner or a concurrent test's destroy() on an overlapping path could be racing this database's directory at that moment (the new destroyingPaths set is meant to prevent exactly this kind of overlap -- worth checking whether Shutdown()'s own path is exempted from it).
—
Generated by Barber AI
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
- shutdown() no longer permanently throws once a destroy-cleanup tombstone exists; it stays non-destructive (per AGENTS.md) and skips the entry instead of poisoning every later call - binding.cpp always releases global listener threadsafe functions, even when DBRegistry::Shutdown() throws - compactSync() cancels its manual compaction when finishClose() is draining in-flight operations, instead of blocking the untimed drain (and cascading OpenDB timeouts) for the compaction's full duration - Iterator Return()/Throw() are idempotent again on an already-closed iterator, matching close() elsewhere, instead of throwing over a clean loop exit or the caller's real error - narrow the AGENTS.md VT fast-path claim to what's actually true - log the retained path on a benchmark teardown failure instead of leaking it silently - add a deterministic test for iteratorMutex serializing Next() against a foreign forced close, plus a return()/throw() idempotency unit test
- DBIterator::Next() no longer pays a getenv() scan per row for the ROCKSDB_JS_ITERATOR_NEXT_DELAY_MS seam; it is snapshotted once in initializeTestSeams() alongside the close-failure flags. Next() returns one row per call, so this was ~a quarter of the per-row getRange cost for a seam that is unset in production. - Extract closeClaimedDescriptors() in db_registry.cpp: the finishClose() -> erase-or-quarantine -> notify -> emit tail was copied four times (PurgeIfUnreferenced, DestroyDB, PurgeAll, Shutdown), each handling closeError/closeRetrying slightly differently. Only the claim predicate genuinely differs per caller, so that is all that is left at the call sites. The completed-but-errored policy that had drifted is now one named option: fatal for shutdown()/PurgeAll() because dropping a failed close-time flush would hide possible data loss, non-fatal for destroy(), whose caller asked for the data to be deleted anyway. - getKeysCount() was the remaining unbounded OperationGuard holder that finishClose()'s untimed drain could not cancel. The scan now polls isClosing() per row and reports the abort instead of a partial count, on both the database and transaction paths, so a foreign destroy() is no longer blocked for the length of the range (and concurrent OpenDB() calls for that path no longer time out behind it). The comment claiming compaction was the only unbounded in-flight op is corrected. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
…nbounded for drain AsyncWorkHandle::registerAsyncWork() unconditionally incremented its counter with no serialization against cancelAllAsyncWork(), and waitForAsyncWorkCompletion() gave up after a hardcoded 5s even if work remained. Since Flush/Compact/Clear/async Get in database.cpp only hold the descriptor's operationsInFlight guard through synchronous setup (not through the queued execute callback), the 5s bound let DBDescriptor::finishClose() reach this->db.reset() while a slow flush() (legitimately waiting out a write stall per AGENTS.md invariant 16, which has no bound) was still executing against it — a genuine use-after-free, not a theoretical one. registerAsyncWork()/cancelAllAsyncWork() now share waitMutex so admission and cancellation can never interleave: a registration either fully lands before cancellation publishes, or is refused. waitForAsyncWorkCompletion() is now unbounded, matching the existing unbounded operationsInFlight wait pattern elsewhere in db_descriptor.cpp. Every registerAsyncWork() call site (database.cpp Clear/Compact/Flush/Get, backup.cpp's shared queueBackupWork, checkpoint.cpp, transaction.cpp's two Commit() paths) is wired through a new admitAsyncWorkOrReject() helper that rejects the already-constructed promise and tears down cleanly on refusal instead of proceeding into a closing handle. ScopedAsyncWorkRegistration (transaction_handle.cpp, used for cross-column-family transactional reads) now tracks admission via ok() so its destructor can't underflow the count on a refused registration, and both of its call sites in TransactionHandle::get() check ok() explicitly rather than relying on the (currently-true but unenforced) correlation with isCancelled(). backup_stream.cpp's registration is left unchecked, with an explanatory comment: its operationsInFlight claim is held through the whole async execution already, so it can't hit refusal in practice. Corrected the README's lifecycleWaitSeconds doc: it said "Total maximum time," which contradicted the existing note that destroy()/shutdown()'s wait for in-flight backups/checkpoints is intentionally unbounded — reworded to clarify it only bounds the wait for a conflicting lifecycle op on the same path. Deleted .pr787-lifecycle-repair-plan.md (superseded by this commit and AGENTS.md invariant 17, which documents the fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nt6hrpR4pnotKkZ8AUVv5
Gemini + Harper-domain pre-push review (round 1) found that every admitAsyncWorkOrReject() call site followed a successful admission with a bare NAPI_STATUS_THROWS(::napi_queue_async_work(...)). On the rare napi_queue_async_work() failure that macro throws and returns immediately, leaking `state` with its AsyncWorkHandle registration still counted. Since waitForAsyncWorkCompletion() is now unbounded (this branch's whole point), that stuck count blocks the handle's close forever, which blocks every later OpenDB() for its path -- worse than the leak it fixed. Added queueAsyncWorkOrReject() alongside admitAsyncWorkOrReject() in async.h: releases the admitted claim via signalExecuteCompleted(), deletes the async work object, rejects the promise, deletes state. Takes an `admitted` flag for backup.cpp's queueBackupWork(), whose registration is conditional on its registerWork/state->handle parameters -- unregistering an admission that never happened would underflow the count the same way. backup_stream.cpp's AsyncBackupStreamState is refcounted (acquire()/ release(), shared with an N-API tsfn) rather than a plain heap object, so the generic helper's `delete state` would double-free against tsfnFinalize()'s later release(). Wrote the queue-failure cleanup by hand there instead, mirroring backupStreamComplete()'s existing teardown sequence (delete async work, release the tsfn, drop the descriptor pin, reject, release the constructor's own ref). Also asserted the invariant its "(void)registerAsyncWork()" comment already claimed (registration there is guaranteed by the operationsInFlight claim held for the whole stream) -- the review's other nit, that the claim was load-bearing but unenforced. transaction_handle.cpp's async Get fallback was checked and left alone: its queue call runs before a still-in-scope PendingAsyncState RAII guard is released, so a queue failure already unwinds and deletes state correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nt6hrpR4pnotKkZ8AUVv5
107b216 to
ab5cd96
Compare
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
| invariant 9), so for those this drain is defense in depth rather than the only thing preventing | ||
| a use-after-free. | ||
|
|
||
| 17. **An env's pending transactions are reaped by its cleanup hook — never by `DBHandle::close()`**: |
There was a problem hiding this comment.
Medium: duplicate invariant 17. — already failing CI's Check job
This branch's own new invariant (632: **Async-work admission and cancellation share one mutex...**) and the invariant that arrived via #780's merge into main (654: **An env's pending transactions are reaped by its cleanup hook...**) are both numbered 17.. Confirmed in both your branch tip and GitHub's own refs/pull/787/merge content — this isn't a stale-local-clone artifact.
This is not just a cosmetic nit: it's already red. The Check job (pnpm fmt:check → oxfmt --check) is failing right now on ba0e1c07 specifically because of this file (verified against the actual CI run and reproduced locally with oxfmt --check / oxfmt AGENTS.md). oxfmt does auto-renumber ordered-list items in Markdown — running it without --check rewrites the second 17. to 18. — which also means the doc note just added a few lines above this ("oxfmt formats TS/JS/JSON only. It does not touch C++ or Markdown") is itself incorrect; oxfmt clearly does reformat this file's Markdown lists. No source file references "invariant 17" by number today, so renumbering to 18. is a clean, safe fix with no cross-references to chase.
Suggested fix:
| 17. **An env's pending transactions are reaped by its cleanup hook — never by `DBHandle::close()`**: | |
| 18. **An env's pending transactions are reaped by its cleanup hook — never by `DBHandle::close()`**: |
—
Generated by Barber AI
| if (this->closing.exchange(true)) { | ||
| return false; | ||
| } | ||
| this->compactCancelRequested.store(true); |
There was a problem hiding this comment.
Low: the compaction-cancel-timing fix in this commit has no regression coverage
Moving the compactCancelRequested arm from finishClose() into beginClose() closes a real race window (a manual compactRange() starting between close-claim and the old, later arm point wouldn't be told to cancel promptly). But mutation-testing it shows the suite doesn't enforce the new timing: I reverted this exact change (armed the token back in finishClose() instead, matching the pre-this-commit behavior) in a clean rebuild and ran compaction.test.ts, destroy.test.ts, shutdown.test.ts, concurrent-teardown.test.ts, and db-options.test.ts — 66/66 still green. Nothing currently exercises a manual compactRange() in flight at the moment beginClose()/DestroyDB()/Shutdown() claims the descriptor, so this specific ordering guarantee could regress silently.
Suggested fix: a test that starts a manual (non-cancellable) compactRange() on one thread, then triggers destroy()/shutdown() from another the moment the compaction begins (mirroring the existing test_seam.h delay-based race harnesses already used elsewhere in this PR, e.g. countScanDelayMsFlag), asserting the close completes promptly instead of blocking for the compaction's full duration.
—
Generated by Barber AI
Summary
Harper currently works around a rocksdb-js lifecycle race by locking root opens in JavaScript. This moves the invariant into the native registry: physical destruction now owns a database path across read-write/read-only descriptors, concurrent opens wait, and shutdown is serialized with both operations.
The change also makes teardown failures observable and recoverable, and prevents destroy/shutdown from releasing the native database beneath directory backups, streaming backups, or checkpoints.
For the human reviewer
destroy()intentionally changes from refusing while peer handles exist to closing every in-process handle for the physical path before removing it. This is what allows Harper schema propagation to race safely with a database drop. Cross-process coordination remains RocksDB's lock responsibility.database:closeFailed. A failed post-destroy directory cleanup is visible inregistryStatus()and can only be retried by the explicit destructive verbs,destroy()orshutdown();open()remains non-destructive.close()in afinally, but silently ignoring a failed flush would hide possible data loss.DBHandle; shutdown already exercised that path before this change. N-API reference deletion is now owner-thread-only, and worker lifecycle fixtures pass, but the remaining shared-handle synchronization is a follow-up decision for the storage maintainer.waitForAsyncWorkCompletion()is now unbounded (async.h#L189), replacing the previous 5s bound. That was a deliberate fix for a genuine UAF (a flush legitimately waiting out a write stall could still be executing when the old bound gave up andfinishClose()proceeded to release the native DB) — but it means any future call site that admits work viaregisterAsyncWork()/admitAsyncWorkOrReject()and then fails to release that claim on every exit path (including rare N-API failures) now hangs the handle's close forever, not just for 5s.queueAsyncWorkOrReject()(async.h#L274) closes the one gap the pre-push review found in this repo's own call sites; the contract itself (every registration must be balanced on every exit path, forever) is a standing invariant future changes need to keep honoring, which is whyAGENTS.mdinvariant 17 documents it explicitly.This is the rocksdb-js root-cause fix for Harper PR #2169, "Prevent job wedges on runtime database opens". Harper's JavaScript
.openlock should remain out of the released path once a package containing this change is available.2026-08-26 rebase + async-work lifecycle repair
Rebased onto latest
origin/main(no textual conflicts affecting this PR's own commits). Also implemented the async-work admission/cancellation repair called out as a known gap by the pre-push review of the prior head:AsyncWorkHandle::registerAsyncWork()/cancelAllAsyncWork()now share a mutex so a registration racing a close either fully lands before cancellation publishes or is refused (async.h#L222); everyFlush/Compact/Clear/asyncGet/backup/checkpoint/commit call site rejects cleanly on refusal instead of proceeding into a closing handle (database.cpp). A follow-on pre-push review round (Gemini + Harper-domain) then found and this fixed a real leak the first pass introduced: everyadmitAsyncWorkOrReject()call site followed successful admission with a barenapi_queue_async_work()that, on the rare N-API queue failure, threw and returned without releasing the just-admitted claim — a stuck count that, combined with the now-unbounded wait above, would block that handle's close (and every laterOpenDB()for its path) forever.backup_stream.cpp's state is refcounted and shared with an N-API tsfn, so it got a hand-written equivalent rather than the generic helper (which would double-free there) — seebackup_stream.cpp#L721.Findings surfaced by this round's review that are outside this PR's diff (pre-existing on the branch before today, from earlier commits) — not fixed here, flagged for separate triage:
transaction.cpp:765— an asynccommit()'sstate = Committingwrite isn't restored on theERR_TRANSACTION_CLOSINGrefusal path the way the coordinated-retry paths restore it, so a commit that raced a path destroy can leave the handle reportingCommittingafter it was actually rolled back; a caller retryingcommit()per this repo's own documented pattern gets a resolved promise for a transaction that never landed.database.cpp(Destroy) — a failed physical delete leaves a tombstone that blocks every laterOpenDB()for the path, and if the caller drops theirRocksDatabaseinstance, there is no in-process way to retrydestroy()to clear it (the native handle only populatespathon a successfulopen()).db_registry.cppShutdown()— acloseErrorcaptured from one database can be silently discarded if a later wait for a different database times out, downgrading a possible-data-loss signal to a background event.db_registry.cpp— destroy-cleanup tombstones omittransactionDetails, whichRegistryStatusDBdeclares required.db_iterator.cpp:284— a per-row mutex added by an earlier commit on this branch (Serialize iterators with forced teardown) taxes the hot read path for a window that only matters during a foreign forced close.db_descriptor.cpp—compactCancelRequested(the manual-compaction cancel token that keepsfinishClose()'s untimed drain bounded) has no direct test, unlike its count-scan sibling.Verified: native
pnpm test:native151/151;pnpm check(type-check/lint/format) clean; targeted vitest runs of every path this repair touches (clear/compact/flush/get/backup/backup-stream/checkpoint/transactions/cross-column-family/orphan-gc) all passing. A fullpnpm testrun was attempted but this session hit sustained extreme contention on the shared build machine (uptimeload averages up to 34 on 20 cores from concurrent, unrelated sessions) that produced non-reproducible timeouts unrelated to this diff — confirmed by an extended-timeout A/B (--testTimeout 300000) showing identical behavior with and without the changes under review, and by targeted runs completing cleanly once isolated. See the dispatch log for the full trace.Verification
node_modules/.bin/node-gyp buildnode_modules/.bin/tsc --noEmitnode_modules/.bin/oxlintnode_modules/.bin/oxfmt --checkReview coverage
— Claude Sonnet 5 (2026-08-26 rebase + async-work repair), GPT-5 Codex (original)
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(no-receipt); declined=cursor-composer; rounds=6 @ 107b216
Human-Review-Need: 4 (decisions: shutdown-throws-vs-reports, quarantine-blocks-reopen, untimed-drain-semantics, destroy-waits-for-backup, opened-means-not-closing, lifecycle-wait-seconds-global, destroy-requires-opened-handle, iterator-mutex-per-next) @ 107b216