290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Windows live E2E probes for the CLOSE-WAIT getUpdates reconnect fix (#87057).
|
|
|
|
These probes run ONLY on a real Windows runner (the on-demand
|
|
``windows-venv-e2e.yml`` lane, fired by pushes to ``wine2e/**`` branches).
|
|
They exercise the REAL PTB ``HTTPXRequest`` transport against a live local
|
|
HTTP server whose sockets are genuinely half-closed by the server side, so
|
|
the client's pooled connection sits in CLOSE-WAIT exactly the way a dropped
|
|
``api.telegram.org`` long-poll does on Windows (selector/proactor overlapped
|
|
I/O never surfaces the peer close).
|
|
|
|
Scenario pinned by #87057: after a transient network error the reconnect
|
|
ladder calls ``_drain_polling_connections()``. On Windows the close of the
|
|
CLOSE-WAIT socket can wedge; PTB's ``HTTPXRequest.initialize()`` only builds
|
|
a fresh client when ``client.is_closed`` is true, so an abandoned close left
|
|
``start_polling()`` on the same dead socket and the gateway went silently
|
|
deaf. The fix swaps in a fresh HTTP client when the drain times out.
|
|
|
|
Probes:
|
|
1. ``test_drain_recovers_after_server_half_close_live`` — a real pooled
|
|
keep-alive connection is half-closed by the server (CLOSE-WAIT on the
|
|
client). The drain must complete within its bound and the next real
|
|
request must succeed on a NEW TCP connection.
|
|
2. ``test_drain_bounded_and_functional_when_close_wedges_live`` — the real
|
|
request's ``shutdown()`` is replaced with one that hangs forever
|
|
(deterministic stand-in for the observed proactor CLOSE-WAIT close hang).
|
|
The drain must return within the wall-clock bound, replace the wedged
|
|
client, and the replacement must complete a real HTTP round-trip.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
# The gateway conftest installs a MagicMock ``telegram`` package when the
|
|
# real library has not been imported yet. This probe exercises the REAL PTB
|
|
# HTTPXRequest against a live socket server, so evict any mock before the
|
|
# real import. The lane installs the messaging extra, so real PTB is present.
|
|
# Gated to win32: on other platforms these tests are skipped and evicting the
|
|
# shared mock here would poison later test modules in the same session.
|
|
if sys.platform == "win32":
|
|
_tg = sys.modules.get("telegram")
|
|
if _tg is not None and not hasattr(_tg, "__file__"):
|
|
for _name in [
|
|
m for m in list(sys.modules) if m == "telegram" or m.startswith("telegram.")
|
|
]:
|
|
del sys.modules[_name]
|
|
# The adapter module may have bound mock names at import time — reload
|
|
# it against the real library.
|
|
for _name in [
|
|
m for m in list(sys.modules) if m.startswith("plugins.platforms.telegram")
|
|
]:
|
|
del sys.modules[_name]
|
|
|
|
pytestmark = [
|
|
pytest.mark.asyncio,
|
|
pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Windows-only live probe: CLOSE-WAIT reconnect behavior (#87057)",
|
|
),
|
|
]
|
|
|
|
|
|
class _LiveBotApiServer:
|
|
"""Minimal live HTTP/1.1 server that can half-close its connections.
|
|
|
|
Speaks just enough HTTP for PTB's ``HTTPXRequest.do_request`` POSTs.
|
|
Every accepted TCP connection is tracked so probes can assert whether a
|
|
request arrived on a fresh connection or reused a pooled one, and the
|
|
server can actively half-close (FIN) all live connections to park the
|
|
client side in CLOSE-WAIT.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.server = None
|
|
self.port = None
|
|
self.connections_accepted = 0
|
|
self.requests_served = 0
|
|
self._writers = []
|
|
|
|
async def start(self):
|
|
self.server = await asyncio.start_server(
|
|
self._handle, host="127.0.0.1", port=0
|
|
)
|
|
self.port = self.server.sockets[0].getsockname()[1]
|
|
|
|
async def stop(self):
|
|
for w in self._writers:
|
|
try:
|
|
w.close()
|
|
except Exception:
|
|
pass
|
|
if self.server is not None:
|
|
self.server.close()
|
|
await self.server.wait_closed()
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"http://127.0.0.1:{self.port}/botTEST/getUpdates"
|
|
|
|
async def half_close_all(self):
|
|
"""Send FIN on every live connection -> client side goes CLOSE-WAIT."""
|
|
for w in self._writers:
|
|
try:
|
|
w.write_eof()
|
|
except Exception:
|
|
pass
|
|
# Give the client's TCP stack a moment to process the FIN.
|
|
await asyncio.sleep(0.2)
|
|
|
|
async def _handle(self, reader, writer):
|
|
self.connections_accepted += 1
|
|
self._writers.append(writer)
|
|
try:
|
|
while True:
|
|
# Read request head.
|
|
head = await reader.readuntil(b"\r\n\r\n")
|
|
headers = head.decode("latin1").lower()
|
|
length = 0
|
|
for line in headers.split("\r\n"):
|
|
if line.startswith("content-length:"):
|
|
length = int(line.split(":", 1)[1].strip())
|
|
if length:
|
|
await reader.readexactly(length)
|
|
self.requests_served += 1
|
|
body = json.dumps({"ok": True, "result": []}).encode()
|
|
writer.write(
|
|
b"HTTP/1.1 200 OK\r\n"
|
|
b"Content-Type: application/json\r\n"
|
|
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
|
b"Connection: keep-alive\r\n"
|
|
b"\r\n" + body
|
|
)
|
|
await writer.drain()
|
|
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
pass
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _make_adapter():
|
|
from gateway.config import PlatformConfig
|
|
from plugins.platforms.telegram.adapter import TelegramAdapter
|
|
|
|
return TelegramAdapter(PlatformConfig(enabled=True, token="123456:TEST"))
|
|
|
|
|
|
def _diag(server, label, extra=""):
|
|
print(
|
|
f"[closewait-probe] {label}: connections={server.connections_accepted} "
|
|
f"requests={server.requests_served} platform={sys.platform} {extra}",
|
|
flush=True,
|
|
)
|
|
|
|
|
|
async def test_drain_recovers_after_server_half_close_live(monkeypatch):
|
|
"""Drain must retire a real CLOSE-WAIT pooled connection within bound."""
|
|
from telegram.request import HTTPXRequest
|
|
from unittest.mock import MagicMock
|
|
|
|
import plugins.platforms.telegram.adapter as tg_adapter
|
|
|
|
server = _LiveBotApiServer()
|
|
await server.start()
|
|
try:
|
|
polling_req = HTTPXRequest(
|
|
connection_pool_size=1,
|
|
read_timeout=5.0,
|
|
connect_timeout=5.0,
|
|
pool_timeout=5.0,
|
|
)
|
|
await polling_req.initialize()
|
|
|
|
# Real round-trip 1: connection enters the keep-alive pool.
|
|
code, payload = await polling_req.do_request(server.url, "POST")
|
|
assert code == 200 and b'"ok"' in payload
|
|
assert server.connections_accepted == 1
|
|
_diag(server, "after first round-trip")
|
|
|
|
# Server half-closes: the pooled client connection is now CLOSE-WAIT.
|
|
await server.half_close_all()
|
|
_diag(server, "after server half-close (client socket CLOSE-WAIT)")
|
|
|
|
adapter = _make_adapter()
|
|
mock_app = MagicMock()
|
|
mock_app.bot._request = (polling_req, MagicMock())
|
|
adapter._app = mock_app
|
|
|
|
monkeypatch.setattr(tg_adapter, "_DRAIN_TIMEOUT", 5.0)
|
|
started = time.monotonic()
|
|
await adapter._drain_polling_connections()
|
|
elapsed = time.monotonic() - started
|
|
_diag(server, "after drain", f"elapsed={elapsed:.2f}s")
|
|
assert elapsed < 12.0, (
|
|
f"drain must be bounded even with a CLOSE-WAIT socket, took {elapsed:.2f}s"
|
|
)
|
|
|
|
# The reconnect path must be LIVE: a new real request succeeds on a
|
|
# fresh TCP connection, not the dead pooled one.
|
|
before = server.connections_accepted
|
|
code, payload = await polling_req.do_request(server.url, "POST")
|
|
assert code == 200 and b'"ok"' in payload
|
|
assert server.connections_accepted > before, (
|
|
"post-drain getUpdates must use a NEW connection, not the "
|
|
"CLOSE-WAIT one"
|
|
)
|
|
_diag(server, "after post-drain round-trip")
|
|
await polling_req.shutdown()
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
async def test_drain_bounded_and_functional_when_close_wedges_live(monkeypatch):
|
|
"""A wedged close must not hang the drain; the swapped client must work.
|
|
|
|
Deterministic stand-in for the Windows proactor hang: the real
|
|
HTTPXRequest's shutdown() is replaced with a coroutine that never
|
|
returns (what a CLOSE-WAIT close did in #87057). The drain must
|
|
(a) return within the wall-clock bound, (b) swap in a fresh client
|
|
because initialize() would otherwise no-op on is_closed=False, and
|
|
(c) leave the polling request able to complete a REAL round-trip.
|
|
"""
|
|
from telegram.request import HTTPXRequest
|
|
from unittest.mock import MagicMock
|
|
|
|
import plugins.platforms.telegram.adapter as tg_adapter
|
|
|
|
server = _LiveBotApiServer()
|
|
await server.start()
|
|
try:
|
|
polling_req = HTTPXRequest(
|
|
connection_pool_size=1,
|
|
read_timeout=5.0,
|
|
connect_timeout=5.0,
|
|
pool_timeout=5.0,
|
|
)
|
|
await polling_req.initialize()
|
|
code, _ = await polling_req.do_request(server.url, "POST")
|
|
assert code == 200
|
|
old_client = polling_req._client # noqa: SLF001
|
|
_diag(server, "wedge-probe: after first round-trip")
|
|
|
|
async def _wedged_shutdown():
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(polling_req, "shutdown", _wedged_shutdown)
|
|
monkeypatch.setattr(tg_adapter, "_DRAIN_TIMEOUT", 1.0)
|
|
|
|
adapter = _make_adapter()
|
|
mock_app = MagicMock()
|
|
mock_app.bot._request = (polling_req, MagicMock())
|
|
adapter._app = mock_app
|
|
|
|
started = time.monotonic()
|
|
await asyncio.wait_for(adapter._drain_polling_connections(), timeout=30.0)
|
|
elapsed = time.monotonic() - started
|
|
_diag(server, "wedge-probe: after drain", f"elapsed={elapsed:.2f}s")
|
|
assert elapsed < 10.0, (
|
|
f"drain with a wedged shutdown must stay bounded, took {elapsed:.2f}s"
|
|
)
|
|
|
|
new_client = polling_req._client # noqa: SLF001
|
|
assert new_client is not old_client, (
|
|
"drain must swap in a fresh HTTP client when shutdown wedges "
|
|
"(initialize() no-ops while is_closed is False)"
|
|
)
|
|
|
|
# The replacement client must be genuinely functional: real request,
|
|
# real socket, live server.
|
|
before = server.connections_accepted
|
|
code, payload = await polling_req.do_request(server.url, "POST")
|
|
assert code == 200 and b'"ok"' in payload
|
|
assert server.connections_accepted > before
|
|
_diag(server, "wedge-probe: after post-swap round-trip")
|
|
|
|
# Bounded cleanup of the orphaned client must not linger forever.
|
|
deadline = time.monotonic() + 10.0
|
|
while adapter._background_tasks and time.monotonic() < deadline:
|
|
await asyncio.sleep(0.2)
|
|
assert not adapter._background_tasks, (
|
|
"orphaned-client cleanup task must complete/abandon within bound"
|
|
)
|
|
finally:
|
|
await server.stop()
|