from __future__ import annotations import threading import time from unittest.mock import MagicMock import pytest from rein_aharness.execution_cancel import ( ExecutionCancel, ExecutionCancelled, active_cancel, using_cancel, ) def test_cancel_is_one_shot_and_bounded() -> None: cancel = ExecutionCancel() first = cancel.cancel("lease-loss") second = cancel.cancel("timeout") assert first == second == "lease-loss" assert cancel.cancelled is True assert cancel.reason == "lease-loss" assert cancel.evidence() == {"cancelled": "true", "reason": "lease-loss"} def test_unknown_reason_is_normalized() -> None: cancel = ExecutionCancel() cancel.cancel("provider exploded with secrets") assert cancel.reason == "cancelled" def test_check_raises_only_after_cancel() -> None: cancel = ExecutionCancel() cancel.check() cancel.cancel("timeout") with pytest.raises(ExecutionCancelled, match="timeout") as excinfo: cancel.check() assert excinfo.value.reason == "timeout" def test_register_process_is_killed_on_cancel() -> None: proc = MagicMock() proc.poll.return_value = None cancel = ExecutionCancel() cancel.register_process(proc) cancel.cancel("lease-loss") proc.kill.assert_called_once() def test_already_cancelled_register_kills_immediately() -> None: proc = MagicMock() proc.poll.return_value = None cancel = ExecutionCancel() cancel.cancel("signal") cancel.register_process(proc) proc.kill.assert_called_once() def test_exited_process_is_not_killed() -> None: proc = MagicMock() proc.poll.return_value = 0 cancel = ExecutionCancel() cancel.register_process(proc) cancel.cancel("timeout") proc.kill.assert_not_called() def test_stop_callback_failure_does_not_block_cancel() -> None: cancel = ExecutionCancel() cancel.register_stop(lambda: (_ for _ in ()).throw(RuntimeError("stop failed"))) assert cancel.cancel("signal") == "signal" def test_concurrent_cancel_invokes_stop_once() -> None: seen = [] cancel = ExecutionCancel() cancel.register_stop(lambda: seen.append("stop")) threads = [ threading.Thread(target=cancel.cancel, args=("lease-loss",)) for _ in range(8) ] for thread in threads: thread.start() for thread in threads: thread.join() assert seen == ["stop"] def test_using_cancel_exposes_active_cancel() -> None: cancel = ExecutionCancel() assert active_cancel() is None with using_cancel(cancel): assert active_cancel() is cancel cancel.cancel("timeout") assert active_cancel() is cancel assert active_cancel() is None def test_wait_returns_after_cancel() -> None: cancel = ExecutionCancel() started = threading.Event() def _cancel_soon() -> None: started.wait(timeout=1) time.sleep(0.01) cancel.cancel("signal") thread = threading.Thread(target=_cancel_soon) thread.start() started.set() assert cancel.wait(timeout=1) is True thread.join()