172 lines
5.7 KiB
Python
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"))
|