fix: Nebenläufigkeit und verfrühtes "ok" in der Recovery-Station beheben
Fix-Runde 1 nach Review: Station.served und Station.recent wurden ohne Lock von mehreren Handler-Threads (ein Thread pro Verbindung unter ThreadingHTTPServer) mutiert - der Normalfall, wenn mehrere Lampen gleichzeitig laden. Ein threading.RLock schuetzt jetzt Mutation und Serialisierung nach status.json als eine Einheit. Ausserdem hatte mein vorheriger Fix fuer das Status-Race einen neuen Fehler eingefuehrt: "ok" wurde vor dem letzten wfile.write() vermerkt, sodass ein danach scheiternder Schreibvorgang trotzdem als Erfolg in status.json stand. record() laeuft jetzt erst, nachdem die Uebertragung tatsaechlich abgeschlossen oder gescheitert ist; die betroffenen Tests warten stattdessen auf den Eintrag statt sofortige Konsistenz vorauszusetzen. test_stall_after_stops_sending_but_keeps_the_connection prüfte bisher nur die gesendete Bytezahl und haette einen faelschlich schliessenden Server nicht bemerkt - ergaenzt um eine explizite Pruefung, dass ein weiterer recv() in einen Timeout laeuft statt EOF zu liefern. Neuer Test test_concurrent_downloads_are_all_recorded faehrt 20 gleichzeitige Downloads und prueft served/recent auf Vollstaendigkeit; er schlaegt zuverlaessig fehl, wenn das Lock entfernt wird (lokal gegengeprueft: 3 von 10 Laeufen ohne Lock verloren Eintraege, 10 von 10 mit Lock bestanden). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
37f4dd425e
commit
a2e87b8855
+27
-25
@@ -17,6 +17,7 @@ import select
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -59,29 +60,36 @@ class Station:
|
||||
self.since = now_iso()
|
||||
self.served = 0
|
||||
self.recent: list[dict] = []
|
||||
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
|
||||
# can be downloading at once. Every mutation of served/recent and the snapshot
|
||||
# that gets serialized to disk must happen under this same lock, or concurrent
|
||||
# requests lose increments and stomp on each other's status.json writes.
|
||||
self.lock = threading.RLock()
|
||||
self.write_state()
|
||||
|
||||
def image_path(self, target: str) -> Path:
|
||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||
|
||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||
if status == "ok":
|
||||
self.served += 1
|
||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
||||
"version": version, "bytes": nbytes, "status": status})
|
||||
del self.recent[:-RECENT_MAX]
|
||||
self.write_state()
|
||||
with self.lock:
|
||||
if status == "ok":
|
||||
self.served += 1
|
||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
||||
"version": version, "bytes": nbytes, "status": status})
|
||||
del self.recent[:-RECENT_MAX]
|
||||
self.write_state()
|
||||
|
||||
def write_state(self) -> None:
|
||||
payload = {"since": self.since, "served": self.served, "recent": self.recent}
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
os.replace(tmp, self.state_path)
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||
with self.lock:
|
||||
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
|
||||
try:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
os.replace(tmp, self.state_path)
|
||||
except OSError as e:
|
||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -150,7 +158,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
sent = 0
|
||||
status = "ok"
|
||||
recorded = False
|
||||
remaining = size - start
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
@@ -180,20 +187,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
chunk = f.read(read_size)
|
||||
if not chunk:
|
||||
break
|
||||
if sent + len(chunk) >= remaining:
|
||||
# Record success before the last bytes go out: the lamp (and our
|
||||
# tests) may observe the response completing and immediately check
|
||||
# status.json, so the state must already be on disk by then.
|
||||
self.station.record(self.client_address[0], target, version,
|
||||
sent + len(chunk), "ok")
|
||||
recorded = True
|
||||
self.wfile.write(chunk)
|
||||
sent += len(chunk)
|
||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||
status = "aborted"
|
||||
# Record only now that the transfer has actually finished (or failed): "ok" must
|
||||
# never be written before the last byte is confirmed sent, or status.json can
|
||||
# claim success for a device that never received the end of the image.
|
||||
self.log(206 if start else 200, sent, started)
|
||||
if not recorded:
|
||||
self.station.record(self.client_address[0], target, version, sent, status)
|
||||
self.station.record(self.client_address[0], target, version, sent, status)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
Reference in New Issue
Block a user