Skip to content

Commit 32924e8

Browse files
Stabilize test suite: fix flaky timing tests, and handle Windows resource leaks (#11992)
1 parent 100e5ed commit 32924e8

File tree

8 files changed

+68
-45
lines changed

8 files changed

+68
-45
lines changed

CHANGES/11992.contrib.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed flaky performance tests by using appropriate fixed thresholds that account for CI variability -- by :user:`rodrigobnogueira`.

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ Raúl Cumplido
318318
Required Field
319319
Robert Lu
320320
Robert Nikolich
321+
Rodrigo Nogueira
321322
Roman Markeloff
322323
Roman Podoliaka
323324
Roman Postnov

tests/conftest.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@
4949
TRUSTME = False
5050

5151

52+
def pytest_configure(config: pytest.Config) -> None:
53+
# On Windows with Python 3.10/3.11, proxy.py's threaded mode can leave
54+
# sockets not fully released by the time pytest's unraisableexception
55+
# plugin collects warnings during teardown. Suppress these warnings
56+
# since they are not actionable and only affect older Python versions.
57+
if os.name == "nt" and sys.version_info < (3, 12):
58+
config.addinivalue_line(
59+
"filterwarnings",
60+
"ignore:Exception ignored in.*socket.*:pytest.PytestUnraisableExceptionWarning",
61+
)
62+
63+
5264
try:
5365
if sys.platform == "win32":
5466
import winloop as uvloop
@@ -431,13 +443,14 @@ async def make_client_request(
431443
loop: asyncio.AbstractEventLoop,
432444
) -> AsyncIterator[Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest]]:
433445
"""Fixture to help creating test ClientRequest objects with defaults."""
434-
request = session = None
446+
requests: list[ClientRequest] = []
447+
sessions: list[ClientSession] = []
435448

436449
def maker(
437450
method: str, url: URL, **kwargs: Unpack[ClientRequestArgs]
438451
) -> ClientRequest:
439-
nonlocal request, session
440452
session = ClientSession()
453+
sessions.append(session)
441454
default_args: ClientRequestArgs = {
442455
"loop": loop,
443456
"params": {},
@@ -462,14 +475,15 @@ def maker(
462475
"server_hostname": None,
463476
}
464477
request = ClientRequest(method, url, **(default_args | kwargs))
478+
requests.append(request)
465479
return request
466480

467481
yield maker
468482

469-
if request is not None:
470-
await request._close()
471-
assert session is not None
472-
await session.close()
483+
await asyncio.gather(
484+
*(request._close() for request in requests),
485+
*(session.close() for session in sessions),
486+
)
473487

474488

475489
@pytest.fixture

tests/test_client_middleware_digest_auth.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,12 +1332,18 @@ async def handler(request: Request) -> Response:
13321332

13331333

13341334
def test_regex_performance() -> None:
1335+
"""Test that the regex pattern doesn't suffer from ReDoS issues."""
1336+
REGEX_TIME_THRESHOLD_SECONDS = 0.08
13351337
value = "0" * 54773 + "\\0=a"
1338+
13361339
start = time.perf_counter()
13371340
matches = _HEADER_PAIRS_PATTERN.findall(value)
1338-
end = time.perf_counter()
1341+
elapsed = time.perf_counter() - start
13391342

1340-
# If this is taking more than 10ms, there's probably a performance/ReDoS issue.
1341-
assert (end - start) < 0.01
1342-
# This example probably shouldn't produce a match either.
1343+
# If this is taking more time, there's probably a performance/ReDoS issue.
1344+
assert elapsed < REGEX_TIME_THRESHOLD_SECONDS, (
1345+
f"Regex took {elapsed * 1000:.1f}ms, "
1346+
f"expected <{REGEX_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue"
1347+
)
1348+
# This example shouldn't produce a match either.
13431349
assert not matches

tests/test_cookie_helpers.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -638,13 +638,18 @@ def test_cookie_pattern_matches_partitioned_attribute(test_string: str) -> None:
638638

639639

640640
def test_cookie_pattern_performance() -> None:
641+
"""Test that the cookie pattern doesn't suffer from ReDoS issues."""
642+
COOKIE_PATTERN_TIME_THRESHOLD_SECONDS = 0.08
641643
value = "a" + "=" * 21651 + "\x00"
642644
start = time.perf_counter()
643645
match = helpers._COOKIE_PATTERN.match(value)
644-
end = time.perf_counter()
646+
elapsed = time.perf_counter() - start
645647

646-
# If this is taking more than 10ms, there's probably a performance/ReDoS issue.
647-
assert (end - start) < 0.01
648+
# If this is taking more time, there's probably a performance/ReDoS issue.
649+
assert elapsed < COOKIE_PATTERN_TIME_THRESHOLD_SECONDS, (
650+
f"Pattern took {elapsed * 1000:.1f}ms, "
651+
f"expected <{COOKIE_PATTERN_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue"
652+
)
648653
# This example shouldn't produce a match either.
649654
assert match is None
650655

tests/test_imports.py

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,6 @@ def test_web___all__(pytester: pytest.Pytester) -> None:
2828
result.assert_outcomes(passed=0, errors=0)
2929

3030

31-
_IS_CI_ENV = os.getenv("CI") == "true"
32-
_XDIST_WORKER_COUNT = int(os.getenv("PYTEST_XDIST_WORKER_COUNT", 0))
33-
_IS_XDIST_RUN = _XDIST_WORKER_COUNT > 1
34-
35-
_TARGET_TIMINGS_BY_PYTHON_VERSION = {
36-
"3.12": (
37-
# 3.12+ is expected to be a bit slower due to performance trade-offs,
38-
# and even slower under pytest-xdist, especially in CI
39-
_XDIST_WORKER_COUNT * 100 * (1 if _IS_CI_ENV else 1.53)
40-
if _IS_XDIST_RUN
41-
else 295
42-
),
43-
}
44-
_TARGET_TIMINGS_BY_PYTHON_VERSION["3.13"] = _TARGET_TIMINGS_BY_PYTHON_VERSION["3.12"]
45-
46-
4731
@pytest.mark.internal
4832
@pytest.mark.dev_mode
4933
@pytest.mark.skipif(
@@ -56,7 +40,10 @@ def test_import_time(pytester: pytest.Pytester) -> None:
5640
Obviously, the time may vary on different machines and may need to be adjusted
5741
from time to time, but this should provide an early warning if something is
5842
added that significantly increases import time.
43+
44+
Runs 3 times and keeps the minimum time to reduce flakiness.
5945
"""
46+
IMPORT_TIME_THRESHOLD_MS = 300 if sys.version_info >= (3, 12) else 200
6047
root = Path(__file__).parent.parent
6148
old_path = os.environ.get("PYTHONPATH")
6249
os.environ["PYTHONPATH"] = os.pathsep.join([str(root)] + sys.path)
@@ -66,18 +53,12 @@ def test_import_time(pytester: pytest.Pytester) -> None:
6653
try:
6754
for _ in range(3):
6855
r = pytester.run(sys.executable, "-We", "-c", cmd)
69-
70-
assert not r.stderr.str()
71-
runtime_ms = int(r.stdout.str())
72-
if runtime_ms < best_time_ms:
73-
best_time_ms = runtime_ms
56+
assert not r.stderr.str(), r.stderr.str()
57+
best_time_ms = min(best_time_ms, int(r.stdout.str()))
7458
finally:
7559
if old_path is None:
7660
os.environ.pop("PYTHONPATH")
7761
else:
7862
os.environ["PYTHONPATH"] = old_path
7963

80-
expected_time = _TARGET_TIMINGS_BY_PYTHON_VERSION.get(
81-
f"{sys.version_info.major}.{sys.version_info.minor}", 200
82-
)
83-
assert best_time_ms < expected_time
64+
assert best_time_ms < IMPORT_TIME_THRESHOLD_MS

tests/test_proxy_functional.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from aiohttp.client import _RequestOptions
2222
from aiohttp.client_exceptions import ClientConnectionError
2323
from aiohttp.pytest_plugin import AiohttpRawServer, AiohttpServer
24+
from aiohttp.test_utils import TestServer
2425

2526
ASYNCIO_SUPPORTS_TLS_IN_TLS = sys.version_info >= (3, 11)
2627

@@ -244,24 +245,34 @@ async def test_https_proxy_unsupported_tls_in_tls(
244245
# otherwise this test will fail because the proxy will die with an error.
245246
async def test_uvloop_secure_https_proxy(
246247
client_ssl_ctx: ssl.SSLContext,
248+
ssl_ctx: ssl.SSLContext,
247249
secure_proxy_url: URL,
248250
uvloop_loop: asyncio.AbstractEventLoop,
249251
) -> None:
250252
"""Ensure HTTPS sites are accessible through a secure proxy without warning when using uvloop."""
253+
payload = str(uuid4())
254+
255+
async def handler(request: web.Request) -> web.Response:
256+
return web.Response(text=payload)
257+
258+
app = web.Application()
259+
app.router.add_route("GET", "/", handler)
260+
server = TestServer(app, host="127.0.0.1")
261+
await server.start_server(ssl=ssl_ctx)
262+
263+
url = URL.build(scheme="https", host=server.host, port=server.port)
251264
conn = aiohttp.TCPConnector(force_close=True)
252265
sess = aiohttp.ClientSession(connector=conn)
253266
try:
254-
url = URL("https://example.com")
255-
256267
async with sess.get(
257268
url, proxy=secure_proxy_url, ssl=client_ssl_ctx
258269
) as response:
259270
assert response.status == 200
260-
# Ensure response body is read to completion
261-
await response.read()
271+
assert await response.text() == payload
262272
finally:
263273
await sess.close()
264274
await conn.close()
275+
await server.close()
265276
await asyncio.sleep(0)
266277
await asyncio.sleep(0.1)
267278

tests/test_web_request.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -601,13 +601,17 @@ def test_single_forwarded_header() -> None:
601601

602602

603603
def test_forwarded_re_performance() -> None:
604+
FORWARDED_RE_TIME_THRESHOLD_SECONDS = 0.08
604605
value = "{" + "f" * 54773 + "z\x00a=v"
605606
start = time.perf_counter()
606607
match = _FORWARDED_PAIR_RE.match(value)
607-
end = time.perf_counter()
608+
elapsed = time.perf_counter() - start
608609

609-
# If this is taking more than 10ms, there's probably a performance/ReDoS issue.
610-
assert (end - start) < 0.01
610+
# If this is taking more time, there's probably a performance/ReDoS issue.
611+
assert elapsed < FORWARDED_RE_TIME_THRESHOLD_SECONDS, (
612+
f"Regex took {elapsed * 1000:.1f}ms, "
613+
f"expected <{FORWARDED_RE_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue"
614+
)
611615
# This example shouldn't produce a match either.
612616
assert match is None
613617

0 commit comments

Comments
 (0)