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:
parent
36bfd95a87
commit
4a82be05cc
@ -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
|
||||
|
||||
@ -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} с")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user