Skip to content

Commit 1dc709b

Browse files
Reject pickle and copy on client classes
DqliteConnection / ConnectionPool / ClusterClient / DqliteProtocol each hold loop-bound state that cannot survive serialization: sockets, asyncio.Lock / Queue / Event, weak cursor sets, single- flight slot maps. ConnectionPool / ClusterClient pickle SILENTLY in Python 3.10+ (asyncio primitives became pickleable) — producing a "live"-looking duplicate detached from any running loop. Any use yields opaque corruption. Add ``__reduce__`` raising a clear driver-level TypeError to each class, naming the class and the unpickleable internal state. Symmetric with the existing dbapi Connection / Cursor guards; applies uniformly to pickle, copy.copy, and copy.deepcopy.
1 parent 4925d40 commit 1dc709b

5 files changed

Lines changed: 155 additions & 4 deletions

File tree

src/dqliteclient/cluster.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
import random
77
from collections.abc import Callable, Iterable
8-
from typing import Final
8+
from typing import Final, NoReturn
99

1010
from dqliteclient.connection import DqliteConnection, _parse_address, _validate_timeout
1111
from dqliteclient.exceptions import (
@@ -161,6 +161,21 @@ def from_addresses(
161161
store = MemoryNodeStore(addresses)
162162
return cls(store, timeout=timeout, redirect_policy=redirect_policy)
163163

164+
def __reduce__(self) -> NoReturn:
165+
# Holds a per-client single-flight slot map keyed by loop-bound
166+
# asyncio.Task instances and a NodeStore that may itself hold
167+
# mutable address state. Pickling produces a duplicate detached
168+
# from any loop and from the original NodeStore's lifecycle —
169+
# any use yields opaque corruption. Surface a clear
170+
# driver-level TypeError instead. Symmetric with the
171+
# ConnectionPool / DqliteConnection guards.
172+
raise TypeError(
173+
f"cannot pickle {type(self).__name__!r} object — holds a "
174+
f"loop-bound single-flight slot map and a NodeStore "
175+
f"reference; reconstruct from configuration in the target "
176+
f"process instead."
177+
)
178+
164179
def _check_redirect(self, address: str) -> None:
165180
"""Reject leader-redirect targets that fail the configured policy."""
166181
if self._redirect_policy is None:

src/dqliteclient/connection.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
1111
from contextlib import asynccontextmanager
1212
from types import TracebackType
13-
from typing import Any, Final
13+
from typing import Any, Final, NoReturn
1414

1515
from dqliteclient.exceptions import (
1616
DataError,
@@ -787,6 +787,22 @@ def __repr__(self) -> str:
787787
state = "connected" if self._protocol is not None else "disconnected"
788788
return f"<DqliteConnection address={self._address!r} database={self._database!r} {state}>"
789789

790+
def __reduce__(self) -> NoReturn:
791+
# ``DqliteConnection`` holds a live socket, an event-loop-bound
792+
# asyncio.Lock, and a WeakSet of registered cursors — none of
793+
# which survive serialization. Surface a clear driver-level
794+
# TypeError instead of leaking the underlying ``cannot pickle
795+
# '_thread.lock'`` (or, worse, the cryptic
796+
# ``Can't pickle local object 'WeakSet.__init__.<locals>._remove'``
797+
# post-connect). Symmetric with the dbapi-layer guards on
798+
# Connection / Cursor.
799+
raise TypeError(
800+
f"cannot pickle {type(self).__name__!r} object — holds a "
801+
f"live socket, loop-bound asyncio.Lock, and weak cursor "
802+
f"refs; reconstruct from configuration in the target "
803+
f"process instead."
804+
)
805+
790806
@property
791807
def in_transaction(self) -> bool:
792808
"""Check if a transaction is active.

src/dqliteclient/pool.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from collections.abc import AsyncIterator, Sequence
77
from contextlib import asynccontextmanager
88
from types import TracebackType
9-
from typing import Any, Final
9+
from typing import Any, Final, NoReturn
1010

1111
from dqliteclient.cluster import ClusterClient
1212
from dqliteclient.connection import (
@@ -280,6 +280,21 @@ def __repr__(self) -> str:
280280
f"max_size={self._max_size}, {state})"
281281
)
282282

283+
def __reduce__(self) -> "NoReturn":
284+
# ``asyncio.Queue`` and ``asyncio.Lock`` became pickleable in
285+
# Python 3.10+, so a naive ``pickle.dumps(pool)`` SILENTLY
286+
# produces a "live"-looking duplicate — detached from any
287+
# running loop, with fresh internal locks and queue. Any use
288+
# of the duplicate yields opaque corruption. Surface a clear
289+
# driver-level TypeError instead. Symmetric with the dbapi
290+
# Connection / Cursor guards.
291+
raise TypeError(
292+
f"cannot pickle {type(self).__name__!r} object — holds "
293+
f"loop-bound asyncio.Queue / asyncio.Lock / asyncio.Event "
294+
f"and live worker tasks; reconstruct from configuration "
295+
f"in the target process instead."
296+
)
297+
283298
async def initialize(self) -> None:
284299
"""Initialize the pool with minimum connections.
285300

src/dqliteclient/protocol.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import secrets
66
import sys
77
from collections.abc import Sequence
8-
from typing import Any, Final
8+
from typing import Any, Final, NoReturn
99

1010
from dqliteclient.exceptions import DqliteConnectionError, OperationalError, ProtocolError
1111
from dqlitewire import (
@@ -161,6 +161,20 @@ def __init__(
161161
# a latency-SLO boundary from server-induced amplification.
162162
self._trust_server_heartbeat = trust_server_heartbeat
163163

164+
def __reduce__(self) -> NoReturn:
165+
# Wraps a live ``asyncio.StreamReader`` / ``StreamWriter``
166+
# (loop-bound), a ``MessageDecoder`` with internal buffer
167+
# state, and per-stream cap counters that mean nothing
168+
# post-deserialise. Surface a clear driver-level TypeError
169+
# instead of leaking the underlying ``cannot pickle
170+
# 'asyncio.streams.StreamReader'``.
171+
raise TypeError(
172+
f"cannot pickle {type(self).__name__!r} object — wraps "
173+
f"loop-bound StreamReader / StreamWriter and a stateful "
174+
f"MessageDecoder; reconstruct from a fresh wire handshake "
175+
f"in the target process instead."
176+
)
177+
164178
@property
165179
def is_wire_coherent(self) -> bool:
166180
"""True if the decoder buffer has not been poisoned.

tests/test_pickle_guards.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Pin: client-layer classes raise a clear ``TypeError`` on pickle /
2+
copy / deepcopy. Symmetric with the dbapi's existing pickle guards
3+
on Connection / Cursor.
4+
5+
Without explicit ``__reduce__`` raises:
6+
- ``ConnectionPool`` / ``ClusterClient`` SILENTLY pickle (asyncio
7+
primitives became pickleable in 3.10+) — producing a
8+
"live"-looking duplicate detached from any loop. Any use yields
9+
opaque corruption.
10+
- ``DqliteConnection`` post-``connect()`` raises a cryptic
11+
``AttributeError("Can't pickle local object
12+
'WeakSet.__init__.<locals>._remove'")`` from the cursor-tracking
13+
weak set.
14+
- ``DqliteProtocol`` raises an opaque error from wrapped
15+
StreamReader / StreamWriter.
16+
17+
Mirror the ISSUE-791 pattern: every class raises a
18+
driver-level ``TypeError`` naming the specific class.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import copy
24+
import pickle
25+
26+
import pytest
27+
28+
from dqliteclient.cluster import ClusterClient
29+
from dqliteclient.connection import DqliteConnection
30+
from dqliteclient.node_store import MemoryNodeStore
31+
from dqliteclient.pool import ConnectionPool
32+
33+
34+
class TestDqliteConnectionPickleGuard:
35+
def test_pickle_raises(self) -> None:
36+
conn = DqliteConnection("localhost:9001")
37+
with pytest.raises(TypeError, match="DqliteConnection"):
38+
pickle.dumps(conn)
39+
40+
def test_copy_copy_raises(self) -> None:
41+
conn = DqliteConnection("localhost:9001")
42+
with pytest.raises(TypeError, match="DqliteConnection"):
43+
copy.copy(conn)
44+
45+
def test_copy_deepcopy_raises(self) -> None:
46+
conn = DqliteConnection("localhost:9001")
47+
with pytest.raises(TypeError, match="DqliteConnection"):
48+
copy.deepcopy(conn)
49+
50+
51+
class TestConnectionPoolPickleGuard:
52+
def test_pickle_raises(self) -> None:
53+
pool = ConnectionPool(addresses=["localhost:9001"])
54+
with pytest.raises(TypeError, match="ConnectionPool"):
55+
pickle.dumps(pool)
56+
57+
def test_copy_copy_raises(self) -> None:
58+
pool = ConnectionPool(addresses=["localhost:9001"])
59+
with pytest.raises(TypeError, match="ConnectionPool"):
60+
copy.copy(pool)
61+
62+
def test_copy_deepcopy_raises(self) -> None:
63+
pool = ConnectionPool(addresses=["localhost:9001"])
64+
with pytest.raises(TypeError, match="ConnectionPool"):
65+
copy.deepcopy(pool)
66+
67+
68+
class TestClusterClientPickleGuard:
69+
def test_pickle_raises(self) -> None:
70+
cluster = ClusterClient(node_store=MemoryNodeStore(["localhost:9001"]))
71+
with pytest.raises(TypeError, match="ClusterClient"):
72+
pickle.dumps(cluster)
73+
74+
def test_copy_copy_raises(self) -> None:
75+
cluster = ClusterClient(node_store=MemoryNodeStore(["localhost:9001"]))
76+
with pytest.raises(TypeError, match="ClusterClient"):
77+
copy.copy(cluster)
78+
79+
80+
class TestDqliteProtocolPickleGuard:
81+
def test_pickle_raises(self) -> None:
82+
import asyncio
83+
from unittest.mock import MagicMock
84+
85+
from dqliteclient.protocol import DqliteProtocol
86+
87+
reader = MagicMock(spec=asyncio.StreamReader)
88+
writer = MagicMock(spec=asyncio.StreamWriter)
89+
proto = DqliteProtocol(reader=reader, writer=writer)
90+
with pytest.raises(TypeError, match="DqliteProtocol"):
91+
pickle.dumps(proto)

0 commit comments

Comments
 (0)