Skip to content

Commit 4c5aff5

Browse files
committed
PYTHON-3449 Add regression tests for PyByteArray buffer implementation
Tests cover the memory safety concerns identified during the spike: resize+backfill correctness, sequential encode correctness, concurrent encoding, public API return types, is_valid() bytearray acceptance, and error-path memory behaviour.
1 parent 4b69f1a commit 4c5aff5

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

test/test_bson_buffer.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Regression tests for PYTHON-3449: buffer.c rewritten in terms of PyByteArray.
2+
3+
Each test maps to a specific memory safety concern identified during the spike.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import threading
9+
import tracemalloc
10+
11+
import pytest
12+
13+
import bson
14+
15+
pytestmark = pytest.mark.default
16+
from bson import DEFAULT_CODEC_OPTIONS, _dict_to_bson
17+
18+
19+
class TestResizeWithNestedBackfill:
20+
"""Resize + backfill correctness (stale-pointer hazard).
21+
22+
Encodes a deeply nested document that forces multiple buffer growths and
23+
exercises the save_space + backfill pattern (the document length field is
24+
written at a saved offset *after* all child elements are encoded). If the
25+
PyByteArray pointer were stale after a resize, the backfilled length would
26+
be wrong and decode() would raise or return corrupt data.
27+
"""
28+
29+
def test_deep_nesting_round_trip(self):
30+
# ~300 bytes per level forces a resize before the outer doc length is backfilled
31+
doc: dict = {"a" * 50: "b" * 250}
32+
for _ in range(20):
33+
doc = {"nested": doc}
34+
encoded = bson.encode(doc)
35+
assert bson.decode(encoded) == doc
36+
37+
def test_wide_document_round_trip(self):
38+
# Many keys causes many save_space + backfill cycles
39+
doc = {str(i): "x" * 100 for i in range(100)}
40+
encoded = bson.encode(doc)
41+
assert bson.decode(encoded) == doc
42+
43+
44+
class TestSequentialEncodesNoCorruption:
45+
"""Many sequential encodes (regression for use-after-free / double-free).
46+
47+
Allocator reuse of freed memory would surface as a corrupt decode if a
48+
use-after-free existed in pymongo_buffer_finish or pymongo_buffer_free.
49+
"""
50+
51+
def test_varying_sizes(self):
52+
docs = [{"i": i, "data": "x" * (i % 500)} for i in range(2000)]
53+
results = [bson.encode(d) for d in docs]
54+
for doc, enc in zip(docs, results):
55+
assert bson.decode(enc) == doc
56+
57+
58+
class TestConcurrentEncoding:
59+
"""Concurrent encoding (free-threaded Python / Py_BEGIN_CRITICAL_SECTION).
60+
61+
Each thread owns its own buffer, so a race would show up as a corrupt
62+
result rather than a crash/deadlock.
63+
"""
64+
65+
def test_concurrent_threads(self):
66+
doc = {"k": "v" * 100, "nested": {"a": list(range(50))}}
67+
expected = bson.encode(doc)
68+
errors: list[bytes] = []
69+
70+
def encode_and_check():
71+
for _ in range(500):
72+
result = bson.encode(doc)
73+
if result != expected:
74+
errors.append(result)
75+
76+
threads = [threading.Thread(target=encode_and_check) for _ in range(8)]
77+
for t in threads:
78+
t.start()
79+
for t in threads:
80+
t.join()
81+
assert not errors, f"Got {len(errors)} corrupt result(s)"
82+
83+
84+
class TestPublicAPIReturnTypes:
85+
"""bson.encode() must return bytes; _dict_to_bson() must return bytearray.
86+
87+
Guards the bytes() wrapper in bson/__init__.py from being accidentally
88+
removed, and confirms the internal function exposes bytearray.
89+
"""
90+
91+
def test_encode_returns_bytes(self):
92+
result = bson.encode({"x": 1})
93+
assert type(result) is bytes, f"expected bytes, got {type(result)}"
94+
95+
def test_dict_to_bson_returns_bytearray(self):
96+
result = _dict_to_bson({"x": 1}, False, DEFAULT_CODEC_OPTIONS)
97+
assert type(result) is bytearray, f"expected bytearray, got {type(result)}" # type: ignore[comparison-overlap]
98+
99+
def test_encode_and_dict_to_bson_agree(self):
100+
doc = {"a": 1, "b": "hello"}
101+
assert bson.encode(doc) == bytes(_dict_to_bson(doc, False, DEFAULT_CODEC_OPTIONS))
102+
103+
104+
class TestIsValidAcceptsBytearray:
105+
"""bson.is_valid() must accept bytearray after the isinstance fix."""
106+
107+
def test_is_valid_accepts_bytearray(self):
108+
ba = _dict_to_bson({"x": 1}, False, DEFAULT_CODEC_OPTIONS)
109+
assert isinstance(ba, bytearray)
110+
assert bson.is_valid(ba)
111+
112+
def test_is_valid_rejects_non_bytes(self):
113+
with pytest.raises(TypeError):
114+
bson.is_valid("not bytes") # type: ignore[arg-type]
115+
116+
117+
class TestEncodeFailureNoLeak:
118+
"""Encoding failures must not leak memory (error-path safety).
119+
120+
Peak memory must stay bounded after repeated failures. A growing peak
121+
indicates a leak in the C buffer error path.
122+
"""
123+
124+
def test_no_leak_on_repeated_failures(self):
125+
tracemalloc.start()
126+
for _ in range(1000):
127+
with pytest.raises(Exception): # noqa: B017
128+
bson.encode({1: "non-string key"}) # type: ignore[arg-type,dict-item]
129+
_, peak = tracemalloc.get_traced_memory()
130+
tracemalloc.stop()
131+
assert peak < 5 * 1024 * 1024, f"peak memory {peak} bytes exceeds 5 MiB"

0 commit comments

Comments
 (0)