Files

172 lines
5.7 KiB
Python

"""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"))