fix(station): Freigabe atomar verbrauchen, Schreibfehler nicht stillschweigend armed lassen
Fixrunde 1 zu Task 3 (Migrationsroute):
- C1 (Critical): read_control()/Phasenpruefung/Auslieferung/write_control("listen") liefen
ohne gemeinsame Sperre - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst- und
Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und beide
ausgeliefert bekommen. Neues Station.claim_delivery() liest und verbraucht die Freigabe
jetzt atomar unter station.lock, vor Beginn der Auslieferung; Station.restore_armed()
macht das bei einem Abbruch rueckgaengig.
- I2 (Important): eine unerwartet kurz gelesene Datei zaehlte im Sendeloop als "ok" statt
als "aborted" - relevant geworden, weil dieser Status jetzt entscheidet, ob eine armed-
Freigabe restauriert wird.
- I3 (Important): ein fehlschlagendes write_control() (z.B. schreibgeschuetztes /run)
verschluckte den Fehler und liess "armed" dauerhaft scharf. Der Fehler landet jetzt in
status.json (control_error), und claim_delivery() laesst nur die eine ausloesende
Anfrage noch durch, bis ein Schreibversuch wieder gelingt.
- I4 (Important): die neuen Server-Tests synchronisierten sich per fester Pause statt wie
Station.read_state() bis zu einem Praedikat zu pollen. status()/read_control() im
Testklienten tun das jetzt ebenfalls.
- I5 (Important): resolve_mode()/--mode-file waren ungetestet trotz bindender
Vorrangregel, auf der Task 4 aufbaut - fuenf neue reine Funktionstests.
Dazu: recent[0] -> recent[-1] in allen betroffenen Tests (Brief-Fehler), ein neuer
Rechte-Test fuer control.json (0644 unter restriktivem umask), und eine neue
Nebenlaeufigkeits-Regression fuer C1.
This commit is contained in:
@@ -472,20 +472,28 @@ class _Response:
|
||||
|
||||
class MigrationTestServer:
|
||||
"""Startet server.py mit --mode/--control und bietet die vom Brief geforderten
|
||||
Hilfsmethoden. server.py schreibt status.json/control.json fuer eine Anfrage im
|
||||
selben Handler-Thread, unmittelbar nachdem der Antwortkoerper vollstaendig gesendet
|
||||
wurde - aber http.client's read() kann zurueckkehren, sobald Content-Length Bytes
|
||||
angekommen sind, was diesem serverseitigen Schreiben knapp zuvorkommen kann. get()
|
||||
legt deshalb eine kurze Pause ein, bevor es zurueckkehrt (derselbe Grund, aus dem
|
||||
Station.read_state() oben mit einem Prädikat auf status.json wartet - hier gibt es
|
||||
kein generisches Prädikat, das jeder Aufrufer kennt, darum die feste kleine Pause statt
|
||||
einer Wiederholschleife)."""
|
||||
Hilfsmethoden.
|
||||
|
||||
status()/read_control() folgen demselben Muster wie Station.read_state() oben
|
||||
(Zeilen 68-90): sie pollen bis zu einigen Sekunden auf ein optionales Praedikat, statt
|
||||
sich auf eine feste Pause nach get() zu verlassen. Grund ist derselbe wie dort -
|
||||
server.py schreibt status.json/control.json fuer eine Anfrage erst, nachdem der
|
||||
Antwortkoerper vollstaendig gesendet ist, aber http.client's read() kann zurueckkehren,
|
||||
sobald Content-Length Bytes angekommen sind, was diesem Schreiben knapp zuvorkommen
|
||||
kann. Jeder Aufrufer kennt sein eigenes Praedikat (das ohnehin schon als Assertion
|
||||
dasteht), daher kein pauschales Warten hier."""
|
||||
|
||||
def __init__(self, tmp_path, mode):
|
||||
self.image_dir = tmp_path / "images"
|
||||
self.image_dir.mkdir()
|
||||
self.state_path = tmp_path / "status.json"
|
||||
self.control_path = tmp_path / "control.json"
|
||||
# Eigenes Unterverzeichnis fuer die Steuerdatei, getrennt von status.json: ein Test
|
||||
# fuer I3 (Fixrunde 1) macht genau dieses Verzeichnis schreibgeschuetzt, um einen
|
||||
# fehlschlagenden write_control() zu erzwingen, und darf dabei nicht auch das
|
||||
# Schreiben von status.json lahmlegen, ueber das derselbe Test den Fehler beobachtet.
|
||||
control_dir = tmp_path / "control"
|
||||
control_dir.mkdir()
|
||||
self.control_path = control_dir / "control.json"
|
||||
self.port = free_port()
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
|
||||
@@ -511,30 +519,34 @@ class MigrationTestServer:
|
||||
conn.request("GET", path, headers=headers or {})
|
||||
r = conn.getresponse()
|
||||
body = r.read()
|
||||
resp = _Response(r.status, dict(r.getheaders()), body)
|
||||
return _Response(r.status, dict(r.getheaders()), body)
|
||||
finally:
|
||||
conn.close()
|
||||
# See the class docstring: a short pause so the server's own status.json/
|
||||
# control.json write for this request has landed before we return.
|
||||
time.sleep(0.05)
|
||||
return resp
|
||||
|
||||
def _read_json(self, path, timeout=2.0):
|
||||
deadline = time.time() + timeout
|
||||
def _poll_json(self, path, predicate=None, timeout=5.0):
|
||||
"""Wie Station.read_state() oben: liest `path` wiederholt (bis zu `timeout`
|
||||
Sekunden), bis er sich parsen laesst und - falls angegeben - `predicate` erfuellt.
|
||||
Ohne predicate reicht ein einziges erfolgreiches Parsen, genau wie dort."""
|
||||
last = None
|
||||
last_err = None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
last = json.loads(path.read_text())
|
||||
if predicate is None or predicate(last):
|
||||
return last
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
time.sleep(0.02)
|
||||
raise AssertionError(f"could not read {path}: {last_err}")
|
||||
time.sleep(0.02)
|
||||
if last is None:
|
||||
raise AssertionError(f"could not read {path}: {last_err}")
|
||||
raise AssertionError(f"{path} never satisfied predicate, last seen: {last!r}")
|
||||
|
||||
def status(self):
|
||||
return self._read_json(self.state_path)
|
||||
def status(self, predicate=None):
|
||||
return self._poll_json(self.state_path, predicate)
|
||||
|
||||
def read_control(self):
|
||||
return self._read_json(self.control_path)
|
||||
def read_control(self, predicate=None):
|
||||
return self._poll_json(self.control_path, predicate)
|
||||
|
||||
def set_control(self, phase, armed_target=None):
|
||||
tmp = self.control_path.with_name(self.control_path.name + ".tmp")
|
||||
@@ -577,8 +589,9 @@ def test_migration_auto_serves_known_product(migration_server):
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
|
||||
assert r.status == 200
|
||||
assert r.body == b"IMG" * 1000
|
||||
assert srv.status()["last_seen"]["target"] == "g95_cw"
|
||||
assert srv.status()["last_seen"]["variant"] == "dev"
|
||||
st = srv.status(lambda s: s.get("last_seen") is not None)
|
||||
assert st["last_seen"]["target"] == "g95_cw"
|
||||
assert st["last_seen"]["variant"] == "dev"
|
||||
|
||||
|
||||
def test_migration_listen_records_but_serves_nothing(migration_server):
|
||||
@@ -588,10 +601,10 @@ def test_migration_listen_records_but_serves_nothing(migration_server):
|
||||
srv.set_control(phase="listen")
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
st = srv.status()
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["last_seen"]["target"] == "g95_cw" # erkannt
|
||||
assert st["served"] == 0 # aber nichts ausgeliefert
|
||||
assert st["recent"][0]["status"] == "listen"
|
||||
assert st["recent"][-1]["status"] == "listen"
|
||||
|
||||
|
||||
def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
|
||||
@@ -601,7 +614,7 @@ def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
assert srv.get(p).status == 200
|
||||
assert srv.read_control()["phase"] == "listen"
|
||||
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||
assert srv.get(p).status == 404
|
||||
|
||||
|
||||
@@ -619,14 +632,16 @@ def test_unknown_product_id_is_404_and_logged(migration_server):
|
||||
srv, _ = migration_server
|
||||
r = srv.get("/qcx_firmware_-acme.bulb_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
assert srv.status()["recent"][0]["status"] == "unknown"
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["status"] == "unknown"
|
||||
|
||||
|
||||
def test_missing_migrate_image_is_404(migration_server):
|
||||
srv, _ = migration_server
|
||||
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
|
||||
assert r.status == 404
|
||||
assert srv.status()["recent"][0]["status"] == "notfound"
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["status"] == "notfound"
|
||||
|
||||
|
||||
def test_recovery_path_is_404_in_migration_mode(migration_server):
|
||||
@@ -678,4 +693,146 @@ def test_version_comes_from_manifest(migration_server):
|
||||
"ninlight_version": "0.3.9",
|
||||
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]}))
|
||||
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
|
||||
assert srv.status()["recent"][0]["version"] == "0.3.9"
|
||||
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
||||
assert st["recent"][-1]["version"] == "0.3.9"
|
||||
|
||||
|
||||
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
||||
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
||||
control.json: das CLI aus Task 4 muss die Datei lesen UND schreiben koennen."""
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
srv = MigrationTestServer(tmp_path, "migration")
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
try:
|
||||
(srv.image_dir / "migrate").mkdir(exist_ok=True)
|
||||
(srv.image_dir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
|
||||
def world_readable():
|
||||
mode = stat.S_IMODE(srv.control_path.stat().st_mode)
|
||||
return bool(mode & stat.S_IROTH)
|
||||
|
||||
# set_control() oben schreibt direkt aus dem Test, nicht ueber den Server - das
|
||||
# will hier nicht geprueft werden. Erst die Anfrage unten bringt den Server selbst
|
||||
# dazu, control.json zu schreiben (armed -> listen), also denselben Codepfad
|
||||
# (Station.write_control()) wie write_state() fuer status.json.
|
||||
assert srv.get(
|
||||
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
||||
srv.read_control(lambda _: world_readable())
|
||||
assert world_readable(), "control.json ist nach dem Schreiben durch den Server " \
|
||||
"nicht welt-lesbar"
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_armed_serves_exactly_once_under_concurrency(migration_server):
|
||||
"""C1 (Fixrunde 1): read_control()/Phasenpruefung/Auslieferung/write_control("listen")
|
||||
liefen ohne gemeinsame Sperre ab - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst-
|
||||
und Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und
|
||||
beide die volle Auslieferung bekommen. station.claim_delivery() muss das Lesen und das
|
||||
Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
|
||||
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
|
||||
n_workers = 20
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
r = srv.get(p)
|
||||
with results_lock:
|
||||
results.append(r.status)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert len(results) == n_workers, "nicht alle Worker sind fertig geworden"
|
||||
assert results.count(200) == 1, f"erwartet genau eine 200, bekommen: {results}"
|
||||
assert results.count(404) == n_workers - 1
|
||||
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
||||
|
||||
|
||||
def test_control_write_failure_blocks_further_armed_deliveries(migration_server):
|
||||
"""I3 (Fixrunde 1): schlaegt das Schreiben von control.json fehl (z.B. volles oder
|
||||
schreibgeschuetztes /run), darf die Freigabe nicht dauerhaft auf "armed" stehen bleiben
|
||||
- sonst wuerde jede weitere Lampe des Ziels ebenfalls bedient, genau die Luecke aus C1,
|
||||
nur diesmal durch einen Schreibfehler statt durch Nebenlaeufigkeit ausgeloest. Die eine
|
||||
Anfrage, die den Fehler ausloest, bekommt noch ihre Auslieferung (wie eine normale
|
||||
Freigabe); jede weitere schlaegt fehl, bis das Schreiben wieder klappt. Der Fehler muss
|
||||
ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
|
||||
mehr wirkt."""
|
||||
srv, imgdir = migration_server
|
||||
(imgdir / "migrate").mkdir(exist_ok=True)
|
||||
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
|
||||
srv.set_control(phase="armed", armed_target="g95_cw")
|
||||
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
||||
|
||||
control_dir = srv.control_path.parent
|
||||
old_mode = control_dir.stat().st_mode
|
||||
os.chmod(control_dir, 0o500) # r-x fuer den Besitzer, kein Schreiben mehr moeglich
|
||||
try:
|
||||
r1 = srv.get(p)
|
||||
assert r1.status == 200, "die eine vorgesehene Auslieferung muss noch rausgehen"
|
||||
st = srv.status(lambda s: s.get("control_error"))
|
||||
assert st["control_error"], "Schreibfehler muss in status.json sichtbar sein"
|
||||
|
||||
r2 = srv.get(p)
|
||||
assert r2.status == 404, \
|
||||
"ein Schreibfehler darf die Freigabe nicht dauerhaft offen lassen"
|
||||
finally:
|
||||
os.chmod(control_dir, old_mode)
|
||||
|
||||
|
||||
def _load_server_module():
|
||||
"""Laedt server.py wie test_log_error_is_not_silenced_by_log_message oben - als
|
||||
eigenstaendiges Modulobjekt, ohne es in sys.modules einzutragen, damit mehrfache
|
||||
Aufrufe (einer je resolve_mode-Test) sich nicht in die Quere kommen."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ninlight_station_server_rm", SERVER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_resolve_mode_explicit_mode_wins_over_file(tmp_path):
|
||||
"""I5 (Fixrunde 1): --mode schlaegt --mode-file."""
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("migration")
|
||||
assert module.resolve_mode("recovery", mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_reads_the_mode_file(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("migration")
|
||||
assert module.resolve_mode(None, mode_file) == "migration"
|
||||
|
||||
|
||||
def test_resolve_mode_missing_file_means_recovery(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode" # bewusst nicht angelegt
|
||||
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_garbage_file_means_recovery(tmp_path):
|
||||
module = _load_server_module()
|
||||
mode_file = tmp_path / "mode"
|
||||
mode_file.write_text("garbage\n")
|
||||
assert module.resolve_mode(None, mode_file) == "recovery"
|
||||
|
||||
|
||||
def test_resolve_mode_default_without_mode_file_is_recovery():
|
||||
module = _load_server_module()
|
||||
assert module.resolve_mode(None, None) == "recovery"
|
||||
|
||||
Reference in New Issue
Block a user