From 3a3ec6eeafa884d30ed0b63f9465ded939b02591 Mon Sep 17 00:00:00 2001 From: ca-shrimp <320556551+ca-shrimp@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:56:15 +0800 Subject: [PATCH] fix(state): serialize replaced/generation probe with close() in _execute_write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hermes_state.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/hermes_state.py b/hermes_state.py index 270d2ca4bc..76fbaeaa9f 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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")