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 с. В логе — строка
|
Просроченные запросы выбрасываются; `claim` пересылается, только если до срока ≥ 35 с. В логе — строка
|
||||||
`relay <метод> <путь> <код> <секунды>` на запрос. Круг через VirtioFS ≈ 0,8 с.
|
`relay <метод> <путь> <код> <секунды>` на запрос. Круг через VirtioFS ≈ 0,8 с.
|
||||||
|
|
||||||
Если launchd-хаб не видит папку (в логе «папки … нет — жду» при подключённом SSD) — macOS не пускает процесс на внешний
|
Если launchd-хаб не видит папку (в логе «relay: нет доступа к папке … — Operation not permitted; жду» или «папки … нет —
|
||||||
диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv`
|
жду» при подключённом SSD) — macOS не пускает процесс на внешний диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv`
|
||||||
и python из `.venv` хаба, перезапустить задание.
|
и python из `.venv` хаба, перезапустить задание.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class Relay:
|
|||||||
self._settings_at: float | None = None
|
self._settings_at: float | None = None
|
||||||
self._cleanup_at = 0.0
|
self._cleanup_at = 0.0
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._warned = False
|
self._warned: str | None = None
|
||||||
self.workers: list[threading.Thread] = []
|
self.workers: list[threading.Thread] = []
|
||||||
|
|
||||||
# --- цикл ---------------------------------------------------------------------
|
# --- цикл ---------------------------------------------------------------------
|
||||||
@ -78,22 +78,31 @@ class Relay:
|
|||||||
self._reload()
|
self._reload()
|
||||||
if self.root is None:
|
if self.root is None:
|
||||||
return
|
return
|
||||||
if not self.root.is_dir():
|
try:
|
||||||
if not self._warned:
|
if not self.root.is_dir():
|
||||||
say(f"relay: папки {self.root} нет — жду")
|
self._warn(f"relay: папки {self.root} нет — жду")
|
||||||
self._warned = True
|
self._ready = False
|
||||||
|
return
|
||||||
|
if not self._ready:
|
||||||
|
self._prepare()
|
||||||
|
for path in sorted((self.root / "req").iterdir()):
|
||||||
|
if path.name.startswith(".") or not path.name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
self._take(path)
|
||||||
|
except OSError as e: # нет доступа (TCC), подпапку удалили — подготовить заново на следующем шаге
|
||||||
self._ready = False
|
self._ready = False
|
||||||
|
self._warn(f"relay: нет доступа к папке {self.root} — {e.strerror or e}; жду")
|
||||||
return
|
return
|
||||||
if not self._ready:
|
|
||||||
self._prepare()
|
|
||||||
for path in sorted((self.root / "req").iterdir()):
|
|
||||||
if path.name.startswith(".") or not path.name.endswith(".json"):
|
|
||||||
continue
|
|
||||||
self._take(path)
|
|
||||||
if now - self._cleanup_at >= CLEANUP_EVERY:
|
if now - self._cleanup_at >= CLEANUP_EVERY:
|
||||||
self._cleanup_at = now
|
self._cleanup_at = now
|
||||||
self.cleanup(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:
|
def join(self, timeout: float = 5) -> None:
|
||||||
for w in list(self.workers):
|
for w in list(self.workers):
|
||||||
w.join(timeout)
|
w.join(timeout)
|
||||||
@ -107,7 +116,7 @@ class Relay:
|
|||||||
conn.close()
|
conn.close()
|
||||||
root = Path(value) if value else None
|
root = Path(value) if value else None
|
||||||
if root != self.root:
|
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: выключено")
|
say(f"relay: папка {root}" if root else "relay: выключено")
|
||||||
|
|
||||||
def _prepare(self) -> None:
|
def _prepare(self) -> None:
|
||||||
@ -116,14 +125,16 @@ class Relay:
|
|||||||
for stale in (self.root / "work").iterdir():
|
for stale in (self.root / "work").iterdir():
|
||||||
stale.unlink(missing_ok=True)
|
stale.unlink(missing_ok=True)
|
||||||
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)")
|
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)")
|
||||||
self._ready, self._warned = True, False
|
self._ready, self._warned = True, None
|
||||||
|
|
||||||
def _take(self, path: Path) -> None:
|
def _take(self, path: Path) -> None:
|
||||||
target = self.root / "work" / path.name
|
target = self.root / "work" / path.name
|
||||||
try:
|
try:
|
||||||
os.rename(path, target)
|
os.rename(path, target)
|
||||||
except FileNotFoundError: # агент успел удалить свой просроченный запрос
|
except FileNotFoundError:
|
||||||
return
|
if path.exists(): # пропала work/ — step() подготовит папку заново
|
||||||
|
raise
|
||||||
|
return # агент успел удалить свой просроченный запрос
|
||||||
worker = threading.Thread(target=self.process, args=(self.root, target), daemon=True)
|
worker = threading.Thread(target=self.process, args=(self.root, target), daemon=True)
|
||||||
worker.start()
|
worker.start()
|
||||||
self.workers = [w for w in self.workers if w.is_alive()] + [worker]
|
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):
|
if left <= 0 or (method == "POST" and path == CLAIM_PATH and left < CLAIM_MIN_LEFT):
|
||||||
say(f"relay {method} {path} просрочен (осталось {left:.0f} с) — выброшен")
|
say(f"relay {method} {path} просрочен (осталось {left:.0f} с) — выброшен")
|
||||||
return
|
return
|
||||||
|
(root / "resp").mkdir(exist_ok=True) # до пересылки: выданный claim не должен уйти в никуда
|
||||||
status, body = self.forward(method, path, req.get("body"), req.get("authorization"))
|
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})
|
write_atomic(root / "resp", work.name, {"id": req.get("id"), "status": status, "body": body})
|
||||||
say(f"relay {method} {path} {status} {time.monotonic() - started:.1f} с")
|
say(f"relay {method} {path} {status} {time.monotonic() - started:.1f} с")
|
||||||
|
|||||||
@ -267,3 +267,60 @@ def test_serve_runs_relay(tmp_path):
|
|||||||
srv.shutdown()
|
srv.shutdown()
|
||||||
srv.server_close()
|
srv.server_close()
|
||||||
th.join(5)
|
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