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 socket
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -59,29 +60,36 @@ class Station:
|
|||||||
self.since = now_iso()
|
self.since = now_iso()
|
||||||
self.served = 0
|
self.served = 0
|
||||||
self.recent: list[dict] = []
|
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()
|
self.write_state()
|
||||||
|
|
||||||
def image_path(self, target: str) -> Path:
|
def image_path(self, target: str) -> Path:
|
||||||
return self.image_dir / f"ninlight_{target}_ota.img"
|
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||||
if status == "ok":
|
with self.lock:
|
||||||
self.served += 1
|
if status == "ok":
|
||||||
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
self.served += 1
|
||||||
"version": version, "bytes": nbytes, "status": status})
|
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
||||||
del self.recent[:-RECENT_MAX]
|
"version": version, "bytes": nbytes, "status": status})
|
||||||
self.write_state()
|
del self.recent[:-RECENT_MAX]
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
def write_state(self) -> None:
|
def write_state(self) -> None:
|
||||||
payload = {"since": self.since, "served": self.served, "recent": self.recent}
|
with self.lock:
|
||||||
try:
|
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
|
||||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with os.fdopen(fd, "w") as f:
|
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||||
json.dump(payload, f, indent=2)
|
with os.fdopen(fd, "w") as f:
|
||||||
os.replace(tmp, self.state_path)
|
json.dump(payload, f, indent=2)
|
||||||
except OSError as e:
|
os.replace(tmp, self.state_path)
|
||||||
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
except OSError as e:
|
||||||
|
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -150,7 +158,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
status = "ok"
|
status = "ok"
|
||||||
recorded = False
|
|
||||||
remaining = size - start
|
remaining = size - start
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
@@ -180,20 +187,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
chunk = f.read(read_size)
|
chunk = f.read(read_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
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)
|
self.wfile.write(chunk)
|
||||||
sent += len(chunk)
|
sent += len(chunk)
|
||||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||||
status = "aborted"
|
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)
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import pytest
|
|||||||
|
|
||||||
SERVER = Path(__file__).resolve().parents[1] / "server.py"
|
SERVER = Path(__file__).resolve().parents[1] / "server.py"
|
||||||
TRAILER_SIZE = 512
|
TRAILER_SIZE = 512
|
||||||
|
RECENT_MAX = 8
|
||||||
|
|
||||||
|
|
||||||
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
|
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
|
||||||
@@ -60,15 +62,29 @@ class Station:
|
|||||||
def conn(self):
|
def conn(self):
|
||||||
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||||
|
|
||||||
def read_state(self):
|
def read_state(self, predicate=None):
|
||||||
for _ in range(50):
|
"""Reads status.json, optionally waiting for it to satisfy `predicate`.
|
||||||
|
|
||||||
|
The server only writes the entry for a request after that request's response has
|
||||||
|
actually finished (or failed) on the wire - by design, so it never claims success
|
||||||
|
before the last byte is confirmed sent (see server.py). A client that has just
|
||||||
|
finished reading its own response can therefore still see a status.json that is a
|
||||||
|
few milliseconds stale. Rather than have the server promise something before it is
|
||||||
|
true, we wait here for the state to catch up.
|
||||||
|
"""
|
||||||
|
last = None
|
||||||
|
for _ in range(100):
|
||||||
if self.state.exists():
|
if self.state.exists():
|
||||||
try:
|
try:
|
||||||
return json.loads(self.state.read_text())
|
last = json.loads(self.state.read_text())
|
||||||
|
if predicate is None or predicate(last):
|
||||||
|
return last
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
raise AssertionError("no state file written")
|
if last is None:
|
||||||
|
raise AssertionError("no state file written")
|
||||||
|
raise AssertionError(f"state never satisfied predicate, last seen: {last!r}")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
@@ -137,7 +153,7 @@ def test_state_file_records_a_delivery(station):
|
|||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
c.getresponse().read()
|
c.getresponse().read()
|
||||||
state = station.read_state()
|
state = station.read_state(lambda s: s["served"] >= 1)
|
||||||
assert state["served"] == 1
|
assert state["served"] == 1
|
||||||
entry = state["recent"][-1]
|
entry = state["recent"][-1]
|
||||||
assert entry["target"] == "evk"
|
assert entry["target"] == "evk"
|
||||||
@@ -151,7 +167,7 @@ def test_state_file_records_a_missing_image(station):
|
|||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_g95_cw_ota.img")
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||||
c.getresponse().read()
|
c.getresponse().read()
|
||||||
state = station.read_state()
|
state = station.read_state(lambda s: len(s["recent"]) >= 1)
|
||||||
assert state["served"] == 0
|
assert state["served"] == 0
|
||||||
assert state["recent"][-1]["status"] == "notfound"
|
assert state["recent"][-1]["status"] == "notfound"
|
||||||
assert state["recent"][-1]["target"] == "g95_cw"
|
assert state["recent"][-1]["target"] == "g95_cw"
|
||||||
@@ -163,9 +179,9 @@ def test_recent_is_capped_at_eight(station):
|
|||||||
c = station.conn()
|
c = station.conn()
|
||||||
c.request("GET", "/ninlight_evk_ota.img")
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
c.getresponse().read()
|
c.getresponse().read()
|
||||||
state = station.read_state()
|
state = station.read_state(lambda s: s["served"] >= 12)
|
||||||
assert state["served"] == 12
|
assert state["served"] == 12
|
||||||
assert len(state["recent"]) == 8
|
assert len(state["recent"]) == RECENT_MAX
|
||||||
|
|
||||||
|
|
||||||
def test_aborted_transfer_is_recorded(tmp_path):
|
def test_aborted_transfer_is_recorded(tmp_path):
|
||||||
@@ -208,8 +224,61 @@ def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
|||||||
got += chunk
|
got += chunk
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
pass
|
pass
|
||||||
# far less than the full image, and the peer never closed the connection
|
# far less than the full image
|
||||||
assert len(got) < 20000
|
assert len(got) < 20000
|
||||||
|
# and the connection is genuinely still open: a server that (wrongly) closed it
|
||||||
|
# after the stall boundary would make this recv() return b"" (EOF) instead of
|
||||||
|
# timing out. Just counting received bytes above would not catch that - a server
|
||||||
|
# closing the connection right after --stall-after also sends far less than
|
||||||
|
# 20000 bytes.
|
||||||
|
sock.settimeout(1)
|
||||||
|
with pytest.raises(socket.timeout):
|
||||||
|
sock.recv(1)
|
||||||
sock.close()
|
sock.close()
|
||||||
finally:
|
finally:
|
||||||
s.stop()
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_downloads_are_all_recorded(station):
|
||||||
|
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
||||||
|
|
||||||
|
Station.served is a shared counter and Station.recent a shared list, mutated from one
|
||||||
|
handler thread per connection under ThreadingHTTPServer. Without a lock around the
|
||||||
|
read-modify-write of `served` and the recent-list mutation + serialize-to-disk step,
|
||||||
|
concurrent requests can lose increments (two threads read the same old value) or have
|
||||||
|
one thread's status.json write clobber another's.
|
||||||
|
"""
|
||||||
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
||||||
|
expected_len = 5000 + TRAILER_SIZE
|
||||||
|
n_workers = 20
|
||||||
|
errors = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
|
r = c.getresponse()
|
||||||
|
body = r.read()
|
||||||
|
if r.status != 200 or len(body) != expected_len:
|
||||||
|
with results_lock:
|
||||||
|
errors.append((r.status, len(body)))
|
||||||
|
except Exception as e: # pragma: no cover - surfaced via the errors list below
|
||||||
|
with results_lock:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
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 not errors, f"some downloads failed or returned the wrong body: {errors}"
|
||||||
|
|
||||||
|
state = station.read_state(lambda s: s["served"] >= n_workers)
|
||||||
|
assert state["served"] == n_workers
|
||||||
|
assert len(state["recent"]) == min(n_workers, RECENT_MAX)
|
||||||
|
for entry in state["recent"]:
|
||||||
|
assert entry["status"] == "ok"
|
||||||
|
assert entry["target"] == "evk"
|
||||||
|
assert entry["bytes"] == expected_len
|
||||||
|
|||||||
Reference in New Issue
Block a user