diff --git a/README.md b/README.md index b5d036c..1e5ddef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/jira_hub/relay.py b/src/jira_hub/relay.py index 8ab6f29..a6f93eb 100644 --- a/src/jira_hub/relay.py +++ b/src/jira_hub/relay.py @@ -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,22 +78,31 @@ class Relay: self._reload() if self.root is None: return - if not self.root.is_dir(): - if not self._warned: - say(f"relay: папки {self.root} нет — жду") - self._warned = True + try: + if not self.root.is_dir(): + self._warn(f"relay: папки {self.root} нет — жду") + 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._warn(f"relay: нет доступа к папке {self.root} — {e.strerror or e}; жду") 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: 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} с") diff --git a/tests/test_relay.py b/tests/test_relay.py index 7e1eb17..57c6f66 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -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