feat: add guided exploration and share user-started MCP backend
This commit is contained in:
1 parent
f86db1ced6
commit
ee256ad7ce
144 files changed
+8625
-2075
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""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"))
|
||||
Reference in new issue
Block a user