fix: --stall-after Budget der Rettungsstation client-kumulativ zählen

Der Testschalter zählte bisher pro Anfrage, sodass eine Lampe nach ihrem
Empfangs-Timeout einfach per Range neu verband und ein frisches Budget bekam
– am EVK reichten fünf Reconnects, um trotz --stall-after das komplette
Image zu übertragen. Station verfolgt die ausgelieferten Bytes jetzt pro
Client-IP über alle Anfragen hinweg (Station.reserve_stall_bytes, unter dem
bestehenden Lock), sodass kein erneutes Verbinden das Budget auffüllt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Sauer
2026-09-13 00:40:55 +02:00
co-authored by Claude Sonnet 5
parent 2f25714248
commit 3f27ef4b26
2 changed files with 98 additions and 23 deletions
+42 -23
View File
@@ -61,16 +61,35 @@ class Station:
self.since = now_iso()
self.served = 0
self.recent: list[dict] = []
# 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
# every reconnect a brand-new --stall-after allowance and the stall would never
# actually stick (see reserve_stall_bytes below).
self.stall_sent: dict[str, int] = {}
# 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.
# can be downloading at once. Every mutation of served/recent/stall_sent 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 reserve_stall_bytes(self, ip: str, n: int) -> int:
"""How many of the next `n` bytes this client may still receive under --stall-after.
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
that the lamp abandons and resumes via Range does not get a fresh budget just
because it is a new TCP connection. Only called when stall_after is set.
"""
with self.lock:
sent_so_far = self.stall_sent.get(ip, 0)
allowed = max(0, min(n, self.stall_after - sent_so_far))
self.stall_sent[ip] = sent_so_far + allowed
return allowed
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
with self.lock:
if status == "ok":
@@ -175,31 +194,31 @@ class Handler(BaseHTTPRequestHandler):
sent = 0
status = "ok"
remaining = size - start
client_ip = self.client_address[0]
try:
with path.open("rb") as f:
f.seek(start)
while sent < remaining:
if self.station.stall_after is not None and sent >= self.station.stall_after:
# test aid: hold the connection open and send nothing more, so the
# firmware's stall handling can be exercised on real hardware. We
# still notice if the peer gives up and closes its side, so a test
# (or an impatient client) that hangs up is recorded as "aborted"
# instead of leaking the thread forever.
while True:
readable, _, _ = select.select([self.connection], [], [], 1.0)
if readable:
try:
peek = self.connection.recv(1, socket.MSG_PEEK)
except OSError:
peek = b""
if not peek:
raise ConnectionResetError("peer closed during stall")
read_size = CHUNK
read_size = min(CHUNK, remaining - sent)
if self.station.stall_after is not None:
# Don't overshoot the stall boundary within a single read: without
# this a whole CHUNK could be sent before the check above ever
# notices we've passed --stall-after.
read_size = min(read_size, max(1, self.station.stall_after - sent))
# Reserve against this client's cumulative budget, not just this
# request's byte count - see Station.reserve_stall_bytes.
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
if read_size == 0:
# test aid: hold the connection open and send nothing more, so
# the firmware's stall handling can be exercised on real
# hardware. We still notice if the peer gives up and closes its
# side, so a test (or an impatient client) that hangs up is
# recorded as "aborted" instead of leaking the thread forever.
while True:
readable, _, _ = select.select([self.connection], [], [], 1.0)
if readable:
try:
peek = self.connection.recv(1, socket.MSG_PEEK)
except OSError:
peek = b""
if not peek:
raise ConnectionResetError("peer closed during stall")
chunk = f.read(read_size)
if not chunk:
break