159 lines
5.5 KiB
Python
159 lines
5.5 KiB
Python
|
|
"""Phase 10.5 — tests for :class:`iai_mcp.wake_handler.WakeHandler`.
|
||
|
|
|
||
|
|
Five-test matrix from CONTEXT 10.5:
|
||
|
|
|
||
|
|
- ``test_consume_wake_signal_when_present_deletes_and_returns_true``.
|
||
|
|
- ``test_consume_wake_signal_when_absent_returns_false``.
|
||
|
|
- ``test_consume_wake_signal_idempotent`` — second call returns False.
|
||
|
|
- ``test_has_pending_wake_read_only`` — does not delete the file.
|
||
|
|
- ``test_consume_atomic_no_race`` — concurrent wrapper-style writers and
|
||
|
|
a single daemon-style consumer; no exception, end state coherent.
|
||
|
|
|
||
|
|
Tests use ``tmp_path`` for the signal file (no real ``~/.iai-mcp/``
|
||
|
|
involvement) so they are hermetic across machines and parallel runs.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import threading
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from iai_mcp.wake_handler import WakeHandler
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- fixtures
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def wake_signal_path(tmp_path: Path) -> Path:
|
||
|
|
"""Path to a wake.signal file under tmp_path (file does NOT exist yet)."""
|
||
|
|
return tmp_path / "wake.signal"
|
||
|
|
|
||
|
|
|
||
|
|
def _write_signal(
|
||
|
|
path: Path,
|
||
|
|
payload: str = '{"requested_at":"2026-05-02T15:00:00Z"}',
|
||
|
|
tmp_suffix: str = ".tmp",
|
||
|
|
) -> None:
|
||
|
|
"""Atomic write helper mirroring the wrapper's temp + rename semantics.
|
||
|
|
|
||
|
|
The wrapper writes via ``fs.promises.writeFile(tmp)`` then
|
||
|
|
``fs.promises.rename(tmp, final)``; on POSIX that rename is atomic so
|
||
|
|
the consumer either sees the file fully or not at all. The Python
|
||
|
|
test mirrors this with ``Path.write_text`` followed by ``Path.rename``.
|
||
|
|
|
||
|
|
The ``tmp_suffix`` parameter lets concurrent writer threads use
|
||
|
|
distinct tmp filenames (mirroring the wrapper's per-pid-uuid scheme)
|
||
|
|
so they don't collide on the staging path.
|
||
|
|
"""
|
||
|
|
tmp = path.with_suffix(path.suffix + tmp_suffix)
|
||
|
|
tmp.write_text(payload, encoding="utf-8")
|
||
|
|
tmp.replace(path)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- tests
|
||
|
|
|
||
|
|
|
||
|
|
def test_consume_wake_signal_when_present_deletes_and_returns_true(
|
||
|
|
wake_signal_path: Path,
|
||
|
|
) -> None:
|
||
|
|
_write_signal(wake_signal_path)
|
||
|
|
assert wake_signal_path.is_file() # precondition
|
||
|
|
|
||
|
|
handler = WakeHandler(wake_signal_path)
|
||
|
|
assert handler.consume_wake_signal() is True
|
||
|
|
assert not wake_signal_path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_consume_wake_signal_when_absent_returns_false(
|
||
|
|
wake_signal_path: Path,
|
||
|
|
) -> None:
|
||
|
|
assert not wake_signal_path.exists() # precondition
|
||
|
|
|
||
|
|
handler = WakeHandler(wake_signal_path)
|
||
|
|
assert handler.consume_wake_signal() is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_consume_wake_signal_idempotent(wake_signal_path: Path) -> None:
|
||
|
|
_write_signal(wake_signal_path)
|
||
|
|
|
||
|
|
handler = WakeHandler(wake_signal_path)
|
||
|
|
assert handler.consume_wake_signal() is True
|
||
|
|
# Second call must NOT raise — file already gone.
|
||
|
|
assert handler.consume_wake_signal() is False
|
||
|
|
# And once more for good measure: still False, still no exception.
|
||
|
|
assert handler.consume_wake_signal() is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_has_pending_wake_read_only(wake_signal_path: Path) -> None:
|
||
|
|
_write_signal(wake_signal_path)
|
||
|
|
|
||
|
|
handler = WakeHandler(wake_signal_path)
|
||
|
|
# Read-only check — must NOT delete.
|
||
|
|
assert handler.has_pending_wake() is True
|
||
|
|
assert wake_signal_path.is_file()
|
||
|
|
# Multiple reads still don't delete.
|
||
|
|
assert handler.has_pending_wake() is True
|
||
|
|
assert wake_signal_path.is_file()
|
||
|
|
# Now consume; subsequent has_pending_wake reports False.
|
||
|
|
assert handler.consume_wake_signal() is True
|
||
|
|
assert handler.has_pending_wake() is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_consume_atomic_no_race(wake_signal_path: Path) -> None:
|
||
|
|
"""Concurrent wrapper-style writers + one daemon-style consumer.
|
||
|
|
|
||
|
|
Reproduces the wake-on-boot interleaving where the daemon is starting
|
||
|
|
up while one or more wrappers are still writing fresh signals. The
|
||
|
|
consumer must never raise — it either sees the file (returns True
|
||
|
|
and deletes) or doesn't (returns False).
|
||
|
|
"""
|
||
|
|
handler = WakeHandler(wake_signal_path)
|
||
|
|
consumed_truthy_count = 0
|
||
|
|
errors: list[BaseException] = []
|
||
|
|
|
||
|
|
stop_writers = threading.Event()
|
||
|
|
|
||
|
|
def writer_loop(writer_id: int) -> None:
|
||
|
|
# Hammer atomic-rename writes for ~50 ms; ample time for the
|
||
|
|
# consumer thread to fire several reads. Each writer uses a
|
||
|
|
# unique tmp suffix so concurrent writers do NOT collide on the
|
||
|
|
# staging path (mirrors the wrapper's per-pid-uuid scheme).
|
||
|
|
suffix = f".tmp.w{writer_id}"
|
||
|
|
try:
|
||
|
|
for _ in range(200):
|
||
|
|
if stop_writers.is_set():
|
||
|
|
return
|
||
|
|
_write_signal(wake_signal_path, tmp_suffix=suffix)
|
||
|
|
except BaseException as exc: # pragma: no cover -- defensive
|
||
|
|
errors.append(exc)
|
||
|
|
|
||
|
|
def consumer_loop() -> None:
|
||
|
|
nonlocal consumed_truthy_count
|
||
|
|
try:
|
||
|
|
for _ in range(200):
|
||
|
|
if handler.consume_wake_signal():
|
||
|
|
consumed_truthy_count += 1
|
||
|
|
except BaseException as exc:
|
||
|
|
errors.append(exc)
|
||
|
|
|
||
|
|
writers = [
|
||
|
|
threading.Thread(target=writer_loop, args=(i,)) for i in range(3)
|
||
|
|
]
|
||
|
|
consumer = threading.Thread(target=consumer_loop)
|
||
|
|
for w in writers:
|
||
|
|
w.start()
|
||
|
|
consumer.start()
|
||
|
|
consumer.join(timeout=10.0)
|
||
|
|
stop_writers.set()
|
||
|
|
for w in writers:
|
||
|
|
w.join(timeout=10.0)
|
||
|
|
|
||
|
|
# No thread raised. The consumer saw the signal at least once
|
||
|
|
# (writers wrote 600 times). Final filesystem state is allowed to be
|
||
|
|
# either present (a writer ran last) or absent (consumer ran last) —
|
||
|
|
# both are valid steady states.
|
||
|
|
assert errors == []
|
||
|
|
assert consumed_truthy_count >= 1
|