Skip to content

Commit a59c96a

Browse files
authored
[PR #9741/acc2912 backport][3.11] Fix race getting an unused port when there are multiple test runners (#9744)
1 parent ffcf886 commit a59c96a

File tree

5 files changed

+127
-70
lines changed

5 files changed

+127
-70
lines changed

tests/conftest.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from aiohttp.client_proto import ResponseHandler
1717
from aiohttp.http import WS_KEY
18-
from aiohttp.test_utils import loop_context
18+
from aiohttp.test_utils import get_unused_port_socket, loop_context
1919

2020
try:
2121
import trustme
@@ -249,3 +249,18 @@ def enable_cleanup_closed() -> Generator[None, None, None]:
249249
"""
250250
with mock.patch("aiohttp.connector.NEEDS_CLEANUP_CLOSED", True):
251251
yield
252+
253+
254+
@pytest.fixture
255+
def unused_port_socket() -> Generator[socket.socket, None, None]:
256+
"""Return a socket that is unused on the current host.
257+
258+
Unlike aiohttp_used_port, the socket is yielded so there is no
259+
race condition between checking if the port is in use and
260+
binding to it later in the test.
261+
"""
262+
s = get_unused_port_socket("127.0.0.1")
263+
try:
264+
yield s
265+
finally:
266+
s.close()

tests/test_run_app.py

Lines changed: 85 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@
99
import subprocess
1010
import sys
1111
import time
12-
from typing import AsyncIterator, Callable, NoReturn, Set
12+
from typing import (
13+
AsyncIterator,
14+
Awaitable,
15+
Callable,
16+
Coroutine,
17+
NoReturn,
18+
Optional,
19+
Set,
20+
Tuple,
21+
)
1322
from unittest import mock
1423
from uuid import uuid4
1524

@@ -440,8 +449,10 @@ def test_run_app_https(patched_loop) -> None:
440449
)
441450

442451

443-
def test_run_app_nondefault_host_port(patched_loop, aiohttp_unused_port) -> None:
444-
port = aiohttp_unused_port()
452+
def test_run_app_nondefault_host_port(
453+
patched_loop: asyncio.AbstractEventLoop, unused_port_socket: socket.socket
454+
) -> None:
455+
port = unused_port_socket.getsockname()[1]
445456
host = "127.0.0.1"
446457

447458
app = web.Application()
@@ -454,7 +465,24 @@ def test_run_app_nondefault_host_port(patched_loop, aiohttp_unused_port) -> None
454465
)
455466

456467

457-
def test_run_app_multiple_hosts(patched_loop) -> None:
468+
def test_run_app_with_sock(
469+
patched_loop: asyncio.AbstractEventLoop, unused_port_socket: socket.socket
470+
) -> None:
471+
sock = unused_port_socket
472+
app = web.Application()
473+
web.run_app(
474+
app,
475+
sock=sock,
476+
print=stopper(patched_loop),
477+
loop=patched_loop,
478+
)
479+
480+
patched_loop.create_server.assert_called_with( # type: ignore[attr-defined]
481+
mock.ANY, sock=sock, ssl=None, backlog=128
482+
)
483+
484+
485+
def test_run_app_multiple_hosts(patched_loop: asyncio.AbstractEventLoop) -> None:
458486
hosts = ("127.0.0.1", "127.0.0.2")
459487

460488
app = web.Application()
@@ -931,8 +959,16 @@ async def stop(self, request: web.Request) -> web.Response:
931959
asyncio.get_running_loop().call_soon(self.raiser)
932960
return web.Response()
933961

934-
def run_app(self, port: int, timeout: int, task, extra_test=None) -> asyncio.Task:
962+
def run_app(
963+
self,
964+
sock: socket.socket,
965+
timeout: int,
966+
task: Callable[[], Coroutine[None, None, None]],
967+
extra_test: Optional[Callable[[ClientSession], Awaitable[None]]] = None,
968+
) -> Tuple["asyncio.Task[None]", int]:
935969
num_connections = -1
970+
t = test_task = None
971+
port = sock.getsockname()[1]
936972

937973
class DictRecordClear(dict):
938974
def clear(self):
@@ -956,15 +992,15 @@ async def test() -> None:
956992
try:
957993
with pytest.raises(asyncio.TimeoutError):
958994
async with sess.get(
959-
f"http://localhost:{port}/",
995+
f"http://127.0.0.1:{port}/",
960996
timeout=ClientTimeout(total=0.1),
961997
):
962998
pass
963999
except ClientConnectorError:
9641000
await asyncio.sleep(0.5)
9651001
else:
9661002
break
967-
async with sess.get(f"http://localhost:{port}/stop"):
1003+
async with sess.get(f"http://127.0.0.1:{port}/stop"):
9681004
pass
9691005

9701006
if extra_test:
@@ -989,50 +1025,46 @@ async def handler(request: web.Request) -> web.Response:
9891025
app.router.add_get("/stop", self.stop)
9901026

9911027
with mock.patch("aiohttp.web_app.Server", ServerWithRecordClear):
992-
web.run_app(app, port=port, shutdown_timeout=timeout)
1028+
web.run_app(app, sock=sock, shutdown_timeout=timeout)
9931029
assert test_task.exception() is None
9941030
return t, num_connections
9951031

996-
def test_shutdown_wait_for_handler(
997-
self, aiohttp_unused_port: Callable[[], int]
998-
) -> None:
999-
port = aiohttp_unused_port()
1032+
def test_shutdown_wait_for_handler(self, unused_port_socket: socket.socket) -> None:
1033+
sock = unused_port_socket
10001034
finished = False
10011035

10021036
async def task():
10031037
nonlocal finished
10041038
await asyncio.sleep(2)
10051039
finished = True
10061040

1007-
t, connection_count = self.run_app(port, 3, task)
1041+
t, connection_count = self.run_app(sock, 3, task)
10081042

10091043
assert finished is True
10101044
assert t.done()
10111045
assert not t.cancelled()
10121046
assert connection_count == 0
10131047

1014-
def test_shutdown_timeout_handler(
1015-
self, aiohttp_unused_port: Callable[[], int]
1016-
) -> None:
1017-
port = aiohttp_unused_port()
1048+
def test_shutdown_timeout_handler(self, unused_port_socket: socket.socket) -> None:
1049+
sock = unused_port_socket
10181050
finished = False
10191051

10201052
async def task():
10211053
nonlocal finished
10221054
await asyncio.sleep(2)
10231055
finished = True
10241056

1025-
t, connection_count = self.run_app(port, 1, task)
1057+
t, connection_count = self.run_app(sock, 1, task)
10261058

10271059
assert finished is False
10281060
assert t.done()
10291061
assert t.cancelled()
10301062
assert connection_count == 1
10311063

10321064
def test_shutdown_timeout_not_reached(
1033-
self, aiohttp_unused_port: Callable[[], int]
1065+
self, unused_port_socket: socket.socket
10341066
) -> None:
1035-
port = aiohttp_unused_port()
1067+
sock = unused_port_socket
10361068
finished = False
10371069

10381070
async def task():
@@ -1041,7 +1073,8 @@ async def task():
10411073
finished = True
10421074

10431075
start_time = time.time()
1044-
t, connection_count = self.run_app(port, 15, task)
1076+
1077+
t, connection_count = self.run_app(sock, 15, task)
10451078

10461079
assert finished is True
10471080
assert t.done()
@@ -1050,9 +1083,10 @@ async def task():
10501083
assert time.time() - start_time < 10
10511084

10521085
def test_shutdown_new_conn_rejected(
1053-
self, aiohttp_unused_port: Callable[[], int]
1086+
self, unused_port_socket: socket.socket
10541087
) -> None:
1055-
port = aiohttp_unused_port()
1088+
sock = unused_port_socket
1089+
port = sock.getsockname()[1]
10561090
finished = False
10571091

10581092
async def task() -> None:
@@ -1066,33 +1100,34 @@ async def test(sess: ClientSession) -> None:
10661100
with pytest.raises(ClientConnectorError):
10671101
# Use a new session to try and open a new connection.
10681102
async with ClientSession() as sess:
1069-
async with sess.get(f"http://localhost:{port}/"):
1103+
async with sess.get(f"http://127.0.0.1:{port}/"):
10701104
pass
10711105
assert finished is False
10721106

1073-
t, connection_count = self.run_app(port, 10, task, test)
1107+
t, connection_count = self.run_app(sock, 10, task, test)
10741108

10751109
assert finished is True
10761110
assert t.done()
10771111
assert connection_count == 0
10781112

10791113
def test_shutdown_pending_handler_responds(
1080-
self, aiohttp_unused_port: Callable[[], int]
1114+
self, unused_port_socket: socket.socket
10811115
) -> None:
1082-
port = aiohttp_unused_port()
1116+
sock = unused_port_socket
1117+
port = sock.getsockname()[1]
10831118
finished = False
10841119

10851120
async def test() -> None:
1086-
async def test_resp(sess):
1087-
async with sess.get(f"http://localhost:{port}/") as resp:
1121+
async def test_resp(sess: ClientSession) -> None:
1122+
async with sess.get(f"http://127.0.0.1:{port}/") as resp:
10881123
assert await resp.text() == "FOO"
10891124

10901125
await asyncio.sleep(1)
10911126
async with ClientSession() as sess:
10921127
t = asyncio.create_task(test_resp(sess))
10931128
await asyncio.sleep(1)
10941129
# Handler is in-progress while we trigger server shutdown.
1095-
async with sess.get(f"http://localhost:{port}/stop"):
1130+
async with sess.get(f"http://127.0.0.1:{port}/stop"):
10961131
pass
10971132

10981133
assert finished is False
@@ -1117,19 +1152,22 @@ async def handler(request: web.Request) -> web.Response:
11171152
app.router.add_get("/", handler)
11181153
app.router.add_get("/stop", self.stop)
11191154

1120-
web.run_app(app, port=port, shutdown_timeout=5)
1155+
web.run_app(app, sock=sock, shutdown_timeout=5)
1156+
assert t is not None
11211157
assert t.exception() is None
11221158
assert finished is True
11231159

11241160
def test_shutdown_close_idle_keepalive(
1125-
self, aiohttp_unused_port: Callable[[], int]
1161+
self, unused_port_socket: socket.socket
11261162
) -> None:
1127-
port = aiohttp_unused_port()
1163+
sock = unused_port_socket
1164+
port = sock.getsockname()[1]
1165+
t = None
11281166

11291167
async def test() -> None:
11301168
await asyncio.sleep(1)
11311169
async with ClientSession() as sess:
1132-
async with sess.get(f"http://localhost:{port}/stop"):
1170+
async with sess.get(f"http://127.0.0.1:{port}/stop"):
11331171
pass
11341172

11351173
# Hold on to keep-alive connection.
@@ -1148,15 +1186,14 @@ async def run_test(app: web.Application) -> None:
11481186
app.cleanup_ctx.append(run_test)
11491187
app.router.add_get("/stop", self.stop)
11501188

1151-
web.run_app(app, port=port, shutdown_timeout=10)
1189+
web.run_app(app, sock=sock, shutdown_timeout=10)
11521190
# If connection closed, then test() will be cancelled in cleanup_ctx.
11531191
# If not, then shutdown_timeout will allow it to sleep until complete.
11541192
assert t.cancelled()
11551193

1156-
def test_shutdown_close_websockets(
1157-
self, aiohttp_unused_port: Callable[[], int]
1158-
) -> None:
1159-
port = aiohttp_unused_port()
1194+
def test_shutdown_close_websockets(self, unused_port_socket: socket.socket) -> None:
1195+
sock = unused_port_socket
1196+
port = sock.getsockname()[1]
11601197
WS = web.AppKey("ws", Set[web.WebSocketResponse])
11611198
client_finished = server_finished = False
11621199

@@ -1177,8 +1214,8 @@ async def close_websockets(app: web.Application) -> None:
11771214
async def test() -> None:
11781215
await asyncio.sleep(1)
11791216
async with ClientSession() as sess:
1180-
async with sess.ws_connect(f"http://localhost:{port}/ws") as ws:
1181-
async with sess.get(f"http://localhost:{port}/stop"):
1217+
async with sess.ws_connect(f"http://127.0.0.1:{port}/ws") as ws:
1218+
async with sess.get(f"http://127.0.0.1:{port}/stop"):
11821219
pass
11831220

11841221
async for msg in ws:
@@ -1203,22 +1240,23 @@ async def run_test(app: web.Application) -> None:
12031240
app.router.add_get("/stop", self.stop)
12041241

12051242
start = time.time()
1206-
web.run_app(app, port=port, shutdown_timeout=10)
1243+
web.run_app(app, sock=sock, shutdown_timeout=10)
12071244
assert time.time() - start < 5
12081245
assert client_finished
12091246
assert server_finished
12101247

12111248
def test_shutdown_handler_cancellation_suppressed(
1212-
self, aiohttp_unused_port: Callable[[], int]
1249+
self, unused_port_socket: socket.socket
12131250
) -> None:
1214-
port = aiohttp_unused_port()
1251+
sock = unused_port_socket
1252+
port = sock.getsockname()[1]
12151253
actions = []
12161254

12171255
async def test() -> None:
12181256
async def test_resp(sess):
12191257
t = ClientTimeout(total=0.4)
12201258
with pytest.raises(asyncio.TimeoutError):
1221-
async with sess.get(f"http://localhost:{port}/", timeout=t) as resp:
1259+
async with sess.get(f"http://127.0.0.1:{port}/", timeout=t) as resp:
12221260
assert await resp.text() == "FOO"
12231261
actions.append("CANCELLED")
12241262

@@ -1227,7 +1265,7 @@ async def test_resp(sess):
12271265
await asyncio.sleep(0.5)
12281266
# Handler is in-progress while we trigger server shutdown.
12291267
actions.append("PRESTOP")
1230-
async with sess.get(f"http://localhost:{port}/stop"):
1268+
async with sess.get(f"http://127.0.0.1:{port}/stop"):
12311269
pass
12321270

12331271
actions.append("STOPPING")
@@ -1255,6 +1293,6 @@ async def handler(request: web.Request) -> web.Response:
12551293
app.router.add_get("/", handler)
12561294
app.router.add_get("/stop", self.stop)
12571295

1258-
web.run_app(app, port=port, shutdown_timeout=2, handler_cancellation=True)
1296+
web.run_app(app, sock=sock, shutdown_timeout=2, handler_cancellation=True)
12591297
assert t.exception() is None
12601298
assert actions == ["CANCELLED", "SUPPRESSED", "PRESTOP", "STOPPING", "DONE"]

tests/test_test_utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,16 @@ async def test_client_context_manager_response(method, app, loop) -> None:
375375
assert "Hello, world" in text
376376

377377

378-
async def test_custom_port(loop, app, aiohttp_unused_port) -> None:
379-
port = aiohttp_unused_port()
380-
client = _TestClient(TestServer(app, loop=loop, port=port), loop=loop)
378+
async def test_custom_port(
379+
loop: asyncio.AbstractEventLoop,
380+
app: web.Application,
381+
unused_port_socket: socket,
382+
) -> None:
383+
sock = unused_port_socket
384+
port = sock.getsockname()[1]
385+
client = _TestClient(
386+
TestServer(app, port=port, socket_factory=lambda *args, **kwargs: sock)
387+
)
381388
await client.start_server()
382389

383390
assert client.server.port == port

0 commit comments

Comments
 (0)