Files
hermes-agent/tests/gateway/test_telegram_closewait_windows_live.py
ethernet 7899760ff5 fix(ci): repair native Windows test coverage
Native Windows collection called POSIX-only APIs, and the lane omitted its
Telegram dependency. Several live tests also encoded obsolete runtime identity,
release-channel, and process-launch contracts.

Refresh the tests against the current production seams. Give hosted PowerShell
probes enough time under contention and install the Telegram extra in the OS
lane.

Validated on native Windows 3.14.7:
- 12 passed and 3 skipped across the six files that failed in run 35672259145

Validated on Linux:
- 116 passed and 12 skipped across the same six files
- Ruff, actionlint, YAML parsing, and Windows marker discovery passed
2026-09-21 20:56:39 -04:00

289 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.platforms("windows"), # 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(_request):
await asyncio.Event().wait()
# PTB 22.8 slots HTTPXRequest instances, so the bound method is
# read-only. Patch the class seam for this one live request instead.
monkeypatch.setattr(HTTPXRequest, "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()