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:
Michael Sauer
2026-09-13 18:22:49 +02:00
parent b41ed51018
commit d3e608dca0
2 changed files with 265 additions and 40 deletions
+188 -31
View File
@@ -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"