Updated by fix-consistency on 2026-09-04: - update .custodian-brief.md for rein-aharness Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06ba0-10aa-7ea0-b20a-4f3fac39efe9
113 lines
3 KiB
Python
113 lines
3 KiB
Python
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()
|