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:
+77
-9
@@ -78,6 +78,13 @@ class Station:
|
||||
self.last_seen: dict | None = None
|
||||
self._manifest_mtime: float | None = None
|
||||
self._manifest_version = "unbekannt"
|
||||
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
|
||||
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
||||
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
|
||||
# Platte geschrieben werden konnte (Fixrunde 1, I3). control_error landet in
|
||||
# status.json, damit ein Bedienfehler nicht nur auf stdout verschwindet.
|
||||
self._control_write_broken = False
|
||||
self.control_error: str | None = None
|
||||
# Cumulative bytes already delivered to each client while --stall-after is active,
|
||||
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
|
||||
# resumes with Range right where it left off, so a per-request counter would hand
|
||||
@@ -150,7 +157,51 @@ class Station:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.control_path}: {e}", flush=True)
|
||||
self._control_write_broken = True
|
||||
self.control_error = f"cannot write {self.control_path}: {e}"
|
||||
print(f"warning: {self.control_error}", flush=True)
|
||||
else:
|
||||
self._control_write_broken = False
|
||||
self.control_error = None
|
||||
|
||||
def claim_delivery(self, target: str) -> tuple[str, str | None, bool]:
|
||||
"""Entscheidet unter dem Lock, ob eine Anfrage fuer `target` gerade ausgeliefert
|
||||
werden darf, und verbraucht - falls die Freigabe dafuer verantwortlich ist - diese
|
||||
sofort per Compare-and-Set (lesen und auf "listen" setzen, ohne das Lock
|
||||
dazwischen loszulassen). Ohne das koennten zwei gleichzeitige Anfragen (zwei
|
||||
Lampen, oder Erst- und Range-Resume-Verbindung derselben Lampe) beide phase ==
|
||||
"armed" sehen und beide ausgeliefert bekommen (Fixrunde 1, C1) - genau das Szenario,
|
||||
fuer das die Freigabephase laut Spec 3.3 da ist.
|
||||
|
||||
Liefert (phase, armed_target) wie sie VOR dem Verbrauch galten, plus ob die
|
||||
aktuelle Anfrage ausliefern darf. Bricht die Auslieferung anschliessend ab
|
||||
(status == "aborted") oder findet der Aufrufer kein Image, muss er bei
|
||||
phase == "armed" restore_armed(target) aufrufen, damit die Freigabe fuer den
|
||||
naechsten Bootversuch stehen bleibt.
|
||||
|
||||
Schlaegt das Schreiben von "listen" fehl (I3), bekommt nur die eine Anfrage, die
|
||||
den Fehler zuerst ausloest, noch ihre Auslieferung - jede weitere schlaegt hier
|
||||
erneut fehl (_control_write_broken bleibt True), bis ein write_control()-Versuch
|
||||
wieder klappt. Damit bleibt "armed" nicht dauerhaft scharf, nur weil /run gerade
|
||||
nicht beschreibbar ist, heilt sich aber von selbst, sobald es das wieder ist."""
|
||||
with self.lock:
|
||||
phase, armed_target = self.read_control()
|
||||
if phase == "listen" or (phase == "armed" and armed_target != target):
|
||||
return phase, armed_target, False
|
||||
if phase == "armed":
|
||||
was_broken = self._control_write_broken
|
||||
self.write_control("listen", None)
|
||||
if self._control_write_broken:
|
||||
return phase, armed_target, not was_broken
|
||||
return phase, armed_target, True
|
||||
|
||||
def restore_armed(self, target: str) -> None:
|
||||
"""Setzt eine per claim_delivery() verbrauchte Freigabe zurueck, wenn die
|
||||
Auslieferung danach doch nicht (vollstaendig) stattgefunden hat - ein Abbruch darf
|
||||
die Freigabe nicht verbrauchen, sonst muesste am Geraet erneut von Hand gearmt
|
||||
werden, obwohl die Lampe es beim naechsten Bootversuch selbst nochmal versuchen
|
||||
wuerde (Spec 3.3, Brief-Schritt 3.6)."""
|
||||
self.write_control("armed", target)
|
||||
|
||||
def reserve_stall_bytes(self, ip: str, n: int) -> int:
|
||||
"""How many of the next `n` bytes this client may still receive under --stall-after.
|
||||
@@ -205,6 +256,7 @@ class Station:
|
||||
payload["phase"] = phase
|
||||
payload["armed_target"] = armed_target
|
||||
payload["last_seen"] = self.last_seen
|
||||
payload["control_error"] = self.control_error
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
@@ -322,9 +374,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
target, variant, name = product.target, product.variant, product.name
|
||||
version = self.station.manifest_version()
|
||||
phase, armed_target = self.station.read_control()
|
||||
# claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter
|
||||
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
|
||||
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
||||
phase, armed_target, allowed = self.station.claim_delivery(target)
|
||||
|
||||
if phase == "listen" or (phase == "armed" and armed_target != target):
|
||||
if not allowed:
|
||||
# last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase:
|
||||
# die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht.
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||
@@ -335,6 +390,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
path = self.station.migrate_image_path(target, variant)
|
||||
if not path.is_file():
|
||||
if phase == "armed":
|
||||
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
|
||||
# dass es gar kein Image gibt - das darf nicht auf Kosten der Freigabe gehen.
|
||||
self.station.restore_armed(target)
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
|
||||
version, 0, "notfound")
|
||||
self.reject(404, "no image for this device")
|
||||
@@ -342,12 +401,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
sent, status = self._serve_file(path, started)
|
||||
if phase == "armed" and status == "ok":
|
||||
# Erst nach dem vollstaendigen Senden zurueck auf "listen" - _serve_file() ist
|
||||
# an dieser Stelle bereits fertig. Bricht der Transfer ab (status == "aborted"),
|
||||
# bleibt die Freigabe stehen, damit die Lampe es beim naechsten Boot noch
|
||||
# einmal versuchen kann.
|
||||
self.station.write_control("listen", None)
|
||||
if phase == "armed" and status == "aborted":
|
||||
# Bricht der Transfer ab, bleibt die Freigabe stehen, damit die Lampe es beim
|
||||
# naechsten Boot noch einmal versuchen kann (Spec 3.3). claim_delivery() hat sie
|
||||
# bereits auf "listen" gesetzt, bevor _serve_file() ueberhaupt anfing zu senden
|
||||
# (das ist der Punkt von C1) - bei einem Abbruch wird das hier zurueckgenommen.
|
||||
self.station.restore_armed(target)
|
||||
self.station.record_migration(ip, target, variant, pid, lamp_version, name, version,
|
||||
sent, status)
|
||||
|
||||
@@ -403,6 +462,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise ConnectionResetError("peer closed during stall")
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
# Die Datei ist kuerzer als das eingangs per stat() ermittelte
|
||||
# Content-Length (z.B. von deploy.sh waehrend einer laufenden
|
||||
# Auslieferung durch eine kleinere ersetzt) - sent < remaining an
|
||||
# dieser Stelle immer, denn die Schleifenbedingung oben haette sonst
|
||||
# schon abgebrochen. Das ist ein abgebrochener, kein erfolgreicher
|
||||
# Transfer (Fixrunde 1, I2): ohne dies wuerde eine im Migrationsmodus
|
||||
# laufende "armed"-Freigabe faelschlich als verbraucht gelten, obwohl
|
||||
# die Lampe nie das vollstaendige Image bekommen hat.
|
||||
status = "aborted"
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
sent += len(chunk)
|
||||
|
||||
Reference in New Issue
Block a user