Skip to content

fix(txn,lock): reclaim SSI reader bookkeeping (three resource leaks) - #146

Merged
gburd merged 6 commits into
masterfrom
work/fix137-138
Sep 7, 2026
Merged

fix(txn,lock): reclaim SSI reader bookkeeping (three resource leaks)#146
gburd merged 6 commits into
masterfrom
work/fix137-138

Conversation

@gburd

@gburd gburd commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #137. Fixes #138.

Three leaks in the same SSI cleanup chain — the two reported, plus a third found while validating.

#138 — MVCC mutex slot leaked

__txn_reap_si_details removed the detail and called __env_alloc_free with no __mutex_free(&td->mvcc_mtx), while __txn_end (txn.c:1851) and __txn_remove_buffer both free it. Fix frees the mutex before the detail, under TXN_SYSTEM_LOCK — the same ordering __txn_end already uses, so no new lock ordering is introduced.

Because the reaper and __txn_remove_buffer test the same predicate, and the latter dropped mvcc_mtx before taking the region lock, naively adding the free opened a double-free window. TXN_DTL_SNAPSHOT is now a single-owner claim taken under td->mvcc_mtx — the latch every writer of that flag already holds.

#137 — committed-reader locker never reclaimed

The deferral chain was correct until the last step: nothing ever completed the deferred free. __lock_siclean_obj now uses atomic_dec's return value to detect that it removed the last marker of a DB_LOCKER_FREED locker, and the new __lock_sireap_lockers frees those from __lock_sicleanup after all partition mutexes are released.

Why this can't UAF: locker frees need LOCK_LOCKERS, and the established nesting is LOCK_LOCKERS -> partition, so the reclaim runs outside every partition mutex (preserving the M2 rule). The reclaim pass dereferences no TXN_DETAIL — mpool may free the detail the instant si_ref hits 0, so the count is observed only as atomic_dec's return under the partition mutex, and the pass tests only locker-local state. That's also why the marker records INVALID_ROFF instead of leaving td_off pointing at possibly-freed memory. Lockers are reclaimed before __txn_reap_si_details, so no surviving locker names a freed detail.

Third leak — si_ref not decremented on SIREAD→WRITE upgrade

__lock_get_internal's "upgrade our own SIREAD to WRITE" branch removed the marker but never decremented si_ref, unlike the other two removal sites. si_ref stayed permanently above the true marker count, so the detail and the locker deferring on it could never be reclaimed: a snapshot txn that reads then writes the same key leaked on every iteration. Safe by construction — it's the reader's own marker on its live transaction, so si_ref can't reach zero there.

Measured (release -O2)

before after
#137 lockers 2→1410, mutexes 213→1621, ENOMEM at txn 1408 half-peaks 198/193, 2500 complete
#138 txn mvcc 57→651, in_use 372→1619, ENOMEM at cycle 653 half-peaks 104/103, ends at 4, 700 complete
third 672 slots per 1000 txns 23, no ENOMEM

Soak tier reconciliation

The tier from #143 marked both shapes expect_leak=1; they now pass, so the flags are cleared. mvcc_retained needed more: its residual is a bounded sawtooth, not a leak — over 30k txns lockers run 175 → 112 → 43 → 178 with no ENOMEM. Added soak_peak_grew() (a counter over tolerance is only a leak if the second half also peaks higher) and a per-workload min_txns so that shape asserts over ≥25k txns, covering a full collect cycle. Tier: 5 workloads, 0 unexpected outcomes, exit 0 — with all three controls still passing, so the check isn't defanged.

Validation

Builds clean: debug+diagnostic, release -O2, ASan, TSan, DST. ssi001/002/003/009, txn001/2/3, lock001/2/3, recd001, test001 btree+hash — all pass. ssi009 under ASan: 0 faults (the UAF gate for the reclaim change). TSan ssi009: same 3 pre-existing base-BDB mpool warnings as unfixed master — no new race. Fuzz gate 9/9; test_sim_crash_recover passes.

__txn_reap_si_details removed a committed reader's TXN_DETAIL from the
mvcc_txn list and freed it without releasing td->mvcc_mtx, while the two
other detail-free paths (__txn_end, __txn_remove_buffer) both do.  Every
reaped detail therefore leaked one mutex slot for the life of the
environment; repetition shrank the mutex region until a later valid
operation returned ENOMEM ("BDB2034 unable to allocate memory for mutex").

Reached whenever a snapshot transaction both reads a multiversion database
(leaving a SIREAD marker, so si_ref > 0) and writes one (mvcc_ref > 0):
__txn_end parks the detail on mvcc_txn, the last MVCC buffer is evicted
while the marker is still live, and the reaper -- not __txn_remove_buffer
-- performs the final free.

Free the mutex before the detail, under TXN_SYSTEM_LOCK, exactly as
__txn_end does, so no new lock ordering is introduced.

Both free paths test the same predicate, so make TXN_DTL_SNAPSHOT an
explicit single-owner claim taken under td->mvcc_mtx (the latch every
writer of that flag already holds).  Previously __txn_remove_buffer
dropped mvcc_mtx, then took the region lock and freed unconditionally;
with the reaper now also freeing the mutex, that window would be a
double free of the mutex slot.

Fixes #138
…rker goes

SIREAD cleanup removed a committed reader's obsolete markers but never
reclaimed the DB_LOCKER_FREED locker whose reclamation those markers had
deferred, nor its logical mutex.  Sequential read-only DB_TXN_SNAPSHOT
transactions therefore accumulated one locker (and one mutex slot) each,
with only one transaction ever active, until DB_ENV->txn_begin returned
ENOMEM.

The deferral chain was correct up to the last step: __lock_sicommit
detaches the markers and flags the locker DB_LOCKER_FREED,
__lock_freelocker_int defers while the detail's si_ref is nonzero, and
__lock_siclean_obj later drops each obsolete marker -- but nothing then
completed the deferred free.

__lock_siclean_obj now uses atomic_dec's return value to notice it removed
the LAST marker of a DB_LOCKER_FREED locker, and marks the locker
reclaimable by clearing its td_off.  The new __lock_sireap_lockers frees
those lockers from __lock_sicleanup after the object partition mutexes are
released.

Ordering, and why this cannot use-after-free:

  * Freeing a locker needs LOCK_LOCKERS.  The established nesting is
    LOCK_LOCKERS -> partition (__lock_sicommit, the deadlock detector), so
    the reclaim runs outside every partition mutex rather than taking
    LOCK_LOCKERS under one.  The M2 note's "do not free lockers while
    holding a partition mutex" rule is preserved.
  * The reclaim pass deliberately dereferences no TXN_DETAIL.  mpool may
    free the detail the instant si_ref reaches zero, so the marker count is
    observed only where it is safe -- as atomic_dec's return value in
    __lock_siclean_obj, under the partition mutex -- and the reclaim then
    tests only locker-local state (flag, td_off, empty heldby).  This is
    also why the marked locker records INVALID_ROFF instead of leaving
    td_off pointing at a detail that may already be gone.
  * Lockers are reclaimed before __txn_reap_si_details, so no surviving
    locker can name a freed detail.
  * The (DB_LOCKER_FREED && td_off == INVALID_ROFF) pair is set only by
    __lock_siclean_obj: a live locker never carries DB_LOCKER_FREED and a
    still-deferred one keeps its td_off, so ordinary lockers are untouched.

Fixes #137
Both #137 and #138 are resource-exhaustion bugs: nothing crashes, no page
is corrupt, no sanitizer fires -- slot counts just grow once per
transaction until a later valid operation returns ENOMEM.  Only mechanical
accounting catches that shape, so each driver reads the counts back through
the public statistics APIs (DB_ENV->lock_stat, ->mutex_stat,
->mutex_stat_print) and fails if they track the transaction count.

leak_si_locker (#137): 2500 sequential read-only DB_TXN_SNAPSHOT
transactions in one long-lived environment, one active at a time, no
checkpoint.

leak_si_mvcc_mtx (#138): snapshot transactions that read one multiversion
database and write another, with cache churn to force MVCC eviction, so the
final detail free lands in __txn_reap_si_details.

Each has a control mode that creates no SIREAD marker (plain transaction /
no read); the controls pass both before and after the fix, so a control
failure indicts the harness rather than the library.

The marker sweep is best-effort and fires on a lock-region threshold, so
the fixed steady state is a sawtooth rather than a flat line.  The
assertion is therefore peak-per-half-of-run: equal peaks pass, and a
per-transaction leak makes the second half's peak strictly larger.  That is
independent of both the transaction count and the sawtooth phase.

Measured (2500 txns / 700 cycles, release -O2):

  #137  before: lockers 2 -> 1410, mutexes 213 -> 1621, ENOMEM from
                txn_begin after 1408 txns
         after: lockers peak 198/193 per half, mutexes 409/404, all 2500
                complete
  #138  before: "txn mvcc" 57 -> 651, in_use 372 -> 1619, ENOMEM at
                cycle 653
         after: "txn mvcc" peak 104/103 per half, ends at 4, all 700
                complete

Run with test/c/leak-run.sh (timeout-bounded, deterministic, single
thread); `make leak_tests` builds the drivers alone.
Third leak in the same chain, found while validating #137/#138. The
'upgrading our own SIREAD to WRITE' branch in __lock_get_internal removes the
marker from sh_obj->sireaders but never decrements the owning detail's si_ref,
unlike the other two removal sites (__lock_sicommit, __lock_siclean_obj).

si_ref therefore stays permanently above the true marker count, so the detail --
and the locker deferring its free on it -- can never be reclaimed: a snapshot
transaction that reads then writes the same key leaks a detail, a locker and
their mutex slots every iteration until txn_begin returns ENOMEM.

Safe by construction: this is the reader's OWN marker (sh_off == holder), so the
detail is its live running transaction; si_ref cannot reach zero here and no
reclaim can trigger underneath us. Guarded with the same td_off != INVALID_ROFF
test the grant path uses.

Measured: growth 672 -> 23 slots per 1000 txns and the ENOMEM is gone.
The soak tier (PR #143) marked ro_snapshot and mvcc_retained expect_leak=1. With
#137/#138 fixed those now pass, which the harness correctly reports as UNEXPECTED
PASS -> exit 1. Clear both flags.

mvcc_retained needed more than a flag change. Its residual growth is a BOUNDED
SAWTOOTH, not a leak: markers accumulate until the GC trigger fires (live count
past half the allocated lock objects) and then collapse. Measured over 30k txns
the locker count runs 175 -> 112 -> 43 -> 178 with no ENOMEM -- it oscillates, it
does not climb. A least-squares slope cannot express that, and a 2000-txn window
lands mid-rise and reads as a leak. Two changes so the check states the honest
property:

- soak_peak_grew(): a counter over tolerance is only a leak if the SECOND half
  also peaks >10% higher than the first. A leak climbs; a sawtooth returns.
- per-workload min_txns: mvcc_retained asserts over >=25000 txns so the window
  covers a full collect cycle (its period is ~19k). The run reports when it
  raises the count.

Tier now: 5 workloads, 0 unexpected outcomes, exit 0. The three controls
(rw_plain, aborted, cursor_churn) still pass, so the check has not been defanged.
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ABI diff produced no report (build skipped or no base tag).


Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (2) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

The tiers were deliberately advisory while the bugs they reproduce were open,
with their own comments saying to flip them in the fixing PR. Both conditions are
now met:

- B3 (lock-mode matrix under ASan) reproduced the #140 heap overflow in
  __lock_vec; #145 fixed the lock-list sizing and the matrix passes, so any ASan
  fault in the lock list is now a real regression.
- B2 (resource-accounting soak) reproduced #137/#138; this PR fixes them and the
  tier reports 5 workloads / 0 unexpected outcomes.

Dropping continue-on-error from both. B1 was already a hard gate. Verified on this
PR: B1 pass, B3 pass.
@gburd
gburd merged commit 330892b into master Sep 7, 2026
53 of 56 checks passed
@gburd
gburd deleted the work/fix137-138 branch September 7, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant