Follow-up from the #694 cross-model review (adjudicated significant-narrow; shared with the pre-existing listener cleanup path, not introduced by #694).
The module env-cleanup hooks (DBRegistry::RemoveListenersByEnv, DBRegistry::ReleaseCommitCompletionsByEnv) find shared descriptors by walking instance->databases[*].descriptor. Two windows make a live descriptor unreachable from that map:
OpenDB's wait predicate does entry.descriptor.reset() when it observes a closing descriptor (db_registry.cpp), dropping the registry's ref while the closer is still inside finishClose(). A worker env tearing down in that window is not scrubbed from the descriptor; the backstop is finishClose()'s own release pass (which runs after the commit thread is joined), but that pass can then call napi_release_threadsafe_function on a tsfn whose creating env has concurrently finished teardown — a narrow UAF.
- Related pre-existing hazard in the same protocol:
OpenDB's waiting thread holds auto& entry = entryIterator->second (a reference into the map node) across the condition wait, while the closer's guarded erase can erase that node (the guard erases when !eraseIt->second.descriptor, which is exactly the state OpenDB's predicate creates). The woken waiter then evaluates its predicate against freed node storage.
Candidate categorical fix: give the registry a secondary set of all live descriptors (weak_ptr), maintained under databasesMutex, that the env-cleanup hooks walk instead of the entry map — descriptor reachability then no longer depends on entry-map lifecycle. The entry dangling reference wants a re-find-after-wake (or shared_ptr'd entry values) in OpenDB.
#694 already narrowed the surface: DestroyDB now closes before unlinking (CloseDB's discipline).
🤖 Generated with Claude Code
Follow-up from the #694 cross-model review (adjudicated significant-narrow; shared with the pre-existing listener cleanup path, not introduced by #694).
The module env-cleanup hooks (
DBRegistry::RemoveListenersByEnv,DBRegistry::ReleaseCommitCompletionsByEnv) find shared descriptors by walkinginstance->databases[*].descriptor. Two windows make a live descriptor unreachable from that map:OpenDB's wait predicate doesentry.descriptor.reset()when it observes a closing descriptor (db_registry.cpp), dropping the registry's ref while the closer is still insidefinishClose(). A worker env tearing down in that window is not scrubbed from the descriptor; the backstop isfinishClose()'s own release pass (which runs after the commit thread is joined), but that pass can then callnapi_release_threadsafe_functionon a tsfn whose creating env has concurrently finished teardown — a narrow UAF.OpenDB's waiting thread holdsauto& entry = entryIterator->second(a reference into the map node) across the condition wait, while the closer's guarded erase can erase that node (the guard erases when!eraseIt->second.descriptor, which is exactly the state OpenDB's predicate creates). The woken waiter then evaluates its predicate against freed node storage.Candidate categorical fix: give the registry a secondary set of all live descriptors (weak_ptr), maintained under
databasesMutex, that the env-cleanup hooks walk instead of the entry map — descriptor reachability then no longer depends on entry-map lifecycle. Theentrydangling reference wants a re-find-after-wake (or shared_ptr'd entry values) inOpenDB.#694 already narrowed the surface:
DestroyDBnow closes before unlinking (CloseDB's discipline).🤖 Generated with Claude Code