mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
`RepeaterDaemon._shutdown` arms an exit watchdog -- a `threading.Timer` that calls `os._exit(0)` after `SHUTDOWN_EXIT_GRACE_S` (5 s). That is right in production: it guarantees SIGTERM is honoured. `test_run_starts_http_and_handles_dispatcher_cancelled_gracefully` awaits `daemon.run()`, whose `finally` reaches `_shutdown()`, so it armed a real watchdog and returned. Five seconds later the timer fired and took the whole pytest process down. The failure mode is what makes this expensive: `os._exit(0)` exits with **status zero**. pytest produces no summary line and a truncated progress bar, so the run looks like it simply ended -- CI reads exit 0 as green on a run that never finished, and any failure after that point is invisible. Locally the suite appeared to hang at ~77% with no failures reported; it was actually being killed there. It also misattributes itself. The test blamed is whichever happened to be running five seconds later, so it moves between runs (`test_route_login_server_companion_only_skips_login_helper` and `test_route_login_server_hash_collision_offers_to_both` on two runs here) and never points at the cause. Finding it needed wrapping `_arm_exit_watchdog` to log the arming test. Two changes: - Patch `_arm_exit_watchdog` for that test, matching the guard the shutdown tests in `test_main_py_coverage.py` already use. - Add `tests/conftest.py` with an autouse fixture that fails any test leaving a live `shutdown-watchdog` timer, and cancels it. This blames the responsible test at the moment it leaks, rather than an unrelated one later. Verified it catches the original bug: reverting the one-line fix turns the silent process-kill into a teardown error naming the right test. `test_exit_watchdog_is_a_daemon_timer_that_forces_exit` is unaffected -- it sets a 0.05 s grace and asserts on a patched `os._exit` before returning, so it leaves nothing behind. With this the suite runs to completion: 3 failed, 1580 passed. Those 3 are pre-existing on `dev` and unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from repeater.identity_manager import IdentityManager
|
|
from repeater.main import RepeaterDaemon
|
|
|
|
|
|
class _FakeLocalIdentity:
|
|
def __init__(self, seed: bytes):
|
|
self._seed = seed
|
|
|
|
def get_public_key(self):
|
|
# Keep deterministic first-byte hash behavior.
|
|
return bytes([self._seed[0]]) + (b"P" * 31)
|
|
|
|
def get_address_bytes(self):
|
|
return b"\xab\xcd"
|
|
|
|
|
|
def _base_config():
|
|
return {
|
|
"repeater": {"node_name": "n1", "mode": "forward", "identity_key": b"k" * 32},
|
|
"logging": {"level": "INFO"},
|
|
"http": {"host": "127.0.0.1", "port": 8123},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_additional_identities_valid_and_invalid_entries():
|
|
cfg = _base_config()
|
|
cfg["identities"] = {
|
|
"room_servers": [
|
|
{}, # missing fields
|
|
{"name": "bad-hex", "identity_key": "zz-not-hex"},
|
|
{"name": "bad-len", "identity_key": "aa"},
|
|
{"name": "bad-type", "identity_key": 12345},
|
|
{"name": "good-bytes", "identity_key": b"\x10" * 32},
|
|
{"name": "good-hex", "identity_key": ("11" * 32)},
|
|
{"name": "good-hex-64", "identity_key": ("22" * 64)},
|
|
]
|
|
}
|
|
|
|
daemon = RepeaterDaemon(cfg, radio=object())
|
|
daemon.identity_manager = IdentityManager({})
|
|
daemon._register_identity_everywhere = MagicMock(return_value=True)
|
|
|
|
with patch("openhop_core.LocalIdentity", _FakeLocalIdentity):
|
|
await daemon._load_additional_identities()
|
|
|
|
# Only valid entries should be registered (including 64-byte firmware keys).
|
|
assert daemon._register_identity_everywhere.call_count == 3
|
|
names = [c.kwargs["name"] for c in daemon._register_identity_everywhere.call_args_list]
|
|
assert names == ["good-bytes", "good-hex", "good-hex-64"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_starts_http_and_handles_dispatcher_cancelled_gracefully():
|
|
daemon = RepeaterDaemon(_base_config(), radio=SimpleNamespace(cleanup=MagicMock()))
|
|
|
|
async def _init_stub():
|
|
daemon.local_identity = SimpleNamespace(get_public_key=lambda: b"\x22" * 32)
|
|
daemon.dispatcher = SimpleNamespace(
|
|
run_forever=AsyncMock(side_effect=asyncio.CancelledError())
|
|
)
|
|
|
|
daemon.initialize = _init_stub
|
|
|
|
fake_http_instance = SimpleNamespace(start=MagicMock(), stop=MagicMock())
|
|
|
|
fake_loop_for_signals = SimpleNamespace(add_signal_handler=MagicMock())
|
|
|
|
with (
|
|
patch("asyncio.get_running_loop", return_value=fake_loop_for_signals),
|
|
patch("repeater.main.HTTPStatsServer", return_value=fake_http_instance),
|
|
patch("os.path.exists", return_value=False),
|
|
# run()'s finally reaches _shutdown(), which arms the real exit
|
|
# watchdog: a threading.Timer that calls os._exit(0) after
|
|
# SHUTDOWN_EXIT_GRACE_S (5 s). It would fire long after this test
|
|
# returns and take the whole pytest process down. Same guard the
|
|
# shutdown tests in test_main_py_coverage.py already use.
|
|
patch.object(daemon, "_arm_exit_watchdog"),
|
|
):
|
|
await daemon.run()
|
|
|
|
fake_http_instance.start.assert_called_once()
|
|
daemon.dispatcher.run_forever.assert_awaited_once()
|