fix(monitors): a ping or TCP check times the network, not Steward (#6105)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s

response_ms was the whole `ping -c 1` process, or an open_connection awaited
on the event loop: about 15 ms on a LAN whose round trip is under 1, and every
host read slow together whenever Steward was busy. A ping now reports the
round trip ping itself printed (the elapsed time only if it printed none);
a TCP connect is timed in a thread, the name resolved first and not counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:38:17 -04:00
co-authored by Claude Opus 5.5
parent 403025e201
commit 4639ac6e52
2 changed files with 84 additions and 20 deletions
+45 -13
View File
@@ -3,10 +3,17 @@
A failed check says why in `error_msg` (#5430): a down monitor that can't
say whether nothing answered or something refused leaves the operator to
guess.
`response_ms` is the network's own time, not Steward's (#6105): ping's
reported round trip, and a TCP connect timed in a thread of its own. Timed
from the event loop it carried starting a process and whatever else the loop
was busy with: about 15 ms on a LAN whose round trip is under 1, and every
host "slow" together whenever Steward was.
"""
from __future__ import annotations
import asyncio
import logging
import re
import socket
import time
@@ -31,22 +38,46 @@ def tcp_failure(exc: BaseException, address: str, port: int) -> str:
return str(exc) or type(exc).__name__
def _connect_ms(address: str, port: int) -> float:
"""Milliseconds a TCP connect to address:port takes, the name resolved
first and not counted; each address tried in turn, as a client would.
Blocking: run it in a thread."""
last: OSError | None = None
for family, kind, proto, _, sockaddr in socket.getaddrinfo(address, port, type=socket.SOCK_STREAM):
with socket.socket(family, kind, proto) as sock:
sock.settimeout(TCP_TIMEOUT)
start = time.perf_counter()
try:
sock.connect(sockaddr)
except OSError as exc:
last = exc
continue
return (time.perf_counter() - start) * 1000
raise last or OSError(f"no address for {address}")
async def tcp_check(address: str, port: int) -> dict:
"""TCP connect probe. Returns {is_up, response_ms, error_msg}."""
start = time.monotonic()
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(address, port),
timeout=TCP_TIMEOUT,
)
writer.close()
await writer.wait_closed()
return {"is_up": True, "response_ms": round((time.monotonic() - start) * 1000, 2),
"error_msg": None}
ms = await asyncio.wait_for(asyncio.to_thread(_connect_ms, address, port),
timeout=TCP_TIMEOUT * 2) # resolving, then the connect's own 5 s
return {"is_up": True, "response_ms": round(ms, 2), "error_msg": None}
except Exception as exc:
return {"is_up": False, "response_ms": None, "error_msg": tcp_failure(exc, address, port)}
_RTT = re.compile(rb"time[=<]([0-9.]+) ?ms")
def ping_rtt(stdout: bytes) -> float | None:
"""The round trip ping reports ("time=0.412 ms", or "time<1 ms"), or None."""
m = _RTT.search(stdout or b"")
try:
return float(m.group(1)) if m else None
except ValueError:
return None
def ping_failure(returncode: int, stderr: str) -> str:
"""Why `ping -c 1` failed: exit 1 is no reply; anything else is ping's
own complaint (an unknown name, no route), first line of it."""
@@ -67,7 +98,7 @@ async def icmp_check(address: str) -> dict:
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", str(PING_WAIT_S), address,
stdout=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as exc:
@@ -77,7 +108,7 @@ async def icmp_check(address: str) -> dict:
result["error_msg"] = f"ping unavailable, TCP check instead: {result['error_msg']}"
return result
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=PING_TIMEOUT)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=PING_TIMEOUT)
except asyncio.TimeoutError:
proc.kill()
try:
@@ -87,7 +118,8 @@ async def icmp_check(address: str) -> dict:
return {"is_up": False, "response_ms": None,
"error_msg": f"No reply within {PING_TIMEOUT:g} s"}
if proc.returncode == 0:
return {"is_up": True, "response_ms": round((time.monotonic() - start) * 1000, 2),
"error_msg": None}
rtt = ping_rtt(stdout)
ms = rtt if rtt is not None else (time.monotonic() - start) * 1000 # a ping that doesn't say
return {"is_up": True, "response_ms": round(ms, 2), "error_msg": None}
return {"is_up": False, "response_ms": None,
"error_msg": ping_failure(proc.returncode, (stderr or b"").decode(errors="replace"))}