Skip to content

Commit ed6991a

Browse files
authored
PYTHON-5781 Coverage increase for network_layer.py (#2774)
1 parent ed81f02 commit ed6991a

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Copyright 2026-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Async-only unit tests for network_layer.py."""
16+
17+
from __future__ import annotations
18+
19+
import asyncio
20+
import sys
21+
from unittest.mock import AsyncMock, MagicMock, patch
22+
23+
sys.path[0:0] = [""]
24+
25+
from pymongo.common import MAX_MESSAGE_SIZE
26+
from pymongo.errors import ProtocolError
27+
from pymongo.network_layer import PyMongoProtocol, _async_socket_receive
28+
from test.asynchronous import AsyncUnitTest, unittest
29+
from test.utils_shared import pack_msg_header
30+
31+
32+
def _make_protocol(timeout=None):
33+
protocol = PyMongoProtocol(timeout=timeout)
34+
mock_transport = MagicMock()
35+
mock_transport.is_closing.return_value = False
36+
protocol.transport = mock_transport
37+
return protocol
38+
39+
40+
class TestProcessHeader(AsyncUnitTest):
41+
async def asyncSetUp(self):
42+
self.protocol = _make_protocol()
43+
44+
def test_op_msg_returns_body_len_and_op_code(self):
45+
self.protocol._header = memoryview(
46+
bytearray(pack_msg_header(length=32, request_id=1, response_to=99, op_code=2013))
47+
)
48+
body_len, op_code, response_to, expecting_compression = self.protocol.process_header()
49+
self.assertEqual(body_len, 16)
50+
self.assertEqual(op_code, 2013)
51+
self.assertEqual(response_to, 99)
52+
self.assertFalse(expecting_compression)
53+
54+
def test_op_compressed_sets_expecting_compression(self):
55+
# OP_COMPRESSED=2012; process_header strips the 9-byte compression sub-header
56+
# (op code + uncompressed size + compressor id), then the 16-byte standard header.
57+
# length=35 → after compression sub-header: 26 → body: 10
58+
self.protocol._header = memoryview(
59+
bytearray(pack_msg_header(length=35, request_id=1, response_to=0, op_code=2012))
60+
)
61+
body_len, op_code, _response_to, expecting_compression = self.protocol.process_header()
62+
self.assertEqual(body_len, 10)
63+
self.assertEqual(op_code, 2012)
64+
self.assertTrue(expecting_compression)
65+
66+
def test_op_compressed_length_too_small_raises(self):
67+
self.protocol._header = memoryview(
68+
bytearray(pack_msg_header(length=25, request_id=1, response_to=0, op_code=2012))
69+
)
70+
with self.assertRaisesRegex(ProtocolError, "not longer than standard OP_COMPRESSED"):
71+
self.protocol.process_header()
72+
73+
def test_non_compressed_length_too_small_raises(self):
74+
self.protocol._header = memoryview(
75+
bytearray(pack_msg_header(length=16, request_id=1, response_to=0, op_code=2013))
76+
)
77+
with self.assertRaisesRegex(ProtocolError, "not longer than standard message header size"):
78+
self.protocol.process_header()
79+
80+
def test_length_exceeds_max_raises(self):
81+
self.protocol._header = memoryview(
82+
bytearray(
83+
pack_msg_header(
84+
length=MAX_MESSAGE_SIZE + 1, request_id=1, response_to=0, op_code=2013
85+
)
86+
)
87+
)
88+
with self.assertRaisesRegex(ProtocolError, "larger than server max"):
89+
self.protocol.process_header()
90+
91+
92+
class TestClose(AsyncUnitTest):
93+
async def asyncSetUp(self):
94+
self.protocol = _make_protocol()
95+
96+
def test_close_aborts_transport(self):
97+
self.protocol.close()
98+
self.assertTrue(self.protocol.transport.abort.called)
99+
100+
async def test_close_propagates_exception_to_pending_read(self):
101+
read_task = asyncio.create_task(
102+
self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE)
103+
)
104+
await asyncio.sleep(0)
105+
self.protocol.close(OSError("connection reset"))
106+
with self.assertRaisesRegex(OSError, "connection reset"):
107+
await read_task
108+
109+
110+
class TestBufferUpdated(AsyncUnitTest):
111+
async def asyncSetUp(self):
112+
self.protocol = _make_protocol()
113+
114+
async def test_zero_bytes_closes_connection(self):
115+
# A zero-byte buffer_updated (connection closed mid-read) must surface to
116+
# a waiting reader as OSError("connection closed"), not just abort the transport.
117+
read_task = asyncio.create_task(
118+
self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE)
119+
)
120+
await asyncio.sleep(0)
121+
self.protocol.buffer_updated(0)
122+
self.assertTrue(self.protocol.transport.abort.called)
123+
with self.assertRaisesRegex(OSError, "connection closed"):
124+
await read_task
125+
126+
async def test_protocol_error_closes_connection(self):
127+
# A malformed header must surface to a waiting reader as a ProtocolError,
128+
# not just abort the transport.
129+
read_task = asyncio.create_task(
130+
self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE)
131+
)
132+
await asyncio.sleep(0)
133+
buf = self.protocol.get_buffer(16)
134+
buf[:16] = pack_msg_header(length=16, request_id=1, response_to=0, op_code=2013)
135+
self.protocol.buffer_updated(16)
136+
self.assertTrue(self.protocol.transport.abort.called)
137+
with self.assertRaisesRegex(ProtocolError, "not longer than standard message header"):
138+
await read_task
139+
140+
async def test_resolves_pending_read(self):
141+
read_task = asyncio.create_task(
142+
self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE)
143+
)
144+
await asyncio.sleep(0)
145+
146+
# Feed a valid 32-byte OP_MSG header (16-byte header + 16-byte body).
147+
header = pack_msg_header(length=32, request_id=1, response_to=99, op_code=2013)
148+
buf = self.protocol.get_buffer(16)
149+
buf[:16] = header
150+
self.protocol.buffer_updated(16)
151+
152+
self.assertFalse(self.protocol._expecting_header)
153+
self.assertEqual(self.protocol._message_size, 16)
154+
155+
# Feed the 16-byte body.
156+
buf = self.protocol.get_buffer(16)
157+
buf[:16] = b"x" * 16
158+
self.protocol.buffer_updated(16)
159+
160+
_data, op_code = await read_task
161+
self.assertEqual(op_code, 2013)
162+
163+
164+
class TestAsyncSocketReceive(AsyncUnitTest):
165+
async def test_raises_on_connection_closed(self):
166+
# Covers the explicit `raise OSError("connection closed")` branch when
167+
# sock_recv_into returns 0.
168+
mock_socket = MagicMock()
169+
loop = asyncio.get_running_loop()
170+
171+
with patch.object(loop, "sock_recv_into", new=AsyncMock(return_value=0)):
172+
with self.assertRaisesRegex(OSError, "connection closed"):
173+
await _async_socket_receive(mock_socket, 10, loop)
174+
175+
176+
if __name__ == "__main__":
177+
unittest.main()

test/test_network_layer.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright 2026-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Sync-only unit tests for network_layer.py.
16+
17+
These cover ``receive_message`` and ``receive_data``, which only exist on the
18+
synchronous receive path (the async path uses ``PyMongoProtocol`` instead).
19+
The async-only tests live in ``test/asynchronous/test_async_network_layer.py``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import sys
25+
from unittest.mock import MagicMock, patch
26+
27+
sys.path[0:0] = [""]
28+
29+
from pymongo import network_layer
30+
from pymongo.common import MAX_MESSAGE_SIZE
31+
from pymongo.errors import ProtocolError
32+
from test import UnitTest, unittest
33+
from test.utils_shared import pack_msg_header
34+
35+
36+
def _make_conn():
37+
conn = MagicMock()
38+
conn.conn.gettimeout.return_value = None
39+
# On PyPy/Windows, receive_data() calls wait_for_read() before recv_into().
40+
# wait_for_read() checks fileno() == -1 as an early-exit; without this mock,
41+
# sock.fileno() returns a MagicMock and sock.pending() > 0 raises TypeError.
42+
conn.conn.sock.fileno.return_value = -1
43+
return conn
44+
45+
46+
class TestReceiveMessage(UnitTest):
47+
def test_request_id_mismatch_raises(self):
48+
with patch.object(
49+
network_layer,
50+
"receive_data",
51+
return_value=pack_msg_header(length=32, request_id=0, response_to=99, op_code=2013),
52+
):
53+
with self.assertRaisesRegex(ProtocolError, "Got response id"):
54+
network_layer.receive_message(_make_conn(), request_id=1)
55+
56+
def test_length_too_small_raises(self):
57+
with patch.object(
58+
network_layer,
59+
"receive_data",
60+
return_value=pack_msg_header(length=16, request_id=0, response_to=0, op_code=2013),
61+
):
62+
with self.assertRaisesRegex(ProtocolError, "not longer than standard message header"):
63+
network_layer.receive_message(_make_conn(), request_id=None)
64+
65+
def test_length_exceeds_max_raises(self):
66+
with patch.object(
67+
network_layer,
68+
"receive_data",
69+
return_value=pack_msg_header(
70+
length=MAX_MESSAGE_SIZE + 1, request_id=0, response_to=0, op_code=2013
71+
),
72+
):
73+
with self.assertRaisesRegex(ProtocolError, "larger than server max"):
74+
network_layer.receive_message(_make_conn(), request_id=None)
75+
76+
def test_unknown_opcode_raises(self):
77+
with patch.object(
78+
network_layer,
79+
"receive_data",
80+
side_effect=[
81+
pack_msg_header(length=20, request_id=0, response_to=0, op_code=9999),
82+
b"data",
83+
],
84+
):
85+
with self.assertRaisesRegex(ProtocolError, "Got opcode"):
86+
network_layer.receive_message(_make_conn(), request_id=None)
87+
88+
89+
class TestReceiveData(UnitTest):
90+
def test_raises_on_connection_closed(self):
91+
# Covers the explicit `raise OSError("connection closed")` branch when
92+
# recv_into returns 0.
93+
conn = _make_conn()
94+
conn.conn.recv_into.return_value = 0
95+
with self.assertRaisesRegex(OSError, "connection closed"):
96+
network_layer.receive_data(conn, 10, deadline=None)
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

test/utils_shared.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import random
2424
import re
2525
import shutil
26+
import struct
2627
import sys
2728
import threading
2829
import unittest
@@ -743,3 +744,13 @@ async def async_barrier_wait(barrier, timeout: float | None = None):
743744

744745
def barrier_wait(barrier, timeout: float | None = None):
745746
barrier.wait(timeout=timeout)
747+
748+
749+
def pack_msg_header(length: int, request_id: int, response_to: int, op_code: int) -> bytes:
750+
"""Pack a MongoDB wire-protocol message header (``<iiii``: length,
751+
request_id, response_to, op_code).
752+
753+
Lets tests set an arbitrary ``length`` (including invalid values), which
754+
production header-packing never does.
755+
"""
756+
return struct.pack("<iiii", length, request_id, response_to, op_code)

tools/synchro.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def async_only_test(f: Path) -> bool:
189189
"test_async_loop_safety.py",
190190
"test_async_contextvars_reset.py",
191191
"test_async_loop_unblocked.py",
192+
"test_async_network_layer.py",
192193
]
193194

194195

0 commit comments

Comments
 (0)