Skip to content

Commit 063ed54

Browse files
Reap a parked transaction owner slot on signal
transaction()'s COMMIT/ROLLBACK arms park the owner slot at an internal sentinel during the wire round-trip and restore it to the thread token in an inner finally. A KeyboardInterrupt/SystemExit landing in that restore window left the slot at the sentinel, which the outer finally's token-equality clear never matched, so every later with-transaction() raised "Nested ... not supported" permanently. Clear the slot when it holds the sentinel too; the sentinel is only ever set within this same frame, so this can't clear a concurrent caller's slot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de2d35a commit 063ed54

2 files changed

Lines changed: 74 additions & 3 deletions

File tree

src/dqlitedbapi/connection.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,9 +2001,13 @@ def transaction(self) -> Iterator[None]:
20012001
with _state_lock:
20022002
self._transaction_owner = token
20032003
finally:
2004-
# Clear only if we still own the slot (== because the token
2005-
# is a thread-id int, not an interned-guaranteed object).
2006-
if self._transaction_owner == token:
2004+
# Clear if we still own the slot (== because the token is a
2005+
# thread-id int, not an interned-guaranteed object), OR if a
2006+
# signal in the COMMIT/ROLLBACK restore window left it parked at
2007+
# the internal sentinel — that sentinel is only ever set within
2008+
# this same frame, so reaping it here can't clear a sibling's slot
2009+
# and avoids a permanent transaction() wedge.
2010+
if self._transaction_owner == token or self._transaction_owner is _OWNER_INTERNAL_BUSY:
20072011
self._transaction_owner = None
20082012
with contextlib.suppress(Exception):
20092013
cursor.close()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""A signal landing in transaction()'s COMMIT/ROLLBACK owner-restore window leaves the slot
2+
parked at the internal sentinel; the outer finally must reap it, else every later
3+
``with conn.transaction()`` is permanently rejected as nested.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
import threading
10+
from unittest.mock import MagicMock
11+
12+
import pytest
13+
14+
from dqlitedbapi.connection import Connection
15+
16+
17+
def _bare_connection() -> Connection:
18+
conn = Connection.__new__(Connection)
19+
conn._closed = False
20+
conn._async_conn = MagicMock() # truthy but unused; cursor() is mocked
21+
conn._creator_thread = threading.get_ident()
22+
conn._creator_pid = os.getpid()
23+
conn._transaction_owner = None
24+
conn.messages = []
25+
return conn
26+
27+
28+
class _LockRaisingOnNthEnter:
29+
"""A lock proxy that raises KeyboardInterrupt on the Nth ``__enter__`` (simulating a
30+
signal delivered between bytecodes), delegating to a real lock otherwise."""
31+
32+
def __init__(self, n: int) -> None:
33+
self._n = n
34+
self._count = 0
35+
self._real = threading.Lock()
36+
37+
def __enter__(self) -> None:
38+
self._count += 1
39+
if self._count == self._n:
40+
raise KeyboardInterrupt("signal in owner-restore window")
41+
self._real.acquire()
42+
43+
def __exit__(self, *exc: object) -> None:
44+
if self._real.locked():
45+
self._real.release()
46+
47+
48+
def test_signal_in_commit_restore_reaps_owner_slot() -> None:
49+
conn = _bare_connection()
50+
# COMMIT (clean) path acquires _state_lock 3x: reserve, park-sentinel, restore-token.
51+
# Raise on the 3rd (restore) so the slot is left at the parked sentinel.
52+
conn._state_lock = _LockRaisingOnNthEnter(3) # type: ignore[assignment]
53+
conn.cursor = MagicMock(return_value=MagicMock())
54+
55+
with pytest.raises(KeyboardInterrupt), conn.transaction():
56+
pass # clean exit -> COMMIT path
57+
58+
# The outer finally must have reaped the leaked sentinel; without that, the slot
59+
# stays non-None and transaction() is permanently wedged.
60+
assert conn._transaction_owner is None
61+
62+
# A subsequent transaction() must NOT be rejected as nested.
63+
conn._state_lock = threading.Lock()
64+
conn.cursor = MagicMock(return_value=MagicMock())
65+
with conn.transaction():
66+
pass
67+
assert conn._transaction_owner is None

0 commit comments

Comments
 (0)