diff --git a/src/plato_edge/keeper.py b/src/plato_edge/keeper.py index ad36288..56cf0ba 100644 --- a/src/plato_edge/keeper.py +++ b/src/plato_edge/keeper.py @@ -57,9 +57,18 @@ def start(self, bind: str = "0.0.0.0") -> None: """Start listening for pings.""" if self._running: return - self._running = True sock = self._get_socket() - sock.bind((bind, self._port)) + try: + sock.bind((bind, self._port)) + except (OSError, OverflowError): + # OverflowError (out-of-range port) is not a subclass of OSError. + self._sock = None + try: + sock.close() + except OSError: + pass + raise + self._running = True self._thread = threading.Thread(target=self._loop, daemon=True) self._thread.start() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 6e43de1..81b8bd4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -126,6 +126,25 @@ def test_bad_port(self): with pytest.raises(OverflowError): b.start() + def test_start_failure_does_not_latch(self): + # A failed start() must roll back partial state so the Beacon is + # not left latched as "running" with a dangling open socket and + # unable to be retried or cleanly discarded. + b = Beacon(port=99999) + with pytest.raises(OverflowError): + b.start() + assert b._running is False + assert b._sock is None + # stop() must remain a safe no-op after a failed start. + b.stop() + # And the instance must be reusable on a valid port. + b2 = Beacon(port=0) + try: + b2.start() + assert b2._running is True + finally: + b2.stop() + class TestExplain: def test_trace_id(self):