fix(state): serialize replaced/generation probe with close() in _execute_write

A lock-free _raise_if_db_replaced() probe at the top of the _execute_write
retry loop raced a concurrent close(). close() runs under the same _lock and
ends the WAL generation: it checkpoints, closes the connection (SQLite
unlinks the -wal/-shm sidecars), nulls _conn and clears
_db_sidecar_identity. The probe could observe the mid-teardown state —
sidecars already unlinked while _db_sidecar_identity was not yet cleared —
and misclassify this process's OWN clean close as an externally deleted WAL
generation, raising a sticky DeletedWalGenerationError that permanently
refused every later write on that handle (#105567).

Move the live probe inside the lock, ahead of the close-race reopen
decision, so it only ever observes the stable post-close state (identity
cleared -> the existing adopt/reopen path). The corrupt flag check stays on
the lock-free fast path; external file/generation replacement detection is
unchanged, just serialized with teardown.

Synthetic repro (100 rounds x 40 writes, direct SessionDB handles): before
~9 failing rounds / ~360 DeletedWalGenerationError; after 0 failures,
4000/4000 writes persisted across repeated runs. tests/state (181) plus the
generation/replaced/corrupt guard suites (55) pass.

Fixes #105567
This commit is contained in:
ca-shrimp
2026-09-08 12:56:15 +08:00
committed by Teknium
parent d6b036b726
commit 3a3ec6eeaf

View File

@@ -810,10 +810,20 @@ class SessionDB(
ioerr_begin_retried = False
while True:
self._raise_if_db_corrupt()
self._raise_if_db_replaced()
# NOTE: the replaced/generation live probe runs INSIDE the lock below,
# not here. close() mutates _conn and _db_sidecar_identity under that
# same lock, ending the WAL generation (SQLite unlinks the -wal/-shm
# sidecars on a clean close). A lock-free probe that races close() can
# observe the mid-teardown state — sidecars already unlinked while
# _db_sidecar_identity is not yet cleared — and misclassify this
# process's OWN clean close as an externally deleted generation,
# raising a sticky DeletedWalGenerationError that permanently refuses
# later writes (#105567). Inside the lock the probe only ever sees the
# stable post-close state (identity cleared → adopt / reopen path).
fn_started = False
try:
with self._lock:
self._raise_if_db_replaced()
if self._conn is None: # close() raced this writer
self._reopen_after_close_locked(context="write")
self._conn.execute("BEGIN IMMEDIATE")