fix(relay): 502 на сбой протокола, work/ чистится один раз на папку, тест serve --host (#13)

M-1, M-3, M-4 (hub#8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:54:45 +04:00
parent bd8c769e75
commit 615530aa1b
2 changed files with 55 additions and 4 deletions

View File

@ -1,6 +1,7 @@
"""Реле через общую папку (specs/hub-api, «Реле через общую папку»): файлы-запросы агента из <relay_dir>/req/
пересылаются в HTTP этого же хаба, ответы кладутся в <relay_dir>/resp/. HTTP API, обработчики и токены не меняются;
реле для них — ещё один клиент. Нужно, когда VPN на виртуалке закрывает сеть до хаба (design.md folder-transport)."""
import http.client
import json
import os
import threading
@ -60,6 +61,7 @@ class Relay:
self._cleanup_at = 0.0
self._ready = False
self._warned: str | None = None
self._cleaned: Path | None = None # папка, чей work/ вычищен после старта реле
self.workers: list[threading.Thread] = []
# --- цикл ---------------------------------------------------------------------
@ -122,9 +124,15 @@ class Relay:
def _prepare(self) -> None:
for sub in ("req", "resp", "work"):
(self.root / sub).mkdir(exist_ok=True)
for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (перезапуск реле)")
if self._cleaned != self.root:
# первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает
for stale in (self.root / "work").iterdir():
stale.unlink(missing_ok=True)
say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)")
self._cleaned = self.root
else:
# пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем
say(f"relay: папка {self.root} подготовлена заново")
self._ready, self._warned = True, None
def _take(self, path: Path) -> None:
@ -193,7 +201,7 @@ class Relay:
e.close()
except ValueError as e: # недопустимый заголовок (перевод строки) или URL
return 400, _error("bad_request", str(e))
except (urllib.error.URLError, OSError) as e:
except (urllib.error.URLError, http.client.HTTPException, OSError) as e:
return 502, _error("relay", f"хаб недоступен изнутри: {e}")
# --- уборка ------------------------------------------------------------------------

View File

@ -1,3 +1,4 @@
import http.client
import json
import os
import threading
@ -324,3 +325,45 @@ def test_removed_resp_dir_recreated_before_forward(env):
put_request(root, "r1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert response(root, "r1")["status"] == 200
def test_protocol_error_gives_502(env, monkeypatch):
r, root = env["relay"], env["root"]
r.step()
def broken(*a, **k):
raise http.client.BadStatusLine("мусор")
monkeypatch.setattr("urllib.request.urlopen", broken)
put_request(root, "b1", "GET", "/agent/settings", env["agent"])
run_once(r)
resp = response(root, "b1")
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
def test_reprepare_keeps_work(env, capsys):
r, root = env["relay"], env["root"]
r.step() # первая подготовка
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
(root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново
for _ in range(3):
r.step()
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
out = capsys.readouterr().out
assert "перезапуск реле" not in out and "подготовлена заново" in out
def test_relay_targets_serve_host(tmp_path, monkeypatch):
seen = []
class Spy(relay.Relay):
def __init__(self, hub, port, host="127.0.0.1", poll=0.1):
seen.append(host)
super().__init__(hub, port, host, poll)
monkeypatch.setattr(relay, "Relay", Spy)
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
for host in ("0.0.0.0", "localhost"):
srv, stop = api.start(h, host, 0)
stop.set()
srv.server_close()
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)