Stop a recycled Windows PID from wedging deploy_component and release dropped databases on every thread - #2470
Stop a recycled Windows PID from wedging deploy_component and release dropped databases on every thread#2470kriszyp wants to merge 20 commits into
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
…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>
Use the explicit BOM escape in the Windows process-table parser, retry a marker probe that disappeared between state and kind reads, and reopen an LMDB index when its dupSort shape no longer matches the attribute definition. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Do not kill a reused Windows PID whose creation time is unknown, retain the spawn-interval fallback, and retire audit cleanup before releasing table stores during database close. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep the termination confirmation loop in its unknown state until a root with no creation timestamp has left the process table, and cover LMDB ordinary-index reuse. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep terminating already verified descendants while an unverified root remains unknown. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
ec33981 to
459b253
Compare
| if (dirname(dbPath) !== root || !dbPath.startsWith(root + sep)) { | ||
| throw new Error(`Refusing to recover a drop of '${dbName}': it does not resolve to a database directory`); | ||
| } | ||
| const remove = options.remove ?? ((path: string) => rmSync(path, { recursive: true, force: true })); |
There was a problem hiding this comment.
Suggestion (non-blocking): recoverInterruptedDrop's default remove here is a synchronous rmSync(..., { recursive: true }). It only runs when an interrupted drop marker is actually found, and only on the one thread that wins tryFileLock — but that thread blocks its own event loop for the full duration of the removal, on a rescan path that (per the comment at resources/databases.ts:870-872) runs on every worker for every schema event. The online drop path already moved this same job to the async, iterative removeSteadily/removeDroppedDatabaseFiles, so this crash-recovery path is now the one place still doing a blocking recursive delete on a worker's event loop.
I see the PR description calls this out as deferred because getDatabases() requires a synchronous return — that's a reasonable scope call. But the trade-off isn't recorded anywhere in this file. A short comment on recoverInterruptedDrop (or right here) explaining why it's sync despite its async sibling would save whoever revisits getDatabases() from having to rediscover the constraint first.
|
Reviewed; no blockers found — left one non-blocking suggestion inline. |
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.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.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.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).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).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.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.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.Verification
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).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.npm run test:integration -- integrationTests/apiTests/blob.test.mjs: 22/22 pass.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).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.Complexity: complicated
Review-Coverage: authored=codex; ran=claude; blocked=gemini(timeout); declined=cursor-grok,cursor-composer,domain; rounds=4 @ 459b253
Human-Review-Need: 3 @ 459b253