fix(gateway): arm the loop-scheduling witness on Windows via TCP loopback

`asyncio.start_unix_server` does not exist on Windows (no AF_UNIX event-loop
support in asyncio), so arming the loop-tick witness in
`loop_heartbeat_forever` raised AttributeError on every native-Windows
gateway start. The broad except swallowed it and recorded
`loop_tick_socket=False`, so every stale-heartbeat probe classified the
gateway as UNKNOWN — never WEDGED, never ALIVE-with-stalled-write. The
two-witness interlock from a1c83ef9 (issue #90502 follow-up) has been
effectively disabled on Windows since it landed: a wedged native-Windows
gateway could never be detected, and an alive one could never be
distinguished from a stalled heartbeat write.

On non-POSIX platforms the witness now arms over a TCP loopback server on
127.0.0.1 (OS-assigned dynamic port) instead:

- same protocol — connect, read one byte "1"
- same semantics — pure in-memory, zero disk I/O, answered only while the
  loop is dispatching, armed by the loop task itself (an awaited
  `asyncio.start_server` is structurally loop-owned exactly like the Unix
  variant, so a wedged loop cannot keep answering pings)
- the assigned port is published in the heartbeat payload as
  `loop_tick_tcp_port`, and `probe_gateway_loop_liveness` prefers the TCP
  witness when the producer published a port, falling back to the AF_UNIX
  socket for POSIX/legacy producers

POSIX behavior is unchanged: the AF_UNIX arm (including the stale-node
sweep) stays gated behind `os.name == "posix"` so the missing attribute can
never raise on Windows again. Legacy heartbeats without `loop_tick_tcp_port`
keep the existing socket-node contract untouched.

Tested end-to-end on native Windows: witness arms, port is published,
`_probe_loop_tick_tcp` answers from an external thread while the loop
dispatches, and the existing loop-liveness suite passes unchanged (the
AF_UNIX structural test still passes — the Unix arm text is preserved
inside the POSIX branch).

Adds two tests pinning the new behavior: an E2E test that arms the TCP
witness and probes it (skipped on POSIX, where the Unix arm is the real
witness), and a structural test that the TCP arm stays awaited on the loop
task and the AF_UNIX arm stays POSIX-gated.
This commit is contained in:
OmniaZ1
2026-09-01 22:42:44 +08:00
committed by Teknium
parent 1df78c2da8
commit d7bda2ad89
3 changed files with 209 additions and 33 deletions
+48 -29
View File
@@ -526,33 +526,34 @@ async def loop_heartbeat_forever(
# disables the witness, and the payload flag tells probes that staleness is
# no longer sufficient authority to escalate.
#
# Windows (non-POSIX generally): the server creation below is explicitly
# gated to POSIX — asyncio AF_UNIX support is POSIX-only, and an ungated
# call would raise AttributeError on every native-Windows gateway start.
# The witness is therefore DELIBERATELY left absent there (debug log only,
# no warning): the payload records loop_tick_socket=False and every
# stale-file probe classifies UNKNOWN, never WEDGED. That is deliberate
# fail-safe: a wedged native Windows gateway keeps the graceful-drain
# backstop instead of an escalation verdict built on a witness that cannot
# exist. (WSL2 — the #90502 incident environment — is Linux and arms the
# socket normally.)
# Windows (non-POSIX generally): asyncio AF_UNIX support is POSIX-only, so
# the AF_UNIX arm below is gated to POSIX — an ungated call raised
# AttributeError on every native-Windows gateway start (#96956). Instead of
# leaving the witness permanently absent there, the non-POSIX arm binds a
# TCP loopback server on 127.0.0.1 with an OS-assigned port and publishes
# the port in the heartbeat payload (``loop_tick_tcp_port``) so probes know
# where to connect. Same protocol, same loop-owned semantics. If that bind
# fails, the payload records loop_tick_socket=False and probes classify
# UNKNOWN, never WEDGED — the graceful-drain backstop stays in place. (WSL2
# — the #90502 incident environment — is Linux and arms the socket.)
tick_server = None
tick_socket_path = None
tick_tcp_port = None
try:
tick_socket_path = get_loop_tick_socket_path(home)
tick_socket_path.parent.mkdir(parents=True, exist_ok=True)
# Re-bind over a leftover node from a dead process (os._exit(75) /
# SIGKILL skip the finally-unlink; PID reuse re-lands on this
# PID-suffixed path) is handled by asyncio itself:
# create_unix_server os.remove()s an existing socket node before
# binding — guarded by test_producer_rebinds_over_stale_socket_node.
# What asyncio does NOT do is clean up SIBLING nodes from other
# dead PIDs, so sweep those to keep state/ from accumulating
# gateway.loop-tick.*.sock nodes across crash-restart cycles.
# POSIX-only: os.kill(pid, 0) is a liveness probe here, but on
# Windows os.kill calls TerminateProcess for non-CTRL signals —
# and AF_UNIX server nodes are never created there anyway.
if os.name == "posix":
tick_socket_path = get_loop_tick_socket_path(home)
tick_socket_path.parent.mkdir(parents=True, exist_ok=True)
# Re-bind over a leftover node from a dead process (os._exit(75) /
# SIGKILL skip the finally-unlink; PID reuse re-lands on this
# PID-suffixed path) is handled by asyncio itself:
# create_unix_server os.remove()s an existing socket node before
# binding — guarded by test_producer_rebinds_over_stale_socket_node.
# What asyncio does NOT do is clean up SIBLING nodes from other
# dead PIDs, so sweep those to keep state/ from accumulating
# gateway.loop-tick.*.sock nodes across crash-restart cycles.
# POSIX-only: os.kill(pid, 0) is a liveness probe here, but on
# Windows os.kill calls TerminateProcess for non-CTRL signals —
# and AF_UNIX server nodes are never created there anyway.
try:
for _stale in tick_socket_path.parent.glob(
"gateway.loop-tick.*.sock"
@@ -576,14 +577,29 @@ async def loop_heartbeat_forever(
_tick_socket_handler, path=str(tick_socket_path)
)
else:
logger.debug(
"loop-tick witness intentionally not armed on non-POSIX "
"platform (os.name=%r): asyncio AF_UNIX support is "
"POSIX-only; heartbeat payload records loop_tick_socket=False",
os.name,
# Windows / non-POSIX: no AF_UNIX support, so use a TCP loopback
# server on 127.0.0.1 as the loop-scheduling witness instead.
# Same protocol (connect → read one byte "1"), same semantics
# (pure in-memory, zero disk I/O, answered only when the loop
# is dispatching). Port is dynamic (assigned by the OS) and
# published via the heartbeat payload so external probes know
# where to connect.
tick_server = await asyncio.start_server(
_tick_socket_handler, host="127.0.0.1", port=0
)
# Get the actual port assigned by the OS
_sock_addrs = tick_server.sockets if hasattr(tick_server, "sockets") else []
for _s in _sock_addrs:
try:
_sname = _s.getsockname()
if isinstance(_sname, tuple) and len(_sname) >= 2:
tick_tcp_port = int(_sname[1])
break
except Exception:
pass
except Exception:
tick_server = None
tick_tcp_port = None
logger.warning(
"Loop tick socket unavailable — liveness probes will have no "
"loop-scheduling witness and will not escalate on a stale heartbeat",
@@ -598,7 +614,10 @@ async def loop_heartbeat_forever(
write_loop_heartbeat,
start_time=start_time,
home=home,
extra={"loop_tick_socket": tick_server is not None},
extra={
"loop_tick_socket": tick_server is not None,
"loop_tick_tcp_port": tick_tcp_port,
},
)
except asyncio.CancelledError:
raise
+61 -4
View File
@@ -481,6 +481,46 @@ def _probe_loop_tick_socket(
pass
def _probe_loop_tick_tcp(
port: int,
timeout: float = 1.0,
) -> bool | None:
"""Ping the loop-scheduling witness via TCP loopback (Windows).
Same protocol and semantics as the Unix socket variant: connect to
127.0.0.1:<port> and expect one byte "1" as proof the loop is
dispatching. Used on Windows / non-POSIX systems where AF_UNIX is not
available in asyncio.
Returns:
True — the loop answered.
False — the port was reachable but did not answer, or refused.
None — invalid port / could not connect for unrelated reasons.
"""
try:
port_num = int(port)
if port_num <= 0 or port_num > 65535:
return None
except (TypeError, ValueError):
return None
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(max(float(timeout), 0.0))
sock.connect(("127.0.0.1", port_num))
return sock.recv(1) == b"1"
except Exception:
# Connection refused, timeout, transient errors: witness exists
# but is silent (or the process is dead and the port is closed).
return False
finally:
if sock is not None:
try:
sock.close()
except Exception:
pass
def _probe_loop_tick_socket_sustained(
pid: int,
home: Path | None,
@@ -488,6 +528,7 @@ def _probe_loop_tick_socket_sustained(
timeout: float = 1.0,
strikes: int = 3,
gap_s: float = 0.2,
tcp_port: int | None = None,
) -> bool | None:
"""Probe the tick socket until a reply or the sustained-miss budget.
@@ -509,7 +550,10 @@ def _probe_loop_tick_socket_sustained(
"""
total = max(int(strikes), 0)
for attempt in range(total):
result = _probe_loop_tick_socket(pid, home, timeout=timeout)
if tcp_port is not None:
result = _probe_loop_tick_tcp(tcp_port, timeout=timeout)
else:
result = _probe_loop_tick_socket(pid, home, timeout=timeout)
if result is True:
return True
if result is None:
@@ -579,14 +623,26 @@ def probe_gateway_loop_liveness(
# up, or a stale file from a previous PID. Not evidence of a wedge.
return GATEWAY_LOOP_UNKNOWN
witness = _probe_loop_tick_socket(pid, home, timeout=tick_timeout)
# Pick the right witness probe: TCP loopback (Windows / non-POSIX)
# takes priority if the producer published a port, otherwise fall back
# to the AF_UNIX socket (POSIX / legacy).
tcp_port = payload.get("loop_tick_tcp_port")
try:
tcp_port_int = int(tcp_port) if tcp_port is not None else None
except (TypeError, ValueError):
tcp_port_int = None
if tcp_port_int is not None and tcp_port_int > 0:
witness = _probe_loop_tick_tcp(tcp_port_int, timeout=tick_timeout)
tick_armed = True
else:
witness = _probe_loop_tick_socket(pid, home, timeout=tick_timeout)
tick_armed = payload.get("loop_tick_socket", _LOOP_TICK_ABSENT)
if witness is True:
# The loop answered a ping — it is dispatching right now. A stale
# heartbeat file is a stalled write or a saturated executor, not a
# wedge (#90502).
return GATEWAY_LOOP_ALIVE
tick_armed = payload.get("loop_tick_socket", _LOOP_TICK_ABSENT)
age = time.time() - mtime
if age <= stale_budget:
if witness is False:
@@ -620,6 +676,7 @@ def probe_gateway_loop_liveness(
timeout=tick_timeout,
strikes=tick_strikes - 1,
gap_s=tick_gap_s,
tcp_port=tcp_port_int,
)
if sustained is False:
# Both witnesses agree, sustained: the loop did not schedule for
@@ -3,8 +3,11 @@
from __future__ import annotations
import asyncio
import json
import os
import pathlib
import inspect
import tempfile
import threading
import time
from unittest.mock import MagicMock, patch
@@ -403,3 +406,100 @@ def test_loop_scheduling_witness_is_served_by_the_loop_itself():
assert "await asyncio.start_unix_server(" in body, (
"the loop-scheduling witness socket is not armed by the loop task"
)
def test_windows_tcp_witness_arms_and_publishes_port():
"""On non-POSIX platforms the witness must arm over TCP loopback.
``asyncio.start_unix_server`` does not exist on Windows (no AF_UNIX
event-loop support), so the producer arm fell into the broad except and
recorded ``loop_tick_socket=False`` — every stale-file probe then
classified UNKNOWN forever, disabling the wedge interlock on Windows
entirely. The TCP loopback witness restores the same contract: armed by
the loop task (an awaited ``asyncio.start_server`` is structurally
loop-owned exactly like the Unix variant), answered only while the loop
dispatches, port published in the heartbeat payload.
"""
if os.name == "posix":
pytest.skip("TCP loopback witness is the non-POSIX arm")
async def scenario() -> tuple[dict, bool]:
task = asyncio.create_task(
loop_heartbeat_forever(interval_s=1.0, home=tmp_home)
)
try:
deadline = time.monotonic() + 5.0
payload = None
while time.monotonic() < deadline:
hb = tmp_home.joinpath(*("state", "gateway.heartbeat"))
if hb.exists():
try:
payload = json.loads(hb.read_text(encoding="utf-8"))
except Exception:
payload = None
if payload and payload.get("loop_tick_tcp_port"):
break
await asyncio.sleep(0.02)
assert payload is not None, "heartbeat never appeared"
assert payload.get("loop_tick_socket") is True, (
"witness reported unarmed on a platform where the TCP arm "
"must work"
)
port = int(payload["loop_tick_tcp_port"])
assert 0 < port <= 65535, "published port out of range"
# Probe from a worker thread so the blocking connect/recv never
# stalls the very loop we are witnessing (an external process
# probes from its own loop/thread — reproduce that shape).
from hermes_cli.gateway import _probe_loop_tick_tcp
result_box: dict[str, object] = {}
def _probe() -> None:
result_box["r"] = _probe_loop_tick_tcp(port, timeout=2.0)
worker = threading.Thread(target=_probe)
worker.start()
while worker.is_alive():
await asyncio.sleep(0.05)
worker.join()
return payload, bool(result_box.get("r") is True)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
with tempfile.TemporaryDirectory(prefix="lw-tcp-") as raw:
tmp_home = pathlib.Path(raw)
payload, answered = asyncio.run(scenario())
assert answered, (
"the loop-tick TCP witness did not answer a probe while the loop "
"was dispatching — the two-witness interlock would misclassify "
"this gateway as UNKNOWN"
)
def test_windows_tcp_witness_arms_on_loop_task_source_shape():
"""The TCP arm must be awaited by the loop task, never thread-owned.
Structural companion to ``test_loop_scheduling_witness_is_served_by_the_
loop_itself``: the same property that makes the Unix socket an honest
witness (a coroutine cannot run inside a thread) must hold for the TCP
loopback arm, or a wedged loop could keep answering pings and the
interlock would be void on Windows.
"""
src = pathlib.Path(
inspect.getsourcefile(loop_heartbeat_forever) or ""
).read_text()
body = src[src.index("async def loop_heartbeat_forever("):]
body = body[: body.index("\ndef ") if "\ndef " in body else len(body)]
assert "await asyncio.start_server(" in body, (
"the TCP loop-scheduling witness is not armed by the loop task"
)
# The Unix arm must stay gated to POSIX-only code paths so the missing
# attribute can never raise on Windows again.
assert 'os.name == "posix"' in body, (
"the AF_UNIX witness arm is not gated to POSIX platforms"
)