Commit graph

7 commits

Author SHA1 Message Date
Gergő Magyar
c8629f1b6c
fix(storage): preserve published graph on rebuild failure (#3613)
* fix(storage): preserve published graph on rebuild failure (U1)

* test(storage): strengthen publication review coverage (U1)

* test(storage): align publication evidence (U1)

* fix(storage): retain completed recovery archives

* fix(storage): preserve quarantines before cache reads

* ci: run staged publication benchmark guard

* fix(ci): declare WAL replay probe determinism

Document that the bounded recovery read drains and discards its rows. Only WAL replay success is observed; row identity and query behavior are unchanged. The existing 21 query-determinism checks pass.

* test(storage): cover publication across platforms

* test(ci): exclude POSIX suite from Windows estimates

* test: allow native publication retry to finish

* test: verify Windows incremental recovery

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-10-17 18:45:11 +01:00
Gergő Magyar
a665031c96
fix(lbug): diagnose staged-copy embedding failures, round max DB size (#3570)
* fix(lbug): round GITNEXUS_LBUG_MAX_DB_SIZE up to a power of two

The native buffer manager rejects any max DB size that is not a power of
two, but the env parser accepted any positive integer, so a value such as
300000000000 failed at open and was then reported with a misleading
"set a larger limit" hint.

Round a positive safe integer up to the next power of two and warn once
naming the raw and effective values. Values that are not safe integers
now fall back to the 16 GiB default.

Refs #3529

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(lbug): add remedies for the address-space ceiling and past-EOF read

Add two pure classifiers beside bufferPoolExhaustionRemedy. They parse only
what the engine message states, label the byte count as the limit the engine
reported (not the configured value), and name GITNEXUS_ATOMIC_INCREMENTAL=0
only for runs that knob changes. Neither asserts why the failure occurred.

Refs #3529, #3530

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(lbug): add size-evidence formatting for failure diagnostics

formatFileEvidence is a pure formatter over stat-shaped input (apparent
size, allocated blocks, raw statfs f_type), so sparse and regular cases are
testable without a real sparse file. describeFileEvidence wraps it, never
throws, and reports a missing file as unavailable.

Refs #3530

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(analyze): honor GITNEXUS_ATOMIC_INCREMENTAL=0 and classify staged failures

GITNEXUS_ATOMIC_INCREMENTAL was only read as === '1', so the documented =0
mitigation did nothing for watch and auto-sync, which pass atomicIncremental
as an option. An explicit =0 now beats the caller's request. Full rebuilds
and the escalation to staging still stage and are unaffected.

In the analyze catch block, snapshot the live and staging files before the
checkpoint driver stops and the database closes (closing can checkpoint the
staging file), classify the error once, then log the remedy and the held
evidence for a size-ceiling or past-EOF failure. The off-switch is named
only when the failed run was an atomic-incremental staged write. A max-size
message the classifiers do not recognise keeps the generic hint, and the
original error is rethrown unchanged. The atomic-incremental staging log
line now records the staged file size.

Refs #3529, #3530

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: document max DB size rounding and GITNEXUS_ATOMIC_INCREMENTAL

Note that GITNEXUS_LBUG_MAX_DB_SIZE must be a power of two and is rounded
up, add GITNEXUS_ATOMIC_INCREMENTAL (1 and 0, and which builds 0 does not
affect) to both README env tables, and add a Troubleshooting entry for the
two intermittent embedding write-back failures and the evidence lines GitNexus
now logs for them.

Refs #3529, #3530

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(review): apply review findings

Review fixes:
- Label the GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 sentinel in the failure
  evidence line instead of printing "buffer pool 0 B".
- Replace FailureRemedyContext {staged, offSwitchApplies} with a single
  StagedBuildMode ('in-place' | 'staged-rebuild' | 'staged-incremental'),
  removing the { staged: false, offSwitchApplies: true } illegal state.
- Re-attach the LBUG_MAX_DB_SIZE doc comment to the exported constant.
- Cover the in-place classified failure through runFullAnalysis, and the
  buffer-pool sentinel label, with new tests.

Also carries the simplification pass: failure evidence is collected only for
a classified failure, the staged-build flag is reused, the env var is read
once, and the evidence tests share one temp-dir helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(lbug): bound failure-evidence stats and record checkpoint activity

Review findings #1 and #3 on #3570.

describeFileEvidence raced nothing: a stat on a stalled 9p/NFS/virtiofs
mount hung the analyze failure handler before the driver stopped, the
database closed and the original error was rethrown. Each stat/statfs now
has a 2000 ms deadline and fails open; a missing file reads "absent"
instead of "unavailable", a stalled one reads "unavailable (timed out ...)".

collectFailureEvidence (new) is the single evidence collector: it reads
the checkpoint activity snapshot and dispatches every stat in one
Promise.all before its first await, covering the live index, the staging
copy and its .wal/.shadow/.wal.checkpoint sidecars. That is what lets a
#3530 report tell a short main file from pages pending in the sidecars or
from a CHECKPOINT GitNexus itself ran just before the failing DELETE.

runCheckpointWithRetry now records completed count, last completion time
and in-flight count. Both the periodic driver tick and checkpointOnce (the
staged window-start CHECKPOINT) go through it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(lbug): state the off-switch cost, pin the rounded max size, isolate ambient env

Review findings #5 (note), #6 (wiring) and #11 on #3570.

offSwitchNote now says that GITNEXUS_ATOMIC_INCREMENTAL=0 can leave the
live index partly written after a failed in-place write, and that the next
run then rebuilds it in full.

A new test stubs GITNEXUS_LBUG_MAX_DB_SIZE=300000000000, reloads
lbug-config and asserts the native Database constructor receives 2 ** 39
and that exactly one rounding warning is logged. The existing tests all
called the parsers directly and matched the constructor argument with
expect.any(Number), so reverting the wiring left the suite green.

vitest.config.ts sets GITNEXUS_ATOMIC_INCREMENTAL to the empty string
(neither '0' nor '1', i.e. unset) like GITNEXUS_MEMORY, so an exported =0
no longer switches off every staging test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(analyze): carry the failure diagnosis in the error message and label escalated runs

Review findings #2, #4, #5, #7, #8 and the run-analyze half of #6 on #3570.

#4: the remedy and `Failure evidence` lines were only sent to onLog, which
`analyze --watch` wires only with GITNEXUS_VERBOSE=1 and the auto-sync
launcher drops - the only two callers that request atomic-incremental
staging. They are now appended to the rethrown error's message in place
(identity and prototype preserved, native text first so classifiers
anchored on it still match), which every caller prints. A thrown value
that is not an Error falls back to a log. Auto-sync truncated that message
to 240 characters before logging it; the log now allows 2000 while the
persisted summary stays short.

#8: remedy delivery is best effort, so a throwing onLog sink or an
unwritable message can no longer replace the native error.

#2: escalatedFullWrite is declared above the try, and an escalated
atomic-incremental run is classified as a rebuild. Its remedy no longer
points at GITNEXUS_ATOMIC_INCREMENTAL=0, which does not stop the
escalation from staging.

#5: when GITNEXUS_ATOMIC_INCREMENTAL=0 overrides a caller that requested
staging, the run logs one line saying so and what a failed write costs.

#7: the stage-time size line uses the same "staging copy" label as the
failure evidence.

#6: tests pin the evidence-before-driver-stop-before-close order, the exact
configured max DB size in the evidence, and the appended text against the
substring classifiers in the CLI and auto-sync.

The READMEs describe the new evidence lines, ask reporters to include the
stage-time line, and name GITNEXUS_WAL_MANUAL_CHECKPOINT=0 as the bisect
knob for the checkpoint hypothesis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address PR review feedback (#3570)

- wal-checkpoint-driver: count a checkpoint as completed only when
  tryFlushWAL actually ran one. It returns false when no connection is
  open, and the failure evidence renders the count as "N completed".
- auto-sync runner: an oversized native error now logs its head and tail
  (1000 characters each) so the appended failure diagnosis, which is always
  the tail, survives. The persisted 240-character summary is unchanged.
- lbug-config: the max-size remedy said GITNEXUS_LBUG_MAX_DB_SIZE "must be
  a power of 2", but non-powers are rounded up. It now says so, as do the
  matching lines in gitnexus/README.md.
- failure-evidence and file-evidence tests use the default registered
  temp-dir prefix, so the stale-sidecar sweep recognises their fixtures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address PR review feedback (#3570)

- run-analyze: stringify the thrown value through a helper that cannot
  throw, so an exotic throw (null-prototype object, throwing toString)
  cannot abort the failure handler before it stops the driver, closes the
  database and reclaims the staging copy.
- run-analyze: a classified non-Error throw is wrapped in an Error (original
  kept as `cause`) so the diagnosis travels in the message like every other
  case, instead of in a log that the auto-sync launcher drops. This removes
  the separate log fallback.
- auto-sync runner: the oversized-error log keeps head and tail within the
  2000-character cap; the separator no longer pushes it to 2003.
- lbug-config: the max-size rounding warning prints the operator's value as
  supplied (300000000000.9), not the integer it floored to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address PR review feedback (#3570)

- lbug-config: the max-size remedy's rounding example was itself a power of
  two. It now shows a value being rounded: 300000000000 becomes 549755813888.
- auto-sync-runner test: the comment said the whole log line is capped at
  2000 characters; the assertion covers the error payload after the prefix.
- extension-loader test: pin that the installer child is handed the rounded
  max DB size for a non-power-of-two override. The installer prefers that
  argument over the env var and has its own non-rounding parser, so this is
  what keeps a valid override working there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address PR review feedback (#3570)

- Keep the whole failure diagnosis in auto-sync's capped log line: the
  tail now reserves the remedy and evidence lines (with a 240-char floor
  for the native head) instead of an even head/tail split.
- Count only this run's checkpoints in the failure evidence: snapshot the
  process-global activity when the run begins and report the difference,
  so an earlier `analyze --watch` refresh no longer leaks in.
- Diagnose classified failures thrown while opening the staging copy, via
  helpers shared with the write-path catch.
- Halt checkpoint ticks before measuring failure evidence: start
  `walCheckpointDriver.stop()` (which clears its timer synchronously)
  before the stats, then await the drain before close.

Note: pre-existing timeouts in run-analyze-fts-repair ("fails repair mode
when no base meta exists", "abandons a pending set that fails
EMBEDDING_RESUME_MAX_ATTEMPTS times in a row") reproduce on the unchanged
PR head on this host and are not addressed by this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:43:26 +01:00
Gergő Magyar
114ae0b411
fix(indexing): abort on exhausted or collapsed graph writes (#3540)
* docs(indexing): document native pool exhaustion recovery (U3)

Publish the reviewed recovery runbook and collapsed-write guardrails for #3526.

* docs(indexing): explain native buffer pool sizing (U3)

* fix(indexing): reject collapsed staged graph writes (U2)

* fix(indexing): abort native buffer exhaustion (U1)

* test(indexing): cover exhausted and collapsed graph writes

* ci(tests): split ubuntu coverage into 4 shards

Coverage shard 1/3 runs 20-25 minutes across branches and now hits the
25-minute job timeout (cancelled on this PR and on unrelated branches),
which skips coverage-merge and fails "every test executed" plus every
platform compatibility gate downstream. A fourth shard brings each slice
well under the cap. Shard job names are not required checks; the merge
job globs coverage-blob-*, so no other wiring changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(lbug): remove branching from buffer-exhaustion tests

Split the collapsed-staged-graph case into first-time and existing-index
tests sharing one helper, so every assertion runs unconditionally instead
of behind `if (hasExistingIndex)`. Parameterize the first-COPY exhaustion
case with its statement matcher (reusing isRelationshipCopy) rather than a
ternary on the phase name. Assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:31:38 +01:00
Gergő Magyar
412446408d
fix(index): guard graph integrity and fail closed on incomplete risk (#3442) 2026-10-02 15:34:26 +01:00
azizur100389
bf7dcf98ca
feat(analyze): add incremental watch mode (#3072)
* feat(analyze): add incremental watch mode

* fix(watch): harden control file reads

* fix(watch): contain refresh errors and bound reads

* fix(watch): stream strict control file reads

* fix(watch): harden refresh recovery and lifecycle

* fix(watch): report ignored repository defaults

* fix(analyze): preserve signal exit semantics

* style(analyze): format signal exit helper

* test(config): exercise descriptor growth guard

* test(watch): await source event before rename

* fix(watch): keep live-index retries honest and ignore analyzer writes

Hold retry backoff when events merge, stop only after a live-index mutation, skip .gitnexus self-writes, and reject the remaining one-shot watch flags. Export impact-risk scoring from gitnexus-shared so consumers can share the same scale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): contain queue edge cases after review

Preserve overflow-only refreshes, contain synchronous refresh failures, and mark successful atomic publication before later operations can fail.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-29 10:40:56 +00:00
Gergő Magyar
1e764cd475
fix(analyze): single-writer lock for the index write path (#2658) (#2677) 2026-07-25 05:08:13 +01:00
Abhigyan Patwari
9efc6bfcad
fix(lbug/analyze): atomic index swap + read-pool staleness invalidation (#2614)
* fix(lbug): re-open the read pool when analyze rebuilds the index under it

The MCP read pool's initLbug early-returned on an existing pool entry with no
freshness check, so after analyze rebuilt or mutated the on-disk index the
pool kept serving the old (POSIX: unlinked-but-open) inode until LRU/idle
eviction — a silent stale-read window of up to IDLE_TIMEOUT_MS (5 min).

Record the file identity {ino, mtimeMs, size} on each PoolEntry at open, and
re-stat in initLbug: unchanged → reuse; changed & idle → closeOne + reopen the
new file; changed while a query is in flight → serve the current handle (a
later idle initLbug reopens, since closing an in-use connection is a native
use-after-free). A stat failure (ENOENT during a full rebuild's unlink window)
is treated as unchanged so the reader keeps its valid open inode until the new
file appears. Mirrors the bridge cache's mtime-invalidation pattern.

Step 1 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation. The
end-to-end reopen-on-swap path is exercised by the reader-during-rebuild
integration test in a later step.

* fix(analyze): publish a full rebuild via an atomic swap (POSIX)

The full-rebuild path wiped the live index (wipeLbugDbFiles(lbugPath)) and
rebuilt it in place, so a concurrent MCP reader that opened mid-build could
see an empty/half-loaded DB, and a crash between the wipe and the end-of-run
left the index destroyed (recoverable only by --force).

Build the fresh index at <lbugPath>.new and swap it over the live index in one
atomic rename at the end. All DB work flows through the singleton connection,
so only initLbug/wipeLbugDbFiles take the temp target; the close already
checkpoint-consolidates the build to a single file (verified: no residual
.wal/.shadow), so the rename publishes a complete index in one step. A reader
opening mid-build only ever sees the previous complete index; a reader holding
the old inode keeps a consistent stale snapshot until the pool re-opens onto
the new one (the pool staleness invalidation from the prior commit). On
failure the swap is skipped, leaving the previous index byte-for-byte intact.

POSIX only: the common CLI/serve-worker analyze paths skip the native close
(closeLbugBeforeExit, #2264) and leave the build handle open at swap time.
POSIX renames an open file cleanly; a same-process open handle blocks the
rename on Windows, so Windows keeps the current in-place behavior
(buildPath === lbugPath) until that is resolved. The Windows atomic swap and a
deterministic concurrent reader-during-rebuild test are deferred follow-ups.

Steps 2b + partial 3 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation.
Integration test asserts the no-temp-leak + inode-swap invariants and the
crash-safety guarantee (a load failure leaves the live index untouched).

* test(analyze): end-to-end read-pool reopen after an atomic swap

Adds the deferred reader-during-rebuild / pool-reopen integration test:
analyze v1 -> read pool serves it -> rebuild with a renamed function (atomic
swap) -> the same repoId's initLbug detects the swapped inode and re-opens the
pool onto the new index. Asserts the pool sees the renamed function and NOT the
stale v1 name, exercising #1 (invalidation) and #2 (swap) together end to end.

* fix(lbug): bound pooled read queries with setQueryTimeout

The read pool relied only on a JS-side Promise.race (QUERY_TIMEOUT_MS) that
frees the waiter but leaves the native call running. Set the engine-level
setQueryTimeout on every pooled connection so a pathological query is bounded
at the source too.

* fix(lbug): name the held-open cause for WAL checkpoint failures (#2599)

A WAL-checkpoint IO error that also carries a busy/lock signal means another
handle (a gitnexus mcp server, or this process's own reader) holds the store
open, not a disk fault. Add isLbugCheckpointBusyError (reusing the tested
isDbBusyError keyword set) and, when the checkpoint driver exhausts its retry
budget on such an error, annotate the surfaced error with the actionable
held-open cause instead of a raw IO string.

Note: overlaps in-flight work on repro/issue-2599-windows-wal-checkpoint;
bundled here at the maintainer's request.

* feat(analyze): opt-in atomic incremental + best-effort Windows swap

Extends the atomic-swap publish (POSIX full rebuild) to two more cases:

- Windows: the swap now applies when a real close is safe to release the build
  handle before the rename — i.e. non-pdg runs (windowsSwapOk excludes --pdg,
  the #2264 destructor-crash case), forcing a real close on the swap path.
  UNVERIFIED on Windows (no Windows runner here); --pdg and any failure fall
  back to today's in-place behavior, so it can never corrupt.

- Incremental (opt-in, GITNEXUS_ATOMIC_INCREMENTAL=1): copies the live index
  into the temp, applies the incremental delete/writeback to the copy, and
  swaps at the end. Off by default because the whole-file copy negates
  incremental's speed premise — kept behind a flag pending a benchmark. The
  escalation valve also targets the temp so an escalated write stays atomic.

Integration test covers the opt-in incremental path end to end (no temp leak,
the incremental change is reflected after the swap).

* refactor(lbug): centralize the read-pool + bridge open-retry budgets

The lbug-config retry registry documented the open/handle-release/query-time
budgets but the read pool's LOCK_RETRY_* (pool-adapter) and the bridge's
LBUG_OPEN_RETRY_* (group/bridge-db) kept private copies that could drift. Move
both into the registry as exported constants (POOL_OPEN_LOCK_RETRY_*,
BRIDGE_OPEN_RETRY_*) and alias the local names to them — one tuning surface,
no behavior change.

* fix: address CI regressions from the bundled follow-ups

- setQueryTimeout: guard the call so test doubles that don't model the engine
  method don't break connection creation.
- atomic swap: skip the rename when the build produced no DB at buildPath (an
  empty repo / mocked pipeline) instead of throwing ENOENT.
- #2599: don't wrap the checkpoint error in the driver (it hid the IO signature
  the CLI's --wal-checkpoint-threshold hint keys on); name the held-open cause
  at the CLI instead, beside that hint, keeping the original error intact.
- retry consolidation: revert to documentation-only — moving the pool/bridge
  budgets into lbug-config broke every explicit lbug-config test mock. The
  registry now catalogues all budgets with their in-file locations.
- analyze-wal-checkpoint-failure test: block both lbug.wal.checkpoint and
  lbug.new.wal.checkpoint, since a full rebuild now checkpoints the temp.

* fix(analyze): publish the swap before stamping meta; identity-gate the reader (#2614 F1)

Review found a HIGH regression: the full-rebuild wrote the freshness stamp
(saveMeta, indexedAt=T_new) BEFORE the atomic swap, so a concurrent MCP reader
that reinited in the saveMeta->swap window opened the OLD inode, recorded
observed=T_new, and then never reinited again (ensureInitialized returns early
on 'current') — serving the pre-rebuild graph indefinitely. The build-into-temp
change inverted the pre-PR invariant that 'meta shows T_new' implied 'lbugPath
holds T_new data'.

Two coordinated fixes:
- run-analyze: move the final saveMeta AFTER the swap, so meta.indexedAt only
  becomes visible once lbugPath resolves to the new inode. Verified nothing in
  the span reads on-disk meta and registerRepo writes only the registry.
  Leaving the dirty flag set across the swap also improves crash-safety.
- local-backend: the reader staleness gate now also compares the lbug file
  IDENTITY (ino/mtime/size), reiniting on an inode change even when
  meta.indexedAt is unchanged. This closes the swap-window latch and covers the
  in-place incremental case — and is what actually makes the pool's dbIdentity
  net reachable for the MCP reader (the indexedAt gate otherwise bypassed it).

* fix(lbug/analyze): WAL-aware incremental, residual-sidecar reconcile, Windows opt-in, #2599 anchor (#2614 F2-F4)

Review remediations:
- F3: gate atomic incremental on a CLEAN live index (inspectLbugSidecars) — the
  main-file-only copy would drop an orphan .wal's delta; fall back to in-place.
- F4: on the swap, MOVE a residual <buildPath>.wal/.shadow beside the published
  index (not orphan it) so a swallowed final checkpoint's delta is replayed.
- F2: record identity on the shared read-only Database and warn when a cached
  handle is reused after its on-disk index was rebuilt while another consumer
  holds it (unreachable via MCP — one consumer per lbugPath; a complete fix
  needs per-inode handles, documented).
- Windows swap: opt-in (GITNEXUS_ATOMIC_WINDOWS_SWAP=1), default off — the
  forced real close re-bets an unproven #2264 assumption and can't be verified
  without a Windows runner, so the default Windows path stays in-place.
- #2599: anchor isLbugCheckpointBusyError to real held-open wording instead of
  isDbBusyError's bare .includes('lock') over a message that embeds the DB path
  (a repo under blockchain-app misclassified a disk fault as held-open).
- Docs: corrected retry-catalogue budgets (linear, not exp) and the
  checkedOut>0 bound comment (load-bounded, not IDLE_TIMEOUT_MS).

* test(analyze): cover the production close path in the atomic swap (#2614 F5)

Adds a full-rebuild swap test with skipNativeCloseOnExit:true — the close path
the CLI and serve-worker actually ship (build handle left open at swap time),
distinct from the default real-close the other swap tests exercise. Asserts the
POSIX swap still publishes a single consolidated lbug with no .new temp and no
orphan sidecar.

* test(analyze): give the follow-up git commits an inline identity (CI fix)

The end-to-end reopen and atomic-incremental tests' second commits used a bare
`git commit`, which fails on CI runners with no global git identity (empty
ident name). makeRepo's initial commit already passes -c user.name/-c
user.email inline; apply the same to the rename/change commits. No code change.

* fix(mcp): route reader reinit through initLbug's active-query guard (#2614 review)

Review found an active-query retirement race: LocalBackend.ensureInitialized
detected an identity/stamp change and called closeLbug(poolKey) DIRECTLY, but
closeOne closes the shared Database at refCount 0 regardless of checked-out
connections. So a reader detecting the new generation could close the Database
a concurrent query is still executing on — a native use-after-free. This
bypassed the checkedOut>0 guard that initLbug itself has.

Fix (delegate, not close directly): initLbug now returns whether it actually
rolled the pool over; ensureInitialized calls initLbug (which serves the
current handle while a query is in flight and reopens only when idle) instead
of closeLbug. The observed IDENTITY is advanced only when the pool actually
reopened — if a query was in flight, the identity stays divergent and the
reopen retries on a later idle check rather than latching on the old handle.
The observed STAMP advances regardless so a same-file stamp change can't loop.

Old generation now stays alive until its in-flight queries drain (lazy
rollover); new requests during the busy window share the old handle until the
pool goes idle, then reopen. No parallel open-both-generations, but no UAF and
no stale latch.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-21 21:58:00 +01:00