diff --git a/tests/test_health.py b/tests/test_health.py index 5e1df05..9ae03d4 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -4,6 +4,7 @@ import threading import time +from collections.abc import Callable import pytest @@ -158,32 +159,48 @@ def test_remote_check_disabled() -> None: def test_run_loop_stops_cleanly() -> None: - # Use a healthy manager: with backoff enabled an unhealthy one would grow - # the interval and could starve the ">= 3 checks" assertion below. + """循环反复检查,而设置停止事件后线程真的结束了。 + + 不按墙钟断言:"睡 0.3 秒后至少 3 次检查"考的是**这台机器够快**,而 CI 上带 + coverage 插桩的 macOS runner 把 ``run_loop`` 的一轮从标称 50ms 拉到 120ms, + 于是等到 2 次就断言失败(main 上真实发生过)。这里改成:次数用带截止时间的 + 等待,"停下来"则用 join 证明——那才是测试名字里的 stops cleanly。 + """ + # 用健康的 manager:不健康会触发退避,间隔指数增长,把"若干次检查"饿死。 hc = HealthChecker(_TM(alive=True, ports="list"), _hc()) seen: list[HealthStatus] = [] + before_threads = set(threading.enumerate()) stop = hc.run_loop(interval=0.05, callback=lambda st: seen.append(st)) assert isinstance(stop, threading.Event) - time.sleep(0.3) + monitor = next((t for t in threading.enumerate() if t not in before_threads), None) + assert monitor is not None, "run_loop 没有起后台线程" + + _wait_for_values(seen, 3) + + checks_at_stop = len(seen) stop.set() - time.sleep(0.15) - assert len(seen) >= 3, len(seen) + monitor.join(timeout=5) + assert not monitor.is_alive(), "设置停止事件后循环没有退出" + # 已经进入 check() 的那一轮仍会回调一次(实现只保证在 wait 之前查一次事件), + # 再多就说明停止没生效。线程已 join,计数此后不会再变,所以这是确定性的。 + late = len(seen) - checks_at_stop + assert late <= 1, f"停止后又回调了 {late} 次" assert all(isinstance(st, HealthStatus) for st in seen) - n = len(seen) - time.sleep(0.15) - assert len(seen) == n, (len(seen), n) def test_run_loop_callback_error_swallowed() -> None: + """回调抛出的异常被记下来,而不是把监控线程弄死。""" hc = HealthChecker(_TM(alive=True, ports="dict"), _hc()) def bad_cb(_st: HealthStatus) -> None: raise ValueError("cb boom") stop = hc.run_loop(interval=0.02, callback=bad_cb) - time.sleep(0.15) + _wait_for( + lambda: isinstance(hc.last_callback_error, ValueError), + lambda: f"回调异常没被记录:{hc.last_callback_error!r}", + ) stop.set() - assert isinstance(hc.last_callback_error, ValueError) def test_health_status_str() -> None: @@ -281,12 +298,29 @@ def test_backoff_interval_formula() -> None: assert HealthChecker._backoff_interval(60.0, 10, 300.0) == 300.0 -def _wait_for_values(values: list, n: int, timeout: float = 3.0) -> None: - """Busy-wait until ``values`` has at least ``n`` entries.""" +def _wait_for( + predicate: Callable[[], bool], + describe: Callable[[], str], + timeout: float = 5.0, +) -> None: + """Wait for *predicate* on a deadline, instead of napping a fixed time. + + 固定秒数的 ``sleep`` 断言的是"这台机器够快",而不是被测行为。``describe`` + 是个函数:失败信息要在**等过之后**才取,否则它记的是等待前那一刻的状态。 + """ deadline = time.time() + timeout - while len(values) < n and time.time() < deadline: + while not predicate() and time.time() < deadline: time.sleep(0.01) - assert len(values) >= n, f"captured only {len(values)} values, need {n}" + assert predicate(), describe() + + +def _wait_for_values(values: list, n: int, timeout: float = 5.0) -> None: + """Busy-wait until ``values`` has at least ``n`` entries.""" + _wait_for( + lambda: len(values) >= n, + lambda: f"captured only {len(values)} values, need {n}", + timeout, + ) def _fake_wait_recorder(