Skip to content

Commit a558b94

Browse files
Run misuse guard at top of transaction() so fork diagnostic precedes cross-task
transaction() previously skipped past _check_in_use straight into its own nested-tx / sibling-task checks. After fork, _in_transaction may be True (parent had a tx in flight) and _tx_owner is the parent's Task — neither meaningful in the child, but the "owned by another task" branch fires and renders the parent's task repr in the error message, pointing users at "use a separate connection from the pool" instead of telling them to reconstruct the connection. Run _check_in_use() at the top of transaction() so the fork-pid guard surfaces the clear "reconstruct from configuration in the target process" diagnostic before any task-ownership check fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 38d06e3 commit a558b94

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,6 +2021,13 @@ async def transaction(self) -> AsyncIterator[None]:
20212021
need defensive-rollback semantics against outer cancellation
20222022
— not individual operations inside it.
20232023
"""
2024+
# Run the standard misuse guard first so a forked child entering
2025+
# transaction() sees the clear "used after fork" diagnostic
2026+
# instead of the misleading "owned by another task" branch
2027+
# below (which would render the parent's task repr in the
2028+
# error message). ``_check_in_use`` performs the pid check
2029+
# before any asyncio primitive is touched.
2030+
self._check_in_use()
20242031
# An untracked SAVEPOINT (parser-rejected name issued without a
20252032
# preceding BEGIN) auto-begins a server-side tx without flipping
20262033
# ``_in_transaction``. Surface a dedicated diagnostic so the
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Pin: ``DqliteConnection.transaction()`` calls ``_check_in_use`` at
2+
the top so a forked child sees the clear "used after fork" diagnostic
3+
instead of the misleading "owned by another task" branch (which would
4+
render the parent's task repr in the error message — confusing
5+
diagnostic with a non-actionable owner reference).
6+
7+
The transaction context manager previously skipped past
8+
``_check_in_use`` straight into its own nested-tx / sibling-task
9+
checks. After fork, ``_in_transaction`` may be True (parent had a tx
10+
in flight) and ``_tx_owner`` is the parent's Task object — neither is
11+
meaningful in the child, but the "owned by another task" branch fires
12+
and points users at "use a separate connection from the pool" instead
13+
of telling them to reconstruct the connection.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import asyncio
19+
from unittest.mock import MagicMock, patch
20+
21+
import pytest
22+
23+
from dqliteclient import DqliteConnection
24+
from dqliteclient.exceptions import InterfaceError
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_transaction_after_fork_raises_fork_diagnostic_not_cross_task() -> None:
29+
"""A forked child entering ``transaction()`` must see the
30+
"used after fork" message even if the parent had a transaction
31+
in flight at fork time. Without ``_check_in_use`` at the top, the
32+
nested-task branch fires and surfaces the parent's Task repr."""
33+
conn = DqliteConnection("127.0.0.1:9999")
34+
# Stage a parent-side transaction in flight: _in_transaction True
35+
# and _tx_owner pointing at a fake "parent task." After fork the
36+
# child inherits both fields.
37+
conn._in_transaction = True
38+
conn._tx_owner = MagicMock(spec=asyncio.Task)
39+
conn._bound_loop = asyncio.get_running_loop()
40+
fake_parent_pid = conn._creator_pid + 1
41+
conn._creator_pid = fake_parent_pid
42+
43+
with patch("dqliteclient.connection.os.getpid", return_value=fake_parent_pid + 1):
44+
with pytest.raises(InterfaceError, match="fork") as excinfo:
45+
async with conn.transaction():
46+
pass
47+
48+
# The diagnostic must be the fork message, not the cross-task one.
49+
msg = str(excinfo.value)
50+
assert "fork" in msg
51+
assert "owned by another task" not in msg

0 commit comments

Comments
 (0)