Skip to content

Commit cd30a1d

Browse files
committed
fix: Connection close hang on Windows
1 parent 1559f5e commit cd30a1d

5 files changed

Lines changed: 117 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
lint:
1919
name: Lint + Typecheck
2020
runs-on: ubuntu-latest
21+
timeout-minutes: 10
2122
steps:
2223
- uses: actions/checkout@v5
2324

@@ -33,6 +34,9 @@ jobs:
3334
test:
3435
name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
3536
runs-on: ${{ matrix.os }}
37+
# Backstop only. pytest-timeout is what turns a wedged test into a failure with a
38+
# thread dump; this bounds anything outside pytest, such as a dependency install.
39+
timeout-minutes: 15
3640
strategy:
3741
fail-fast: false
3842
matrix:
@@ -58,6 +62,7 @@ jobs:
5862
build:
5963
name: Build distribution
6064
runs-on: ubuntu-latest
65+
timeout-minutes: 10
6166
steps:
6267
- uses: actions/checkout@v5
6368

@@ -77,6 +82,7 @@ jobs:
7782
samples:
7883
name: Sample apps
7984
runs-on: ubuntu-latest
85+
timeout-minutes: 15
8086
steps:
8187
- uses: actions/checkout@v5
8288

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dev = [
4747
"mypy>=1.14",
4848
"pytest>=8.3",
4949
"pytest-cov>=6.0",
50+
"pytest-timeout>=2.3",
5051
"ruff>=0.9",
5152
]
5253

@@ -99,7 +100,12 @@ enable_error_code = ["redundant-expr", "possibly-undefined", "truthy-bool"]
99100

100101
[tool.pytest.ini_options]
101102
testpaths = ["tests"]
102-
addopts = "-ra --strict-markers --strict-config"
103+
# The timeout covers setup, call and teardown, and is generous next to the longest deliberate
104+
# wait in the suite (~10s). Several tests park a reader on a real socket, so a threading bug
105+
# surfaces as a suite that never finishes rather than as a failure; this turns that back into a
106+
# failure. The thread method is the one that works here: it dumps every thread's stack, and it
107+
# can end a run that is wedged on a lock held across a blocking read, which SIGALRM cannot.
108+
addopts = "-ra --strict-markers --strict-config --timeout=60 --timeout-method=thread"
103109
xfail_strict = true
104110

105111
[tool.coverage.run]

src/configdirector/_eventsource/transport.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,30 @@ def close(self) -> None:
8080
# where no reader is parked on one.
8181
with contextlib.suppress(ValueError, RuntimeError, OSError):
8282
self._response.shutdown()
83+
# shutdown() finishes the job on POSIX, where it wakes the parked recv(). Winsock only
84+
# disallows *subsequent* receives, so on Windows the reader stays in the kernel holding
85+
# the response's buffered-reader lock, and the close() below would block acquiring that
86+
# same lock -- forever, on whichever thread called close(). closesocket() is what
87+
# cancels a pending blocking call, and HTTPConnection.close() drops the socket before it
88+
# touches the buffered reader, so it has to run first.
89+
_drop_socket(self._response)
8390
self._response.close()
8491

8592

93+
def _drop_socket(response: BaseHTTPResponse) -> None:
94+
"""Closes the connection's socket, cancelling any read parked on it.
95+
96+
`_connection` is private, but urllib3 exposes no public way to reach the socket and there is
97+
no other way to cancel a blocking read on Windows. A urllib3 that moves or drops it leaves
98+
this a no-op, which is the behaviour we had before.
99+
"""
100+
connection = getattr(response, "_connection", None)
101+
if connection is None:
102+
return
103+
with contextlib.suppress(Exception):
104+
connection.close()
105+
106+
86107
def open_stream(request: StreamRequest) -> ResponseStream:
87108
response = _pool.request(
88109
request.method,

tests/eventsource/test_transport.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
import threading
55
import time
66
from collections.abc import Callable, Iterator
7+
from typing import cast
78

89
import pytest
10+
from urllib3.response import BaseHTTPResponse
911

1012
from configdirector._eventsource import (
1113
EventSourceClient,
1214
EventSourceMessage,
1315
ReadyState,
1416
ReconnectionState,
1517
)
18+
from configdirector._eventsource.transport import _Stream
1619

1720
from .helpers import wait_for
1821

@@ -266,3 +269,69 @@ def handle(request: http.server.BaseHTTPRequestHandler) -> None:
266269
client.close()
267270

268271
assert received[0].data == payload
272+
273+
274+
class TestStreamClose:
275+
"""The teardown order in `_Stream.close()`, which no live test can pin on a POSIX runner.
276+
277+
A reader parked in `recv()` holds the response's buffered-reader lock for as long as it is
278+
parked. `shutdown()` wakes it on POSIX, so the order is invisible there; Winsock leaves it
279+
parked, and closing the response then blocks on that lock forever. Only closing the socket
280+
cancels the pending read, so the connection has to go first.
281+
"""
282+
283+
class _Connection:
284+
def __init__(self, calls: list[str]) -> None:
285+
self._calls = calls
286+
287+
def close(self) -> None:
288+
self._calls.append("connection.close")
289+
290+
class _Response:
291+
status = 200
292+
293+
def __init__(self, calls: list[str], *, shutdown_error: Exception | None = None) -> None:
294+
self._calls = calls
295+
self._shutdown_error = shutdown_error
296+
self._connection = TestStreamClose._Connection(calls)
297+
298+
def shutdown(self) -> None:
299+
self._calls.append("shutdown")
300+
if self._shutdown_error is not None:
301+
raise self._shutdown_error
302+
303+
def close(self) -> None:
304+
self._calls.append("response.close")
305+
306+
def test_the_socket_is_dropped_before_the_response_is_closed(self) -> None:
307+
calls: list[str] = []
308+
_Stream(cast(BaseHTTPResponse, self._Response(calls))).close()
309+
310+
assert calls == ["shutdown", "connection.close", "response.close"]
311+
312+
def test_a_refused_shutdown_does_not_skip_the_rest_of_the_teardown(self) -> None:
313+
# urllib3 raises when there is no longer a socket to shut down, which is exactly the
314+
# case where nothing is parked on one. The response still has to be closed.
315+
calls: list[str] = []
316+
response = self._Response(calls, shutdown_error=ValueError("no socket"))
317+
_Stream(cast(BaseHTTPResponse, response)).close()
318+
319+
assert calls == ["shutdown", "connection.close", "response.close"]
320+
321+
def test_a_response_without_a_connection_is_still_closed(self) -> None:
322+
# Guards the getattr fallback: a urllib3 that renames `_connection` must degrade to the
323+
# old behaviour rather than raising out of close().
324+
calls: list[str] = []
325+
326+
class Bare:
327+
status = 200
328+
329+
def shutdown(self) -> None:
330+
calls.append("shutdown")
331+
332+
def close(self) -> None:
333+
calls.append("response.close")
334+
335+
_Stream(cast(BaseHTTPResponse, Bare())).close()
336+
337+
assert calls == ["shutdown", "response.close"]

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)