fix(relay): самовосстановление при сбоях папки — одно предупреждение вместо потока трассировок (I-1, I-2, #5)

Отказ доступа (TCC), удалённые req/ или work/ больше не сыплют трассировки каждые 0,1 с и не останавливают
реле молча: одно предупреждение на причину, подкаталоги создаются заново; resp/ создаётся до пересылки,
чтобы выданный claim не уходил в никуда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 01:08:21 +04:00
parent 36bfd95a87
commit 4a82be05cc
3 changed files with 86 additions and 17 deletions

View File

@ -92,8 +92,8 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j
Просроченные запросы выбрасываются; `claim` пересылается, только если до срока ≥ 35 с. В логе — строка
`relay <метод> <путь> <код> <секунды>` на запрос. Круг через VirtioFS ≈ 0,8 с.
Если launchd-хаб не видит папку (в логе «папки … нет — жду» при подключённом SSD) — macOS не пускает процесс на внешний
диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv`
Если launchd-хаб не видит папку (в логе «relay: нет доступа к папке … — Operation not permitted; жду» или «папки … нет —
жду» при подключённом SSD) — macOS не пускает процесс на внешний диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv`
и python из `.venv` хаба, перезапустить задание.
## HTTP API

View File

@ -59,7 +59,7 @@ class Relay:
self._settings_at: float | None = None
self._cleanup_at = 0.0
self._ready = False
self._warned = False
self._warned: str | None = None
self.workers: list[threading.Thread] = []
# --- цикл ---------------------------------------------------------------------
@ -78,10 +78,9 @@ class Relay:
self._reload()
if self.root is None:
return
try:
if not self.root.is_dir():
if not self._warned:
say(f"relay: папки {self.root} нет — жду")
self._warned = True
self._warn(f"relay: папки {self.root} нет — жду")
self._ready = False
return
if not self._ready:
@ -90,10 +89,20 @@ class Relay:
if path.name.startswith(".") or not path.name.endswith(".json"):
continue
self._take(path)
except OSError as e: # нет доступа (TCC), подпапку удалили — подготовить заново на следующем шаге
self._ready = False
self._warn(f"relay: нет доступа к папке {self.root} — {e.strerror or e}; жду")
return
if now - self._cleanup_at >= CLEANUP_EVERY:
self._cleanup_at = now
self.cleanup(now)
def _warn(self, msg: str) -> None:
"""Одно предупреждение на причину: без потока одинаковых строк каждые 0,1 с."""
if msg != self._warned:
say(msg)
self._warned = msg
def join(self, timeout: float = 5) -> None:
for w in list(self.workers):
w.join(timeout)
@ -107,7 +116,7 @@ class Relay:
conn.close()
root = Path(value) if value else None
if root != self.root:
self.root, self._ready, self._warned = root, False, False
self.root, self._ready, self._warned = root, False, None
say(f"relay: папка {root}" if root else "relay: выключено")
def _prepare(self) -> None:
@ -116,14 +125,16 @@ class Relay:
for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)")
self._ready, self._warned = True, False
self._ready, self._warned = True, None
def _take(self, path: Path) -> None:
target = self.root / "work" / path.name
try:
os.rename(path, target)
except FileNotFoundError: # агент успел удалить свой просроченный запрос
return
except FileNotFoundError:
if path.exists(): # пропала work/ — step() подготовит папку заново
raise
return # агент успел удалить свой просроченный запрос
worker = threading.Thread(target=self.process, args=(self.root, target), daemon=True)
worker.start()
self.workers = [w for w in self.workers if w.is_alive()] + [worker]
@ -142,6 +153,7 @@ class Relay:
if left <= 0 or (method == "POST" and path == CLAIM_PATH and left < CLAIM_MIN_LEFT):
say(f"relay {method} {path} просрочен (осталось {left:.0f} с) — выброшен")
return
(root / "resp").mkdir(exist_ok=True) # до пересылки: выданный claim не должен уйти в никуда
status, body = self.forward(method, path, req.get("body"), req.get("authorization"))
write_atomic(root / "resp", work.name, {"id": req.get("id"), "status": status, "body": body})
say(f"relay {method} {path} {status} {time.monotonic() - started:.1f} с")

View File

@ -267,3 +267,60 @@ def test_serve_runs_relay(tmp_path):
srv.shutdown()
srv.server_close()
th.join(5)
def test_permission_denied_warns_once_then_works(env, capsys):
r, root = env["relay"], env["root"]
locked = root.parent / "locked"
target = locked / "bridge"
target.mkdir(parents=True)
conn = env["hub"].conn()
settings.set_(conn, "relay_dir", str(target))
conn.close()
locked.chmod(0) # так выглядит отказ доступа (TCC) у процесса launchd
try:
for _ in range(5):
r.step()
finally:
locked.chmod(0o755)
out, err = capsys.readouterr()
assert out.count("нет доступа к папке") == 1
assert "Traceback" not in err
r.step()
put_request(target, "p1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert response(target, "p1")["status"] == 200
def test_removed_req_dir_recovers(env, capsys):
r, root = env["relay"], env["root"]
r.step()
(root / "req").rmdir() # папку моста почистили при живом реле
for _ in range(3):
r.step()
assert (root / "req").is_dir()
assert "Traceback" not in capsys.readouterr().err
put_request(root, "q1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert response(root, "q1")["status"] == 200
def test_removed_work_dir_recovers(env, capsys):
r, root = env["relay"], env["root"]
r.step()
(root / "work").rmdir()
put_request(root, "w1", "GET", "/agent/settings", env["agent"])
for _ in range(3):
r.step()
r.join()
assert response(root, "w1")["status"] == 200
assert "нет доступа к папке" in capsys.readouterr().out
def test_removed_resp_dir_recreated_before_forward(env):
r, root = env["relay"], env["root"]
r.step()
(root / "resp").rmdir()
put_request(root, "r1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert response(root, "r1")["status"] == 200