Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a more robust mechanism for terminating Windows process trees by tracking process lifetimes rather than relying solely on PIDs, which are prone to recycling. It implements a new utility 'confirmWindowsProcessTreeGone' in 'server/threads/windowsProcessTree.ts', updates 'manageThreads.js' and 'Application.ts' to utilize this new logic, and adds comprehensive unit tests. Additionally, it addresses a race condition in 'drop_database' by ensuring database handles are closed appropriately and adds a new integration test to verify this behavior. I have no feedback to provide.
607945b to
a84d7ba
Compare
ec33981 to
459b253
Compare
|
@/tmp/review-body.txt |
459b253 to
8e80338
Compare
…e blob lifecycle test A get() on a sourcedFrom table resolves to its caller before the resolved record's cache write has committed (Table.ts getFromSource), so the SQL read that immediately followed the GET could run before the record existed — nightly run 33601777987 (Node 22 shard 3) hit exactly that and the next test in the suite proved the record had landed by then. Poll for the record instead of assuming the write completed with the response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTG3A13EcxJymQNwUQ82Gh
… cannot wedge its termination terminateWindowsProcessTree confirmed a spawned tree was gone by asking the process table for the root PID and anything whose ParentProcessId chain reached it. Windows recycles a freed PID almost immediately and a process keeps its ParentProcessId after that parent exits, so once npm.cmd had exited the check could stay true for as long as some newer process held that PID — and the loop ran taskkill /T against whatever owned it every 25ms. The deploy_component call stayed inside the confirmation for the rest of the run, its thread held the component preparation lock, and every later deploy of that component deferred (#2273; nightly run 33601777987, Windows shard 5, is the first occurrence with the stage markers that isolate this branch). The tree is now selected by lifetime from Win32_Process.CreationDate: the root counts only while it still runs and was created no later than we first knew it was running, a child only if it was created while its parent lived (after the root's own creation time where a scan observed it, and before the root's exit — observed by Node, or latched by the first scan that no longer finds the root running as ours), and nothing without a creation time. A round kills either through a still-owned root (/T) or the surviving descendants by their own PIDs — never both, since /T frees the PIDs a same-round per-PID kill would then hit. The wait still has no deadline (#2076) but backs off its polling and logs the survivors it is waiting on. manageThreads' dead-worker reclamation shares the module: a confirmed taskkill bounds the root's lifetime up front, an unconfirmed one leaves the root to be found and re-terminated by the scan. On Windows the unit suite also runs the real PowerShell query against a spawned child. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTG3A13EcxJymQNwUQ82Gh
… announced
drop_database destroys a RocksDB database process-wide (rocksdb-js force-closes every thread's
handles), but only the dropping thread removed its entry from rocksdbDatabaseEnvs. Every other
thread kept the closed store, and as soon as the directory existed again — a same-name
create_database, or the recreation rocksdb-js#818 produces — each of its rescans threw
"Database not open" at that entry and stopped loading everything scanned after it, on every
schema event, for the rest of the process. The ITC schema handler already releases a database's
handles for restore_backup so the restore can rewrite its directory; the drop_schema and
drop_database signals now do the same, which is what cleanLmdbMap has done for LMDB all along.
The new integration test drives drop_table + drop_database under concurrent schema churn on a
multi-worker instance (main flake signature 2's shape) and pins the deterministic half through a
component resource that reports the serving worker's own catalog: after a same-name recreate, a
worker still loads that database and every database scanned after it, and a job worker still
boots. On the previous commit the worker reports {"data":["anchor"],"dropme":[]} and never sees
the later database; with this one it reports both. The destroy-vs-open window itself is not
closed here and is not asserted.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTG3A13EcxJymQNwUQ82Gh
…ed process The Windows unit gate showed the tree walk doing its job: the spawned node.exe came with its own conhost.exe child, which the test's exact-members assertion did not allow for. Every member must now chain back to the spawned child instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTG3A13EcxJymQNwUQ82Gh
…ith a marker that survives a crash drop_database closed and destroyed a RocksDB directory with no cross-thread protocol: nothing told other threads to release it, and nothing stopped their rescans — or the interrupted-drop reconcile those rescans run, which opens a table's column stores — from opening the directory while it was being destroyed. rocksdb-js wakes a parked opener before the files are removed (rocksdb-js#818), so the reopen recreated the directory and held its LOCK for the life of the process; every later open failed and every job worker died at boot (main flake signature 2, run 33592149855). The drop now runs the protocol restore_backup already uses on the same directory. beginDrop takes the per-database lock and writes the lifecycle marker typed `drop` (a second line on the existing .restoring file, so a restore and a drop can never both claim a directory and old markers still read as restores); every thread releases its handles on the ITC-private close_database message and its rescan skips the marked database; waitForDatabaseClosedProcessWide then checks rocksdb-js's registry, and a handle that remains — a running job, or a component holding its own instance — fails the drop with 409 naming what is open instead of being force-closed. Only then are the directory and its blob roots destroyed and the marker cleared. A crash in between leaves an incomplete drop marker that the next scan on any thread finishes under the lock (recoverInterruptedDrop: the name must be a single directory name whose marker key matches, nothing is deleted through a symlink, the marker goes last, and a failure keeps the marker and is logged once — never thrown through getDatabases() at worker boot). LMDB databases are unchanged. Closure verification surfaced three handle leaks that would otherwise have made every drop 409: table() replaced the thread's catalog store handle on every table creation and attribute change (the previous handle stayed open, unreachable), reopened every existing index store on every call and assigned the fresh handle over the old one, and a dropped table's column-family handles were never closed on any thread once the table left the catalog. All three are closed here: a thread now keeps one catalog handle and one handle per index, and a reused index store still gets the per-open preparation (format resolution, versioned encoder, custom-index binding) a fresh one did. Four unit suites that relied on the reopen — intercepting or mocking a handle they expected table() to replace — now intercept or restore the shared one. closeDatabase also releases the root store a thread cached for the database even when a rescan that skipped the marked database has already removed it from the catalog — otherwise a close message arriving after such a rescan found nothing to close and the drop refused on that thread's handles. Directory fsyncs tolerate Windows refusing to flush a directory it did open. The concurrent-rescan test now asserts both halves (the dropped directory never reappears and no thread ever reports the LOCK held), a component that holds its own rocksdb-js handle proves the 409 and the drop after release, and a data root seeded with a crashed drop proves the boot scan finishes it. terminology.test.mjs loses the drop_database retry that hid the old race. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTG3A13EcxJymQNwUQ82Gh
…bers across scans, and reopen an index that changed kind Round-6 review findings on the drop protocol and the Windows tree wait: - drop_database removed its blob roots through a best-effort sweep that logged failures and reported success, and cleared the marker without the parent-directory fsyncs recovery performs. The online path now shares recovery's strict removal (nothing through a symlink, the first failed removal keeps the marker, parents fsynced before the marker goes). - A tree member whose parent exited between two scans had no row to reach it through, so the wait reported the tree gone while a grandchild still ran. Members are remembered by PID and creation time across scans, and their exit is latched from the first scan that lost them. - The registration hop's 5 s allowance before rootKnownAt is gone: the spawner's clock travels with the registration, so both callers bound the root's children by the same spawn-return allowance. - table() reused an index store across a change of index kind, driving an HNSW rebuild through the dupSort wrapper; the store is reopened as the other wrapper when the kind changes. - closeDatabase() releases each table through Table.cleanup(), so timers and reclamation handlers go with the handles; the LMDB drop awaits the environment close before unlinking under it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…pawn, and keep releasing handles when a table's teardown fails Round-7 review findings: - The allowance before a root's first known-running time was a fixed 1 s guess. The root is created inside the spawn() call, so the interval measured around that call is the exact bound; both callers now use it, and the registration carries the spawner's start and return times so the cross-thread hop adds nothing. The constant remains only as the fallback for a registration without them. - closeDatabase() released a table's stores through Table.cleanup(), so a throw earlier in that teardown would have left the column families open and every later drop refused; the stores are closed in the catch as well. - The LMDB close-before-unlink ordering now has a unit test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
… its PID Round-8 review finding: a remembered descendant's exitedAt was latched at the scan that first noticed it missing, which after the poll backs off can be seconds late — long enough for its recycled PID to acquire a new owner and spawn a child inside the gap. A table row that now holds the PID with a later creation time is unambiguously that replacement, so its creation time tightens the bound; the root's own exit does not need this, since confirmWindowsProcessTreeGone latches it on the very scan that first misses it, before any backoff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…n that first misses it Round-9 review finding: the scan where confirmWindowsProcessTreeGone first fails to find the root still built its frontier's notAfter from `now`, because rootExitedAt is only stamped after that call returns. A row that already, visibly held the recycled root PID at a creation time findWindowsTreeRoot itself would reject as ours tightens that bound, the same way a replaced descendant's PID already does; a row within the existing clock-skew tolerance is left alone; only that ambiguous case was ever `root`'s own to accept. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…ll process table as text, and re-check a drop marker that a race replaced Round-10 review findings (Gemini, alongside codex): - A schema change switching an index's kind closed the live handle before attempting the new wrapper's open; a construction failure in the new one (e.g. an invalid custom-index option) then left the table's live index map pointing at a store this thread had already closed, so every later read or write through it would fail. The new wrapper now opens first; the old handle is only closed once that succeeds. - The Windows process-table reader accumulated stdout as raw bytes, so a multi-byte character in a process name split across a chunk boundary would corrupt into replacement characters. The stream now decodes as utf8, which buffers a split character across chunks. - An on-demand open's guard against a database mid-restore-or-drop reads the marker's kind and then, moments later, asks recoverInterruptedDrop to act on it — two unlocked reads of a mutable marker. A drop marker replaced by an incoming restore in that gap reads back as "not-a-drop", which the guard was treating as nothing left to block; it now re-evaluates against the current marker instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…shed Round-11 review finding (codex and Gemini, independently): the previous round's fix opened the new column-family wrapper before closing the old one, but still closed the old handle immediately after that open — several statements, and a few operations that can throw (persisting the attribute descriptor, the reindex-trigger logic), before the assignment that publishes the new handle to the table's live index map. A throw in that gap left the map pointing at a handle this thread had already closed. The old handle is now closed only once the map has actually been updated to the new one. A second regression test for this gap (monkeypatching the shared catalog store's put to inject the failure) corrupted state for unrelated test files run later in the same mocha process — nine drop failures elsewhere, gone once the test was removed — so it is not included; the existing failed-open test and this round's full gates are the coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…n opening and publishing it Round-12 review finding (codex): the previous round's fix closed the old handle only after the new one was published, but did not do the mirror image — a throw after a successful reopen but before the publish (the attribute descriptor persistence, the reindex-trigger logic in between, either of which can throw) left the newly opened replacement dangling: nothing references it to close it, and it still counts as an open native handle against a later drop_database's process-wide closure check. The open-through-publish sequence is now wrapped so a throw anywhere in it closes whichever handle was never published — the old one on a failed reopen, the new one on a failure after a successful reopen, with a regression test for the second case that injects the failure through the table's own primary store rather than the shared catalog this time, avoiding the cross-test pollution from round 11's attempt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…tabase's files iteratively, and reconcile stale documentation Round-13 review findings (codex, Gemini, Harper-domain adjudication): - The rollback added last round only tracked a replacement opened for an index CHANGING kind. A first-time index on an existing table takes the same "open before publish" path but through the sibling branch, which recorded nothing for cleanup — a failure there leaked the handle permanently. Both branches now track their freshly opened, unpublished handle the same way; the regression tests now assert on rocksdb-js's own registry refcount, which actually proves a handle closed rather than only checking the untouched old one. - The online drop's default file removal was a single bulk async rm(), which occupies one of libuv's four threadpool slots for the whole delete and stalls every other queued filesystem operation in the process for as long as a large database or blob root takes to remove. It now walks one entry at a time, yielding between them, while still failing (and keeping the drop marker) on the first entry that cannot be removed. The boot-time/rescan half of the same protocol has the same shape of cost (a single synchronous rmSync blocking the thread's event loop) but cannot take the same fix without first making getDatabases()'s synchronous contract async across its many callers; recorded as a follow-up rather than attempted here. - DESIGN.md's drop-protocol section still described drop's old, marker-less design in one paragraph after the surrounding text was updated to the current marker-based one; reconciled. - A comment in windowsProcessTree.ts described a PowerShell exit code the script never produces; corrected to match the two codes it actually uses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
…it first Round-14 review finding (codex): the entry-by-entry removal added last round still called readdir(), which allocates every directory entry before the first one can be removed — the same whole-directory-at-once cost the switch away from a single bulk rm() was meant to avoid for a directory with very many entries. Uses opendir()'s async iterator instead, which yields one entry at a time without materializing the rest. Also drops an unfinished issue-number placeholder left in a test comment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jynwa8oKDur7LLrXVW4mG
The rebase combines main's audit-retirement barrier with the PR's cached-root release path. Add the cached root before stopping audit cleanup so every root that closeDatabase() releases has its cleanup loop retired first. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
Reviewed the drop lifecycle protocol, index-store reuse and rollback, close/acknowledgement ordering, and the Windows process-tree bounds at dacaf29. The head commit correctly runs Table.cleanup()'s synchronous releases before the derived runtime's close, so a throw there can no longer leave a job worker's interval holding the event loop open. No new blocking defect was confirmed on the changed lines. The three open design-level items are cross-repo or policy decisions already tracked in their own threads, so they were not repeated.
—
Reviewed dacaf29
recoverInterruptedDrop re-derived blob roots from live storage.blobPaths, so an operator who repointed that config between the crash and the restart got the NEW root deleted and the old one orphaned, marker cleared. The drop now records what it resolved — a third marker line, `targets <version> <json>` — and the recovery deletes that instead. Readers that predate it split off lines 1 and 2 and never look further, so no marker migrates; a marker without the line falls back to configuration exactly as before. A manifest that IS present but unreadable fails the recovery closed rather than guessing from configuration, which is the mistake it exists to prevent. Recorded roots are on-disk state, so they are checked before anything is deleted through them: every root a drop can target is join(<a configured blob path>, <database name>), so its last segment must be the database — a rule that holds however the configuration has moved. The recorded database path is checked against the one that resolved, so a per-database `path` change is caught rather than acted on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gtjs8AA4PBLF5aTDyB1btM
cb1kenobi
left a comment
There was a problem hiding this comment.
An interrupted drop can lose its original target manifest when resumed. Configuration drift can then delete the new blob root while orphaning the original one. Preserve and use the existing manifest during recovery.
—
Reviewed 21ad982
… a group id Two defects round 42 found, both in code this branch added. storage.blobPaths may be relative (the schema permits any string) and the manifest recorded getBlobPathsForDatabaseName()'s output verbatim. The thread that recovers need not share the working directory of the one that recorded — workers chdir to the root path while the main thread keeps the launch directory — so a relative root would name a different place on each: the recovery would miss the blob root and clear the marker, or remove a same-named directory under another root. Resolved at record time now, and a recorded root that is not absolute is refused rather than resolved against whoever is recovering. removeProcessGroup cleared the liveness state and creation stamp before checking that the unregistering thread owns the group. A group id is a PID the OS reuses at once, so a late UNREGISTER_PROCESS_GROUP from the thread that used to hold it erased the metadata another thread had just registered for a new child on the recycled PID — leaving that group's termination with no identity to check, back to a rootKnownAt of now, which findWindowsTreeRoot accepts for whatever process holds the PID. That is the harper#2273 unrelated-process kill this module exists to prevent. Ownership is checked first now. Both tests fail against a build without their fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gtjs8AA4PBLF5aTDyB1btM
Round 43's two legs both caught that the previous commit's ownership check does not close the race it targets. The case that matters is not a thread that never owned the PID — it is one that owned it *first*: A registers P, P exits, the OS hands P to B's new child, B registers it, and A's delayed UNREGISTER finally arrives. A still holds P in its own set, so membership alone says yes and B's creation stamp is wiped anyway. The stamp names its owner now, and only that owner may clear or consume it — in removeProcessGroup and in the termination path that reads it. Membership still goes unconditionally; it is per-thread and cannot be another thread's. The regression test covers the case the legs named as the gap, and fails against the previous commit's fix as well as against the original. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gtjs8AA4PBLF5aTDyB1btM
cb1kenobi
left a comment
There was a problem hiding this comment.
The new commits correctly key the Windows process-group stamp by owning thread and record drop blob roots as absolute paths. No new blocking defect was confirmed on the changed lines. Previously raised items on the drop-manifest rewrite, the check-then-destroy instant, and the unbounded Windows observer wait remain in their existing threads.
—
Reviewed ecc4ea4
Round 44's two legs both found the gap the previous commit left: the dead-owner sweep sends taskkill / SIGKILL for every id in the thread's own set, and only the wait loop after it consulted the creation stamp. A thread torn down before its unregister landed still lists a PID another thread's child now holds, so the kill went out before anything checked — the harper#2273 unrelated-process kill, on the one path the previous fix did not cover. Waiting on such an id was wrong too: it would block the termination until a stranger's process exits. The sweep now filters to the groups whose stamp still names this owner, warning for each one it skips, and the wait loop consumes the stamp knowing it is ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gtjs8AA4PBLF5aTDyB1btM
cb1kenobi
left a comment
There was a problem hiding this comment.
The new filter prevents a dead thread from killing a PID now owned by another thread. However, the ownership stamp does not distinguish registrations from different generations on the same thread, so a delayed unregister can erase tracking for a live replacement process. Include a registration token in unregister operations and compare it before deleting tracking.
—
Reviewed cb6b858
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>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
One new defect: closeDatabase always stops a database's audit-cleanup loop, but now sometimes skips actually closing and evicting the root store. When that happens the database reloads on the same cached root with audit cleanup permanently off, so its audit logs grow unbounded until a restart, silently. Everything else on the changed lines held up, and the remaining open items are maintainer decisions already recorded in existing threads.
—
Reviewed 9f15e42
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
The audit-cleanup restart fence can alias concurrent stop calls because it uses a shared drain promise as the generation token. Give every stop invocation a unique token so an older refused close cannot restart cleanup during a newer close.
—
Reviewed 2ebbcaa
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
The generation fence gives each audit-cleanup stop a distinct identity and prevents stale resumes during newer closes. No novel blocking defects were confirmed on changed lines; existing discussions are not repeated.
—
Reviewed 0845110
Characterizes the five Integration Tests failures
mainsaw in its last fifteen runs (four distinct signatures) and lands the fixes for the three that are ours: the Windowsdeploy_componentwedge is a process-tree identity bug, the "missing" blob record is a test that read before an async cache-fill committed, and the RocksDBLOCKwedge is a drop-versus-open race thatdrop_databasenow closes with the lifecycle protocolrestore_backupalready uses (the rocksdb-js registry defect underneath it is filed separately).#1854 audit:false delete …oracle:Corruption … NNNNNN.sst: No such file … MANIFEST-000005 may be corruptedHARPER_UWS_HTTP=1pass)DB::OpenForReadOnly) lists SSTs a concurrent compaction in the server unlinksmainruns since; durable fix tracked in rocksdb-js#812terminology.test.mjsjobsIN_PROGRESS:lock hold by current process … tuckerdoodle/LOCKandDatabase not opendrop_table; the rescan saw the drop tombstone, rancompleteInterruptedDrop(opens column stores) while main/0'sdrop_databasedestroyed the directory. rocksdb-js's registry erases and wakes the parked opener before the files are removed, and the opener holds a dangling map reference — the reopen recreates the directory and is registered nowhere, so itsLOCKis held for the life of the process and every job worker died at bootdrop_databasenow takes the per-database lock and writes adroplifecycle marker, every thread releases its handles and its rescan skips the database, rocksdb-js's registry is checked and a remaining handle fails the drop with 409 (naming what is open) instead of being force-closed, and a crash after the marker is finished by the next scan; three handle leaks the verification exposed are closed too. rocksdb-js#818 filed (P1) for the registry race; the marker keeps a Harper opener out of the window it leavesBlob lifecycle→no record found for cacheKeyget()on asourcedFromtable resolves before its cache-fill write commits (Table.tsgetFromSource); the test'sSELECTran 330 ms after the GETRedeploy runtime-equivalence proof→fetch failedafter 303 s, preparation lockheld by process 8848, thread 0terminateWindowsProcessTreeafternpm.cmdexited cleanly: the tree was identified by PID alone, so a recycled PID (or a staleParentProcessId) kept it "alive" — the branch #2273's last comment predicted, now confirmed by #2374's stage markerstaskkill /Ton an exited PIDNo common cause across 1, 2 and 4: a compaction against a read-only open, a destroy against a parked open in rocksdb-js's registry, and Windows PID recycling.
For the human reviewer
drop_databasenow fails closed (409) instead of force-closing. The planning review returnedbetter-alternative-existson a force-close design, and the ruling was to adopt its alternative: reuse the restore marker and close broadcast, verify process-wide closure throughregistryStatus(), and refuse when a handle remains — a running job (job workers never receive ITC broadcasts, so this is the only barrier for them) or a component holding its ownRocksDatabase. Behaviour change: a drop that today succeeds by force-closing an idle handle now returns 409 with the remaining handle described; the retry is the caller's. One casualty worth knowing: a database whose storage environment Cross-worker write can race RocksDB table drop and poison catalog cleanup #1381 has latched (adrop_tablethat raced another worker's cache-fill write, leavingInvalid column family specified in write batchon every later write) used to be recoverable bydrop_databaseforce-destroying it; it now 409s until a restart, which run 33670016278 (Node 26 3/6,blob.test.mjs) showed. Reversible by swapping the 409 fordestroy(), but that reopens exactly the window rocksdb-js#818 sits in.A handle leak per schema change was hiding behind that verification.
table()replaced the thread's catalog store handle on every table create and attribute change and reopened every existing index store on every call, assigning the fresh handle over the old; a dropped table's column-family handles were never closed on any thread. Unreachable handles only closed when a GC finalizer ran, which is why the first drop in the new test reported 103 references still open. Where to look hardest: the reuse intable()— an index handle is now shared between the live table and the create/update path (with the per-open preparation re-run on it), which was always true of the catalog handle ininitStores; a change of index kind (ordinary ↔ HNSW) is the one case where the handle cannot be reused, since the two are different wrappers over the column family, so the store is reopened as the other wrapper and the structural change rebuilds it through the new one (test flips both ways). Five unit suites had grown to depend on the reopen — intercepting or mocking a handle they expectedtable()to replace, or "restoring" an instance mock by assignment, which leaves an own property shadowing the prototype for every later suite — and now intercept ordeleteon the shared handle instead.The Windows wait still has no deadline. waitForConfirmedTermination polls without a deadline, and processGroupIsAlive counts an unreaped zombie as alive — a spawn's cleanup can hang forever #2076 asks for one; this change keeps the deliberate policy ("a wedged deployment is safer than a released lock over a live descendant") and instead removes the false-positive that made the wait infinite, backs the poll off, and logs the survivors so the next wedge names its process. Adding a deadline is a policy change for that issue. Where to look hardest: the lifetime bounds in
selectWindowsProcessTreeand the root exit latched after the scan — a bound that is too late keeps waiting, one that is too early releases the lock over a live descendant; members are remembered across scans by PID and creation time, so a grandchild whose parent exited between two scans is still reached (test, fails on the previous head) and its exit is latched from the first scan that lost it — the late bound again; and manageThreads' dead-worker path, which only asserts the root's exit when its synchronoustaskkillreported success, and now receives the spawner's clock with the registration so it bounds the root's children by the interval measured around the spawner'sspawncall, asApplication.tsdoes, instead of a 5 s hop allowance.A Windows Job Object would make the tree race-free by construction (assign the spawned child to a job,
TerminateJobObject, no scanning, no PID identity, no clock skew) but needs native code or anffidependency; the lifetime heuristic is the no-new-dependency answer, and the cost of switching later grows with each caller (two today). Raised by the review's decision ledger; not adopted here. The one case the heuristic cannot reach, named by the round-7 review: a grandchild whose linking ancestors (cmd.exeandnpm) both exited before the first process-table snapshot, which on the successful-command path is taken at the root'sclose. Nothing observed links it to the root, so the wait reports the tree gone while an unrefed installer child can still write into the component directory. Scanning during the command would cost a WMI query per poll for the length of an install; the kernel-stable answer is the Job Object. Recorded as a follow-up in the dispatch findings. Rounds 7 through 9 also narrowed three related exposures without removing the class: the allowance before a root's first known-running time is no longer a 1 s guess but the interval actually measured around itsspawn()call; a remembered descendant's exit is bounded by any row that visibly replaced its PID rather than only by the scan that noticed it missing (which, once polling backs off, can be seconds late); and the same bound now applies to the root's own frontier on the one scan where it previously fell back to the widenowbound — the scan that first fails to find the root, before its exit is stamped. All three still admit a stranger that starts and exits inside a window nothing observed, which only a kernel-stable identity closes. The allowance before the root's first known-running time is no longer a guess either:spawnWithEnvmeasures the interval around itsspawncall and both callers bound the root's children by it (the fixed 1 s is now only the fallback for a registration that carried none).The instant between the closure check and
destroy()is rocksdb-js's to close.describeOpenHandles→rootStore.destroy()is check-then-act across an await; a component callingRocksDatabase.open(path)on another thread in that instant is a handle Harper does not manage and cannot exclude, and it reproduces rocksdb-js#818. The window went from "always" to that instant; the registry-level requirement (refuse or serializeDestroyDBagainst a registered opener) is recorded on the issue. Two behaviour changes from the same review round: the online drop now removes the directory remnants and blob roots strictly — a symbolic link where the database directory or a blob root should be refuses the drop before anything is closed (it used to delete through the link), and a removal that fails keeps the marker and returns the error instead of logging it under a successful response (test); and a cross-thread close releases each table throughTable.cleanup(), so its timers, TTL interval and reclamation handler go with the handles (the LMDB drop awaits the environment close before unlinking under it).Round 10's cross-model review pass (Gemini, alongside codex) found two real defects this heuristic-narrowing had missed. A schema change that switches an index's kind now opens the new column-family wrapper before closing the old one — the previous order closed the live handle first, so a construction failure in the new wrapper (an invalid custom-index option, say) left the table's
indicesmap pointing at a store this thread had already closed, and every later read or write through it would fail; a regression test injects that failure. Round 11's re-read found the first fix incomplete: the old handle was still closed as soon as the new one opened, several statements before the assignment that actually publishes the new one (persisting the attribute descriptor and the reindex-trigger logic run in between, either of which can throw) — so a failure there still left the map pointing at an already-closed handle. The old handle is now closed only once that assignment has run. Round 12's re-read found this still incomplete the other direction: the new handle, opened but not yet published, was left dangling — closed nowhere — if anything in between (the descriptor persistence, the reindex trigger's own scan of the primary store) threw before the assignment; an open native handle nothing references still counts against a laterdrop_database's process-wide closure check. The open-through-publish sequence is now wrapped so a throw anywhere in it closes whichever of the two handles was never published — the old one stays open on a failed reopen, the new one closes on a failure after a successful reopen — with a regression test for the second case, injecting the failure through this table's own primary store rather than the shared catalog this time. The PowerShell process-table reader also readstdoutas raw bytes rather than decoded text, so a multi-byte character in a process name split across a chunk boundary would corrupt into replacement characters;setEncoding('utf8')lets the stream buffer a split character instead. A third finding — an on-demand open's guard against a database mid-restore-or-drop reads the marker's kind and state as two separate, unlocked reads, so a drop marker replaced by an incoming restore in the gap between them could read asnot-a-dropand fall through as if there were nothing left to block — is fixed by re-evaluating from the current marker on that outcome instead of treating it as settled; this one is a genuine multi-thread race at the granularity of individual syscalls and I did not find a way to cover it with a fast, deterministic unit test, so it rides on the existing drop-protocol integration coverage rather than a dedicated one. Declined: Gemini's comment-narration nit named five comments, three pre-existing and untouched by this PR; the two this PR did add explain non-obvious rationale (why a value now travels across a thread boundary, why a bound is taken at a particular moment) rather than restating the code, so I left them. Round 11's Gemini pass also raised two claims aboutmanageThreads.js's Windows dead-worker reclamation, both about code that predates this task's session (an earlier round of this same PR): its synchronous, scan-freetaskkillbefore any process-tree check (pre-existing, already# Findingsand this reviewer note's entry 3) and whetherkilledAtclips the tree window too early if a target lingers aftertaskkillreturns — round 12's re-read found that claim unreachable (killedAtis sampled only aftertaskkillhas already reported success, so nothing can spawn between the two), and it is dropped rather than tracked further. A third Gemini claim, that the BOM-strip regex inparseProcessTableis an empty pattern, is incorrect — it does contain\uFEFF, just not visibly so in a diff view.Round 13's cross-model review (Gemini + codex + Harper-domain adjudication) named a real cost this design accepts: the boot-time/rescan half of the drop protocol blocks its whole thread's event loop.
recoverInterruptedDropdeletes an interrupted drop's entire database tree with a single synchronousrmSync(path, {recursive:true}), on whatever thread'sgetDatabases()first sees the marker.getDatabases()is synchronous and called from many synchronous production paths, so this was a deliberate consequence of that contract, not an oversight — but for a crash mid-drop of a large database, the next boot (or a live rescan) freezes that thread for the whole delete: no logs, no health checks, nothing served. The online-drop half is already async, and its default removal is now an iterative walk that yields between entries rather than one bulkrm()occupying a single libuv threadpool slot for the duration — but the boot/rescan half can't take the same fix without first makinggetDatabases()'s lifecycle check async, which is a change to a widely-called synchronous contract, not a narrow bug fix. Recorded in# Findingsfor a follow-up rather than attempted here. Two smaller findings from the same round:DESIGN.md's drop-protocol section had an internally contradictory paragraph left over from before the marker-based redesign (fixed); a comment inwindowsProcessTree.tsdescribed an exit code the script never actually produces (fixed). Round 14's re-read of the same online-drop iteration foundreaddir()'s own cost — materializing every entry before removing any of them, the same whole-directory-at-once shape the switch away from bulkrm()was meant to fix — so the walk now usesopendir()'s async iterator instead, which yields one entry at a time without allocating the rest up front. Gemini's round-14 pass also raised a second manifestation of the already-adjudicated marker-read swallow (round 13, ruled pre-existing): a marker read mid-write (between its truncating open and the write landing) reads as empty content and is skipped the same way a permission error is. Traced the actual consequence:checkRestoreState(the on-demanddatabase()open's guard) checks marker existence, never content, so it is unaffected; only the boot/rescan bulk scan relies on content and could momentarily miss a just-started drop, and even thendrop_database's own process-wideregistryStatus()closure check — independent of this scan's bookkeeping — still catches any handle that gets opened during the miss and fails the drop closed rather than destroying under it. Real, narrow, same pre-existing shape as the earlier finding; recorded in# Findingsfor the same follow-up rather than fixed here.Round 15's Gemini pass reported a blocker-severity claim that does not hold up: a removed
delete databases[databaseName]indropDatabase's LMDB branch, said to leave a window where a concurrent read segfaults on a closed handle. The delete is still there — it now runs insidecloseDatabase(), called synchronously at the same pointdropDatabasealways called it — and diffing the LMDB branch againstorigin/mainline-by-line shows its timing relative to the async close/unlink work is unchanged by any commit in this PR. Declined as factually wrong, not a regression from this branch. Two smaller, real findings from the same round, both recorded in# Findingsrather than fixed here: the WMI process-table query has no timeout (a specific case of the already-adjudicated no-deadline design, not a new defect), and the new drop-protocol integration suite has no LMDB coverage (LMDB drop takes a different code path with no marker/lock/409 protocol; only unit-level close-ordering coverage exists for it today). Gemini's output this round also opened with two lines styled as system-level tool-use directives, unrelated to reviewing this diff — noted for the human reviewer as a probable artifact of the reviewer tooling, not acted on.One PR for three fixes. They share only the triage; a revert of one drags the others, and the Windows half cannot be exercised on the Ubuntu lanes. Kept together because the task's acceptance is one PR stating every signature's disposition, and the commits are independent per fix if a split is wanted.
Rebased onto current
main(169 commits); one file conflicted and the conflict was semantic. The branch was based at6d725818c(2026-09-02) and had inherited harper#2275 —unitTests/apiTests/mqtt-test.mjs:503failed on all three Linux Node versions, deterministically, andmain's own run at6d725818cfails it identically. Its fix (resources/crdt.ts, Fix LMDB partial-record history resolution broken by #2497 (harper#2275), hold the QA-701 409 probe's window open causally, and wait for MQTT deliveries instead of sampling 200ms #2526) merged three hours after that base, so the red Unit Test was never this change's.resources/databases.tsconflicted against feat(branches): declare tables into a branch through @table, ensureTable and defineTable #2523's branch-database work, which addedtarget.adopt(dbi)inside the index-open block this PR had rewritten into the reuse/rollback sequence. Resolution: a handle joins a branch'sopenedStoresclose list only when thistable()call opened it, and only at the publication point — never a reused one (already listed) and never inside the try whose catch closes an unpublished handle. The same rule now covers the catalog store, which this PR had made??=-reused while leaving its adopt unconditional.main'scleanupDisabledPlane()moved into the split-outprepareIndexStore, so an options change that disables a derived plane still cleans it up even though the reuse path no longer reopens the handle — which is also what makes entry 11's third item reachable.The rebase forced a full re-review (rounds 16–18); three findings were fixed and three design-level ones are open. Fixed:
selectWindowsProcessTreeaddedCLOCK_SKEW_MStonotAftereven when the bound came from an observed replacement row, whose creation time is on the same WMI clock as its children's — so a child that replacement spawned within 50 ms was selected as ours andtaskkillInvocationwould kill it, the exact unrelated-process kill (harper#2273) this module exists to prevent; the skew now applies only to the wall-clock bound and an observed replacement's time is exclusive, with a regression test that fails on the previous build.Table.cleanup()startedderivedIndexRuntime.close()withvoidand then synchronously closed the table's column families, against that runtime's documented contract (it resolves only once nothing can still write); it now closes the stores after the release settles, leaves them open when quiescence cannot be proven — so the drop refuses with 409 rather than closing under a live writer — and hands the promise tocloseDatabase's existingclosingarray sodrop_databaseawaits it before the registry check.taskkillInvocationbuilt one command line per round, whichCreateProcessrejects past 8191 characters into a spawn errorrunTaskkilldeliberately absorbs — several hundred orphans would be "killed" by a command that never ran, and the deadline-free wait would never end; per-PID kills are now batched at 200.The framing recheck returned
better-alternative-exists, and its three alternatives are decisions for a maintainer, not this branch. The pre-push CLI required a--mode planrecheck after a late round still produced fresh majors; the verdict names a better framing for each of the three surviving majors. (a) Drop protocol — an atomictryBeginDrop(path)gate in rocksdb-js that blocks new opens and reports existing ownership, so a refusal is side-effect-free. That is the registry-level requirement already recorded on rocksdb-js#818 and is a cross-repo change. It is the right answer to the open finding that a 409-refused drop has already runcloseDatabase(): a reference captured before the drop (const Orders = databases.shop.orders) keeps operating on a disposed class over closed handles even though the database survived. Verifying before announcing is not a substitute — the close broadcast is what makes other threads release their handles, so a verify-first ordering would refuse nearly every drop. (b) Index reuse — construct the candidate custom index without publishing or destructive cleanup, persist the catalog, then swapdbi.customIndexand clean up the superseded one. Today a same-kind options change rebinds the live wrapper and may unlink its HNSW plane file before the catalog write that can still throw, leaving this worker on the new definition while disk keeps the old. This one is implementable here and is a real change to the reuse mechanism, not a bug fix. (c) Windows — a Job Object (native code or anffidependency), or bound the confirmation and quarantine the unique.deploy-staging/<deploymentId>tree instead of waiting. Both reverse the recorded no-deadline policy (harper#2076). Concretely open: whenqueryWindowsProcessTable()returnsnull— WMI unavailable, PowerShell restricted — no kill is issued at all and the wait is infinite, where the removedterminateWindowsProcessTreefiredtaskkill /pid <root> /T /Fevery round. That is a behaviour regression on that host class and the reviewer is right that "an operator can see and clear it" does not hold when there may be no surviving target, only a permanently unavailable observer.Review-response rounds on
1c9dbae5…e56a86d0b: sixteen threads adjudicated, thirteen fixed and resolved, three left open as @kriszyp's standing calls — the rocksdb-jstryBeginDropgate (entry 5) and the Windows no-deadline policy (entries 3 and 4). Every fix below has a test that fails against a build without it. Lifecycle marker: the kind was read before the lock, so a restore that began and abandoned in the gap left a markerbeginDrop's truncating open erased (read under the lock now); the marker was written in place, so an ENOSPC left a nameless marker the scan skips and a partly deleted database loaded as healthy (staged, fsynced and renamed now, with short and zero-progress writes treated as failures); the metadata-directory fsync follows the rename, and its failure released the lock with a live drop marker, so the next scan would have deleted a database the operation had errored on (rolled back now unless the marker was already there); a non-ENOENT read error inside the lock but outside the try held the flock for the process lifetime;dropDatabasecleared markers it had superseded on both non-destructive exits; andlock.preexistingmeant "a file was there" rather than "a marker naming this database". Drop protocol: a 409-refused drop over a pre-existing marker broadcast a close, every thread rescanned, andrecoverInterruptedDropdeleted the directory with no handle check — under the very handle the drop had just refused for (both recovery call sites read the process-global registry first now); and the online drop derived its blob roots throughgetRootBlobPathsForDB, which answers[]behind a warning for a store carrying nodatabaseName, while recovery derives them by name — both halves usegetBlobPathsForDatabaseNamenow, on both engines. Handles:closeLoadedDatabases()did not awaitTable.cleanup()'s derived-index close, so a job worker could exit with its column families still in the process-global registry, the exact leak it exists to prevent; theCLOSE_DATABASEacknowledgement landed before the closes it started, whichdrop_databasereads as the thread having released; a reused index store was mutated (custom-index rebind, derived-plane unlink, versioned-encoder arm, rebuilding flag) several throw-capable statements before its catalog write, and is staged and applied at the publication point now; andcleanupDisabledPlane()could abort a declaration whose catalog write had already landed. Windows:CLOCK_SKEW_MSwas applied to same-clocknotBeforebounds, admitting a process the previous holder of a PID spawned up to 50 ms earlier — the harper#2273 unrelated-process kill this module exists to prevent. On the refused-drop thread, the strongest evidence is that its state is not new here:main'sRESTORE_BACKUPITC handler already callscloseDatabase()on a database that survives and reloads (server/itc/serverHandlers.js:53), so a class captured across that broadcast already operates over closed handles today — the 409 adds a trigger for that state rather than creating it, andTable.cleanup()'sdisposedmakes the failure explicit instead of a native-handle error. @cb1kenobi re-reviewed the result and wrote "the review-response fixes hold up … nothing new blocks here."A reviewer question about
removeStorageReclamationis answered, five more defects are fixed, and the framing recheck is back atbetter-alternative-exists— that last one is yours. @cb1kenobi asked whetherdropDatabase's droppedremoveStorageReclamationcalls moved intocloseDatabase. They did not need to move:closeDatabasealready made the call onmain(resources/databases.ts:2107at the merge-base), and both engine branches ofdropDatabasenow route through it, over a superset of the paths — every root store it can reach, plus each table's own handler viaTable.cleanup(), wheremain'sdropDatabasecleared one root path. The RocksDB half is pinned by a test that predates this PR (unitTests/resources/databases.test.js, "deregisters the storage-reclamation handler when a database is dropped", from Apply audit retention continuously to RocksDB transaction logs #2338) and fails if the call is removed; the LMDB half had no coverage on either side and now does.Five more, each with a test that fails against a build without it. The ITC handler's
await Promise.all(closing)sat outside any try, andcloseStorepushes an asynchronous close unwrapped — a rejection would have skipped the rescan below it, dropped the acknowledgementdrop_databasereads, and rejected intonotifyMessageListeners, which neither catches it nor handles the promise it returns.indexStoreMatchescompared wrapper kind only, anddeclareTable's closed-store guard checked the root store, whichdropTableleaves open while closing the table's own column families — so a same-name create arriving before the eviction broadcast rebound onto closed handles; both are rejected now.queryWindowsProcessTableandrunTaskkillhad no bound, so a hungGet-CimInstanceparked the confirmation loop before its own warning — each invocation is bounded now and a timeout resolves to the samenullan unreadable table already produces, so the deadline-free outer policy (harper#2076) is untouched and the state is finally visible.recoverInterruptedDroprejected both path separators on every platform whileschemaRegexpermits a backslash, so a POSIX database namedsales\2026could never have its interrupted drop finished; only the host's separators are rejected now, and the resolved-parent check that actually enforces "one directory under the databases root" is unchanged. AndTable.cleanup()calledderivedIndexRuntime.close()— another component's method — before its own synchronous releases, so a throw there left this class's record-expiration interval holding a job worker's event loop open.The framing recheck (
--mode plan, round 38) returnedbetter-alternative-exists, and the skill says a recheck is adopted or handed to a human, never overruled — so this is @kriszyp's call. Its concretely-better design is three things, all of which reach past this branch: (a) the drop marker should carry a versioned manifest of its actual deletion targets (a third line; old readers ignore it, so no migration) — today it records only the database name and recovery re-derives blob roots from livestorage.blobPaths, so config drift between the crash and the restart deletes the new root and orphans the old; (b) recovery should be asynchronous under a single owner, with the marker continuing to block scans and opens — which is thegetDatabases()synchronous-contract change already recorded as entry 7; (c) a Windows Job Object instead of creation-time reconstruction, which is the standing alternative in entry 12. The recheck also corrected one thing this PR had been saying:terminateProcessGroupsForThread's PID-keyedtaskkillruns on ordinary worker-port exit (server/threads/manageThreads.js:1797, viaremovePort), not only at process shutdown. It is still byte-identical to the merge-base — verified, not assumed — so it is pre-existing and wants its own issue, but it is a hotter path than this PR had claimed.Ruled and left as they are, with the evidence: the drop events reach the public
onRemovedTable/onRemovedDBhooks before the registry check can refuse with 409 — the emission is what lets a listener release its own handle before that check, so reordering it defeats the check; the closed-primary guard makes adrop_table-then-create_tablerace a loud error rather than retiring the class beforedrop_tableresolves, which both outside legs would prefer — retiring it inline duplicates the rescan's ownership ofdatabases, and a loud error beats the silent success over broken handles that preceded it; a rename failure can leave<key>.restoring.stagedbehind, which no scan reads and the nextbeginLifecycletruncates. Three claims were re-raised and are refuted at source:beginLifecycledoes return{ ...lock, preexisting }(dataLayer/restoreMarker.ts:337, the function's last statement — a fifth round for this one); the LMDB drop does close and evict its root beforeunlink(resources/databases.ts:2263-2268, awaited by the LMDB branch, and the tableless case arrives throughdefinedDatabases); andopenIndex's unguardedprepareIndexStoreis the LMDB branch, where lmdb-js caches dbis by name so there is no per-call handle to leak — every RocksDB index goes through the branch whose catch closes it.@kriszyp ruled the framing recheck: land alternative (a) here, and the other two become follow-up issues. It landed, and chasing it turned up three more defects in this branch's own code. The drop marker now records its own deletion targets on a third line (
targets <version> <json>), andrecoverInterruptedDropdeletes those instead of re-deriving blob roots from livestorage.blobPaths. Readers that predate the line split off lines 1 and 2 and never look further, so nothing migrates; a marker without it falls back to configuration exactly as before, and the seeded-crash integration fixture — whose marker has no manifest — proves that path end to end. A manifest that is present but unreadable fails the recovery closed, because guessing from configuration is the mistake it exists to prevent. Recorded roots are on-disk state, so they are checked before anything is deleted through them: a root must be absolute and its last segment must be the database name, which is the one rule that holds however the configuration has moved (join(<a configured blob path>, <database name>)is the only shape a drop can target). Entry 12's other two alternatives are now follow-up issues by that ruling, not open questions on this PR: Interrupted-drop recovery blocks the event loop: rmSync on the schema-rescan and on-demand-open paths (P2, Task, under [Epic] Backup/restore correctness) and Use a Windows Job Object for spawned process-tree lifetime instead of reconstructing identity from Win32_Process creation times (P2, Task, under [Epic] Component-spawned process supervision).Three defects the review rounds on that change found, all in code this branch added, each with a test that fails against a build without its fix. The manifest recorded relative paths.
storage.blobPathsmay be relative (the schema permits any string) and the roots were recorded verbatim; workerschdirto the root path while the main thread keeps the launch directory, so a relative root named a different place on whichever thread recovered — missing the blob root and clearing the marker, or removing a same-named directory under another root. Resolved at record time, and a recorded root that is not absolute is now refused rather than resolved against the recovering thread. A recycled PID let one thread erase another's process-group identity, in three layers, each found after the previous fix:removeProcessGroupcleared the PID-keyed state before checking ownership at all; then, once it checked membership, the case that actually matters turned out to be a thread that owned the PID first — A registers P, P exits, the OS hands P to B's child, B registers it, and A's delayedUNREGISTERstill finds P in A's own set, so membership says yes and B's stamp is wiped anyway; and then the dead-owner sweep was found to sendtaskkill/SIGKILLfor every id in its set before anything consulted the stamp. The creation stamp names its owner now, only that owner may clear or consume it, and the sweep filters to the groups whose stamp still names it, warning for each id it skips. Every one of those is the harper#2273 unrelated-process kill this module exists to prevent, reached on a path the previous fix did not cover.One open trade this created, recorded rather than decided. Filtering a recycled id out of the old owner's sweep also drops it from that owner's wait, so if that owner still had live descendants under the id, they are abandoned and its preparation lock is released early — where the recorded policy (harper#2076) prefers a wedged deployment over releasing on an unconfirmed tree. On Linux the case is not reachable: the kernel will not hand out a pid that is still live as a process-group id, so a group with surviving members keeps its number. On Windows there are no process groups in that sense and a root PID is recycled freely — but the descendants there are found by the process-table walk keyed on creation times, and that walk is driven by the very stamp that now belongs to the other thread, so the old owner could not have identified them correctly either way. This is the same identity problem entry 12(c)'s Job Object dissolves by making the tree an OS-level set; it is noted here rather than patched further, because choosing "abandon the orphans" over "wedge the lock" is the policy call harper#2076 already owns.
The final review-response pass closes four stale-cleanup races without changing the standing product decisions above. Process registrations now carry a monotonically increasing generation from the application spawner through the main-thread registry, so a delayed unregister from an earlier child cannot clear a newer same-thread, same-PID registration; the regressions cover replacement, cache invalidation, and a real detached child, while the application-level test pins generation transport. A resumed drop now preserves and returns the existing manifest from the lifecycle lock, refuses a malformed manifest instead of overwriting it, and reports unreadable marker hashes to the database loader so only the affected database is blocked; marker tests, real recovery coverage, and the design note pin those contracts. Every index checkpoint, failure, and completion now goes through one locked, build-identity-fenced catalog update, with drop/replacement/concurrent-metadata regressions and the existing checkpoint observer updated to the synchronous publication path. Finally, database roots close only after every child handle settles; Table cleanup deliberately keeps its root open when a derived runtime cannot prove quiescence, and the RocksDB and LMDB tests prove child-before-root ordering on success and rejection. The planning reviewer materially changed the implementation: its
better-alternative-existsruling was adopted by preserving the original drop manifest byte-for-byte and fencing all catalog writes, rather than rewriting the marker or only guarding status updates. The exact-head independent reviewer reports LGTM with no substantive finding; the wrapper remains formally non-converged because its domain/adjudication leg repeatedly failed or was pruned, so the human-review grade remains conservative.The current-main merge and final review tightened three more edges without changing the protocol. The merged audit-floor test now awaits the full child-before-root close chain, which is why the first post-merge CI run's three Linux unit jobs failed on the old single-root promise capture and why this head fixes them. A restore now refuses an unfinished drop, preserving its deletion manifest; index interruption detection now accepts either the bare or legacy primary-key catalog descriptor, with direct marker and catalog-shape regressions. The final
0a8ed8e09review had no accepted blocker: its two reported majors are contradicted by the reviewed source and existing end-to-end assertion (completeRestore/clearRestoreMarkeralready release infinally;signalSchemaChangeawaits the local handler, and the 409 integration suite asserts the database was reloaded). The real remaining concern is the explicitly documented fail-closed derived-runtime path above. The review wrapper remains formally non-converged because its domain/adjudication leg failed on an invalid response schema, so the machine-derived human-review grade is conservative.The final PR-feedback fix preserves audit retention when a close is deliberately refused.
stopAuditCleanup()now returns an identity-bearing retirement barrier and accepts only its matching resume, so a stale resume cannot override a newer concurrent stop. When derived-index teardown cannot prove quiescence andcloseDatabaseintentionally keeps the RocksDB root open, it awaits and resumes that root's cleanup loop before acknowledging; the existing rejection test now schedules a real pass and proves the retained root purges again. This addresses and resolves the only new post-push thread; the three standing design threads remain open for the maintainer decisions above. The delta review reached Gemini but Claude's five-hour quota rejected both its primary and fallback attempts, and the low-risk policy pruned adjudication; Gemini's reported blockers were false on source trace (every audit-cleanup async resume checks retirement/closure, the Windows helper supplies its default taskkill callback, andsignalSchemaChangeawaits the local rescan). The machine-derived footer records that degraded exact-head review honestly.The exact-head review found and closed one remaining overlap in that retirement protocol. Two simultaneous
stopAuditCleanup()calls could share the same in-flight drain promise, so promise identity was not an invocation identity: an older refused close could still resume cleanup through a newer close. Each stop now mints a monotonically increasing generation alongside its drain barrier, and resume accepts only the newest generation after draining; the new regression suspends one cleanup pass, takes two stops, proves the older resume leaves cleanup retired, then proves only the newer one restarts it. The final full review ran both Claude and Gemini over all 36 changed files and found no blocker in this fix. Its domain/adjudication leg failed its response-schema contract, so the receipt remains formally non-converged and Human-Review-Need 3. Two Gemini majors were rejected on direct source trace (signalSchemaChange()does await the sender's local rescan;isAbandonedIndexBuild()already treats a foreign PID or older restart generation as abandoned). The remaining minor notes are either the deliberate fail-closed derived-runtime / synchronous-recovery tradeoffs already called out above, or follow-up API polish (drop events can precede a later 409 and an incomplete-drop restore conflict gets generic wording).Changes
Verification
Signature 1:
HARPER_UWS_HTTP=1 npm run test:integration -- integrationTests/database/delete-index-atomicity-rocksdb.test.tsfive times in a row on Linux: 5/5 pass (the only "recovered on attempt 3/6" lines are the retry-contract test's own).Signature 2:
npm run test:integration -- integrationTests/database/drop-database-concurrent-rescan.test.ts(three suites): 3 workers × 8 drop cycles under schema churn asserting the dropped directory never reappears, a worker-served catalog probe after a same-name recreate, a job boot afterwards, and nolock hold by current processinhdb.log— 3/3 pass; the drop-signal release alone fails the probe on base ({"data":["anchor"],"dropme":[]}, 1668Database not openlines), and the first protocol run reported103 reference(s)still open until the handle leaks were closed. A component holding its own rocksdb-js handle:drop_database→ 409 naming the handle, the database intact and readable, then 200 after release with the directory gone. A data root seeded with a crashed drop (directory withCURRENT, blob root,dropmarker): all three gone at boot, the name creatable again.npx mocha unitTests/dataLayer/restoreMarker.test.js: 30 passing (11 new: typed markers, key mismatch ignored, recovery under the lock, refusals for traversal names / a marker naming another database / a symlink, a failed deletion keeping the marker).terminology.test.mjs(itsdrop_databaseretry removed) andblob.test.mjsunderHARPER_UWS_HTTP=1: 48/48 and 22/22.Signature 3:
npm run test:integration -- integrationTests/apiTests/blob.test.mjs: 22/22 pass.Signature 4:
npx mocha unitTests/server/threads/windowsProcessTree.test.js unitTests/components/applicationSpawn.test.js: 34 passing, 1 pending on Linux — the new tests pin a recycled root PID with children, 1.2 s / 30 s / 10 min stale-ParentProcessIdorphans under both allowances and under the root's observed creation time, a child created before its parent, an unreadable process table (unknown, not gone), root-only versus per-PID kills, the root exit latched after a slow scan, the survivor warning and the poll backoff. The pending test runs only on Windows: it executes the real PowerShell query against a spawned child and confirms the tree gone — exercised by theunit-test-windowsjob and the Windows integration legs below.npm run test:unit:main: 5253 passing, 1 failing on this box only (configValidatorresolves a relative root against a cwd that is 83 characters deep here; unrelated, pre-existing).npm run test:unit:resources: 1926 passing, 0 failing — after adjusting the five suites that depended ontable()reopening handles (entry 2), plus the round-9 through round-14 review fixes and their own regression tests (index-store-shape rollback on both branches, LMDB close ordering, the Windows process-tree descendant/root bounds).Integration Tests workflow, full 40-job matrix by
workflow_dispatch: eight runs on an earlier head (a84d7ba28, prior to the round-9 through round-15 review fixes below) — three fully green including Windows, uWS and Bun, the other five red only on shard 3 on one of two pre-existing tests outside this change (ttlResetOnWrite4. SQL UPDATE resets TTL, a first-SQL-operation cold-start race identical onmain;blob.test.mjsdrop_table BlobCache, Cross-worker write can race RocksDB table drop and poison catalog cleanup #1381's cross-worker drop race, identical onv5.2) — root-caused in the dispatch log, neither touched here. All four task signatures held green in all eight. On the final head (ec339814d), three consecutive full-matrixworkflow_dispatchruns — 33773623955, 33774586222, 33775438489 — all 41/41 jobs green, including every uWS HTTP and Windows leg, satisfying the ≥3-consecutive-greens acceptance criterion.Full 40-job matrix on the current head (
e56a86d0b): threeworkflow_dispatchruns, all 41/41 jobs green — 34834724651, 34834730613, 34834733004 — Node 22/24/26, every uWS HTTP and Windows leg included. The eight- and three-run evidence above belongs to pre-rebase heads (a84d7ba28,ec339814d); this is the head the review receipt and the three open threads refer to, so the >=3-consecutive-greens acceptance criterion is now satisfied on it. No push triggers the full matrix, so it was dispatched by hand.Superseded verification for the pre-rebase heads (
a84d7ba28,ec339814d) and for the intermediate review-response heads (8e803383f…e56a86d0b) is in the commit history and the dispatch log; none of it covers this head, and the run below does. Two things from it are still worth knowing: the worktree had been resolving@harperfast/rocksdb-js2.8.0 from a parent checkout while the rebased lockfile pins 2.9.0 (rangeReadActivity"preserves read-your-writes" passes or fails purely on that — harper#2506 / rocksdb-js#837), and a sixth suite's shadowing catalog mock (indexBuildAbandonment.test.js, new onmain) defeatedupdateAttributesLock's prototype interception and failed all three Node versions on the first rebased head.Review-response round 3 (heads
3e967d327…dacaf2939):npm run build,prettier --check .andoxlintclean (oxlint exits 0; its warnings are all pre-existing and none is in a touched file).npm run test:unit:resources2516 passing / 0 failing;npm run test:unit:main5587 passing / 3 failing, all three environment-specific to this box and outside this diff's call chain (the git-credential tmpdir leak, a uWS port another process on the box held, and theconfigValidatorrelative-root case that depends on cwd length);npx mocha unitTests/dataLayer/*.js281 passing;npx mocha unitTests/server/threads/windowsProcessTree.test.js26 passing with 3 pending (Windows-only, including the two new helper-bound cases, which theunit-test-windowsjob runs);npm run test:integration -- integrationTests/database/drop-database-concurrent-rescan.test.ts3/3, re-run after every change. Each of the seven new tests was confirmed to fail against a build without the behaviour it asserts — by removingcloseDatabase'sremoveStorageReclamation, the ITC handler's try, the closed-store guards on the index and on the primary, the host-aware separator rule, andTable.cleanup()'s release ordering. The full 40-job matrix has to be dispatched again on this head.Review-response round 4 (heads
21ad982d5…cb6b858ed):npm run build,prettier --check .andoxlintclean.unitTests/dataLayer/restoreMarker.test.js45 passing (5 new: the config-drift case, the no-manifest fallback, an unreadable manifest, a recorded root that is not this database's directory, one that is not absolute, and a recorded database path that no longer resolves here);unitTests/server/threads/windowsProcessTree.test.js+unitTests/components/applicationSpawn.test.js46 passing / 3 pending (Windows-only), including the two new ownership cases;npm run test:integration -- integrationTests/database/drop-database-concurrent-rescan.test.ts3/3, re-run after every change. Each new test was mutation-verified — the drift one against a recovery that ignores the manifest, and the process-group ones against both the original code and each intermediate fix, which is how the second and third layers were caught.npm run test:unit:resourceswas run twice on191f9ddf3under heavy load from other agents' review legs (9m vs the usual 5–6m): 2514 and 2515 passing with one different failure each run and none reproducing —blob.test.jspasses 122/122 as a file,derivedIndexRuntimeNativeBackendpasses alone,updateAttributesLockpasses 9/9 alone. The full 40-job matrix has to be dispatched again on this head.Final exact-head verification (
9f15e42b9):npm run format:check,npm run build, andnpm run lint:requiredpass; the full RocksDB resource suite passes 2520 with 33 pending, andHARPER_STORAGE_ENGINE=lmdb npm run test:unit:resourcespasses 1951 with 388 pending. Focused lifecycle/process/index coverage passes 73, index convergence passes 72 with 2 pending, RocksDB close ordering passes 9, and LMDB close ordering passes 3.npm run test:unit:mainreaches 5600 passing / 200 pending with three failures outside the diff: the unchanged deploy-credential test sees another reachable global/tmp/harper-git-cred-*socket,gitCredentials.test.jsinherits the dispatch runner'sGIT_CONFIG_GLOBAL/GIT_PAGER(and passes when those two variables are removed), andconfigValidatorresolves its relative-root fixture against this unusually long worktree path.npm run test:unit:windowsreproduces only that same unchanged global credential-socket failure; its other eight groups pass.npm run test:integration:allcould not start its instances because the external shared loopback-address-pool JSON was concurrently truncated (Unexpected end of JSON input); tests were cancelled at setup, so it supplies no product counterexample.Current exact-head verification (
0a8ed8e09):npm run build,npm run lint:required, andnpm run format:checkpass; focused restore/index regressions pass 53/53; the merged-head LMDB audit/close slice passes 47 with 2 expected skips; the full RocksDB resource suite passes 2691 with 45 expected skips, and the full LMDB resource suite passes 2097 with 421 expected skips. Before the current-main merge, GitHub's complete integration matrix was green on Linux, Bun, uWS, and Windows; the only red checks on the first merged head were all three Linux unit versions asserting the superseded single-root LMDB close capture, now updated at the audit-floor link above. Localtest:integration:allremains unobservable because another process truncated the shared loopback-address-pool JSON during setup, so current-head GitHub CI is the integration authority.Final review-response verification (
2ebbcaa16):npm run build,npm run lint:required, andnpm run format:checkpass; all 9 RocksDB close-ordering tests and all 80 active audit-log tests pass, with 8 expected engine-specific skips. The cleanup-resume assertion is mutation-sensitive: without the new resume, the scheduled pass remains retired and itspurgeLogscount stays zero.Exact-head overlap verification (
084511052):npm run buildandnpm run lint:requiredpass; Prettier reports both modified files clean; the complete combined audit-log and database-close slice passes 90 with 8 expected engine-specific skips. The new overlap test is behavior-sensitive: it fails if two stops reuse their shared drain promise as the restart identity, and passes only when the older generation is refused while the newest generation can resume. GitHub exact-head CI has 45 successful checks and 3 expected skips: Node 22/24/26 and Windows units, every Linux/Bun/uWS/Windows integration shard, adapters, HNSW, smoke, build, lint, and format are green. The only red check is review infrastructure: the full PR review exhausted Claude's context withPrompt is too long, posted no inline comments, and buffered no findings. A separate fresh full-scope Claude + Gemini review completed locally at this SHA; its domain response-schema failure and the GitHub review degradation are reflected in the conservative footer rather than hidden.Complexity: complicated
Review-Coverage: authored=codex; ran=gemini,claude; blocked=domain(exit-1); declined=cursor-grok,cursor-composer; rounds=55; full=4 @ 0845110
Human-Review-Need: 3 @ 0845110