Skip to content

Commit ab6b90e

Browse files
authored
πŸ› fix(rw): close sqlite3 cursors and skip SoftFileLock Windows race (#491)
ReadWriteLock._configure_and_begin creates cursors via Connection.execute() without closing them. On CPython refcounting destroys them immediately, but on PyPy cursors accumulate until non-deterministic GC collects them. If Connection.__del__ runs first and calls sqlite3_close, a later Statement.__del__ calls sqlite3_finalize on freed memory β€” segfault. Fix by chaining .close() on every execute() call. Skip test_threaded_shared_lock_obj for SoftFileLock on Windows. SoftFileLock uses file-existence locking where unlink can silently fail under heavy thread contention when an antivirus scanner or search indexer momentarily holds the handle after close(). This orphans the lock file with no recovery path since stale-lock detection is disabled on Windows and PID-based detection cannot distinguish same-process threads. This is the same known limitation already documented by the skip in test_threaded_lock_different_lock_obj.
1 parent 98b4ee9 commit ab6b90e

File tree

2 files changed

+11
-5
lines changed

2 files changed

+11
-5
lines changed

β€Žsrc/filelock/_read_write.pyβ€Ž

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,25 +188,25 @@ def _configure_and_begin(
188188
) -> None:
189189
waited = time.perf_counter() - start_time
190190
timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)
191-
self._con.execute(f"PRAGMA busy_timeout={timeout_ms};")
191+
self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close()
192192
# Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE
193193
# write transaction is active, making read-write locking impossible without modifying table data.
194194
# MEMORY is safe here since no actual writes happen β€” crashes cannot corrupt the DB.
195195
# See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
196196
#
197197
# Set here (not in __init__) because this pragma itself may block on a locked database,
198198
# so it must run after busy_timeout is configured above.
199-
self._con.execute("PRAGMA journal_mode=MEMORY;")
199+
self._con.execute("PRAGMA journal_mode=MEMORY;").close()
200200
# Recompute remaining timeout after the potentially blocking journal_mode pragma.
201201
waited = time.perf_counter() - start_time
202202
if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms:
203-
self._con.execute(f"PRAGMA busy_timeout={recomputed};")
203+
self._con.execute(f"PRAGMA busy_timeout={recomputed};").close()
204204
stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;"
205-
self._con.execute(stmt)
205+
self._con.execute(stmt).close()
206206
if mode == "read":
207207
# A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database.
208208
# https://www.sqlite.org/lockingv3.html#transaction_control
209-
self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;")
209+
self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close()
210210

211211
def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy:
212212
opposite = "write" if mode == "read" else "read"

β€Žtests/test_filelock.pyβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,12 @@ def join(self, timeout: float | None = None) -> None:
242242

243243
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
244244
def test_threaded_shared_lock_obj(lock_type: type[BaseFileLock], tmp_path: Path) -> None:
245+
if sys.platform == "win32" and lock_type.__name__ == "SoftFileLock":
246+
pytest.skip(
247+
"SoftFileLock uses file-existence locking β€” on Windows, unlink can silently fail under heavy "
248+
"thread contention (EACCES from antivirus/indexer), orphaning the lock file with no recovery path"
249+
)
250+
245251
# Runs 100 threads, which need the filelock. The lock must be acquired if at least one thread required it and
246252
# released, as soon as all threads stopped.
247253
lock_path = tmp_path / "a"

0 commit comments

Comments
Β (0)