"""Real Windows handle sharing and bounded queue-write failure diagnostics.""" import errno import json import multiprocessing import os from pathlib import Path import threading from types import SimpleNamespace import pytest import artemis.runtime.device_lock as module from artemis.runtime.device_lock import DeviceExecutionLock as Lock, DeviceQueueIOError from artemis.runtime.queue_files import queue_file_lock def _hold_shared_reader(path, ready, finish): with queue_file_lock(Path(path)), Path(path).open(encoding="utf-8") as reader: ready.set() assert finish.wait(10) assert json.loads(reader.read()) == {"version": 0} def test_other_process_reader_finishes_before_atomic_replace(tmp_path): path = tmp_path / "票据.wait" path.write_text('{"version":0}', encoding="utf-8") context = multiprocessing.get_context("spawn") ready, finish = context.Event(), context.Event() child = context.Process(target=_hold_shared_reader, args=(str(path), ready, finish)) child.start() release = threading.Timer(0.2, finish.set) try: assert ready.wait(10) release.start() assert Lock._atomic_write_json(path, {"version": 1}, raise_on_error=True) assert json.loads(path.read_text()) == {"version": 1} finally: finish.set() child.join(10) if release.ident is not None: release.join() assert child.exitcode == 0 def test_parallel_queue_readers_never_see_partial_json_or_block_claim(tmp_path): path = tmp_path / "ticket.wait" payload = Lock._owner_payload("token", "device", "queue stress") assert Lock._atomic_write_json(path, payload) stop = threading.Event() errors = [] def scan(): while not stop.is_set(): owner = Lock._read_owner(path) if owner is None or owner.token != "token": errors.append(owner) readers = [threading.Thread(target=scan) for _ in range(3)] for reader in readers: reader.start() try: for index in range(200): assert Lock._atomic_write_json(path, {**payload, "description": str(index)}) finally: stop.set() for reader in readers: reader.join(5) assert not errors assert not list(tmp_path.glob("*.tmp")) @pytest.mark.skipif(os.name != "nt", reason="Windows non-sharing reader behavior") def test_legacy_reader_released_after_40ms_is_retried(tmp_path): path = tmp_path / "ticket.wait" path.write_text("{}") reader = path.open() release = threading.Timer(0.15, reader.close) release.start() try: assert Lock._atomic_write_json(path, {"claimed": True}, raise_on_error=True) finally: release.join() reader.close() def _inject_replace(monkeypatch, operation): delays = [] monkeypatch.setattr(module, "os", SimpleNamespace(**{**vars(os), "replace": operation})) monkeypatch.setattr( module, "time", SimpleNamespace(**{**vars(module.time), "sleep": delays.append}) ) return delays def test_claim_keeps_native_error_and_bounds_sharing_retry(tmp_path, monkeypatch): monkeypatch.setattr(module, "get_temp_dir", lambda *_: tmp_path) token = Lock.reserve("test") lock = Lock("device", queue_ticket=token) original = lock._find_reserved_ticket().read_text() error = PermissionError(errno.EACCES, "held by another process") error.winerror = 5 attempts = [] def denied(src, dst): attempts.append(dst) raise error delays = _inject_replace(monkeypatch, denied) with pytest.raises(DeviceQueueIOError) as raised: lock.acquire() assert raised.value.__cause__ is error assert "winerror=5" in str(raised.value) assert "attempts=6" in str(raised.value) assert str(lock._find_reserved_ticket()) in str(raised.value) assert sum(delays) == pytest.approx(1.55) assert len(attempts) == 6 assert lock._find_reserved_ticket().read_text() == original assert not list(lock.queue_dir.glob("*.tmp")) @pytest.mark.parametrize("code", [errno.ENOSPC, errno.EROFS, errno.EACCES]) def test_unrelated_replace_errors_are_not_retried(tmp_path, monkeypatch, code): error = OSError(code, "disk or permission failure") def fail(*_): raise error delays = _inject_replace(monkeypatch, fail) with pytest.raises(DeviceQueueIOError) as raised: Lock._atomic_write_json(tmp_path / "ticket", {}, raise_on_error=True) assert raised.value.__cause__ is error assert not delays assert not list(tmp_path.glob("*.tmp")) def test_temporary_write_error_is_distinguished(tmp_path): with pytest.raises(DeviceQueueIOError, match="write temporary") as raised: Lock._atomic_write_json(tmp_path / "missing" / "ticket", {}, raise_on_error=True) assert isinstance(raised.value.__cause__, FileNotFoundError) def test_unreadable_owner_is_never_cleaned_as_stale(tmp_path, monkeypatch): path = tmp_path / "owner.lock" path.write_text("{}") os.utime(path, (1, 1)) error = PermissionError(errno.EACCES, "locked reader") def denied(_): raise error monkeypatch.setattr(module, "read_queue_text", denied) with pytest.raises(DeviceQueueIOError, match="read"): Lock._get_active_owner_for_path(path) assert path.exists() def test_cancellation_during_retry_does_not_resurrect_ticket(tmp_path, monkeypatch): path = tmp_path / "ticket" path.write_text("{}") error = PermissionError(errno.EACCES, "sharing") error.winerror = 32 def cancel(src, dst): path.unlink() raise error _inject_replace(monkeypatch, cancel) assert not Lock._atomic_write_json(path, {"claimed": True}, require_existing=True) assert not path.exists() assert not list(tmp_path.glob("*.tmp"))