Files
bootloader/test/test_handshake.py
BlackMark c8ac61779e tool: survive our own leftovers — drain the fresh port, shorten the identity read
--stay leaves the loader's final prompt in the USB pipeline; a fresh
invocation on a board that resets when its port opens then flushes too
early, trusts the stale prompt, and spends the new activation window on
a 2-second identity read against a device that never heard its knock —
collecting the application's banner as an unknown signature. Three
host-side moves, no device bytes: the line is drained until quiet
(bounded, 250 ms) before the port's first knock — once per port, since a
mid-session re-knock faces no foreign bytes and its own window is
already burning; the identity read_exact drops 2.0 to 0.5 s, dozens of
times the worst real answer, so any false prompt match leaves room for
the retry that already works; and the tool version drifts to 8. The
StaleDTRPort fixture models the whole moment — stale prompt in transit,
reset holding the device off the line, a finite window, the banner —
red against the old tool in exactly the field shape (unknown signature
from banner bytes), green now; LoaderPort answers its prompt to the
knock rather than to a read count, which the drain exposed as a
call-order coupling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:05:16 +02:00

213 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Host-tool activation handshake: bounded against a line that misbehaves.
`_handshake` drains the line after it sees a prompt, to absorb a real loader's
trailing bytes before it asks for the identity. That drain must be bounded: a
target that never falls quiet — a board stuck in a reset loop presents exactly
this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects.
The handshake must also survive its own leftovers: after `--stay` the loader's
final prompt can still be in the USB pipeline when the next invocation opens
the port, and on a board wired to reset on open, that opening starts a fresh
activation window the stale prompt then betrays — the tool commits to an
identity read against a device that never heard its knock, and what it finally
collects is the application's banner. StaleDTRPort is that moment as a port.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset
beside pureboot.planner.
"""
import importlib.util
import pathlib
import threading
import time
PB = pathlib.Path(__file__).resolve().parents[1] / "pureboot" / "pureboot.py"
_spec = importlib.util.spec_from_file_location("pureboot", PB)
pb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(pb)
P = F = 0
def check(name, ok):
global P, F
P, F = P + (1 if ok else 0), F + (0 if ok else 1)
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
class FloodPort:
"""A line that never falls quiet: read_available always returns bytes, and
they contain a prompt. No identity ever completes."""
def flush_input(self):
pass
def write(self, data):
pass
def read_available(self, wait):
time.sleep(0.01) # a real read waits; keep the busy loop off a core
return b"+\x00\xff"
def read_exact(self, count, timeout):
raise pb.Error("no identity")
class LoaderPort:
"""A well-behaved pureboot 5: a prompt to the knock, then quiet, then the
slim identity (version 5 + m328p signature) and a closing prompt."""
def __init__(self):
self.pending = b""
self.exacts = 0
def flush_input(self):
self.pending = b""
def write(self, data):
if b"p" in data:
self.pending = b"+" # the prompt answers the knock, nothing else
def read_available(self, wait):
data, self.pending = self.pending, b""
return data
def read_exact(self, count, timeout):
self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
class StaleDTRPort:
"""`--stay`, then a fresh invocation on a board that resets when its port
opens. Three facts of that moment, all timed from the open: the previous
session's final prompt is still in transit and lands only after the
opening flush has already run; the reset holds the device off the line
at first, eating anything written before it completes; and the fresh
window is finite — once it expires the application boots and prints a
banner whose bytes are what a pending identity read collects. A
handshake that trusts the stale prompt spends the whole window waiting
on a device that never heard its knock; one that drains the line first
knocks into the real window and connects."""
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
READY_AT = 0.05 # reset complete, activation window opens
WINDOW = 1.0 # window length; expiry boots the application
def __init__(self):
self.t0 = time.monotonic()
# (visible-from, bytes): the line as a timed queue.
self.queue = [(self.t0 + self.STALE_AT, b"+")]
self.armed = False # a 'p' heard inside the window arms 'b'
self.booted = False
def _boot_check(self):
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
self.booted = True
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
b"W r libavr tempmon\r\n"))
def _visible(self):
self._boot_check()
now = time.monotonic()
return b"".join(d for t, d in self.queue if t <= now)
def _consume(self, n):
now = time.monotonic()
left = []
for t, d in self.queue:
if t <= now and n:
take = min(n, len(d))
d = d[take:]
n -= take
if d:
left.append((t, d))
self.queue = left
def flush_input(self):
self._consume(len(self._visible()))
def write(self, data):
self._boot_check()
now = time.monotonic()
if now < self.t0 + self.READY_AT or self.booted:
return # still in reset, or the application owns the line
if b"p" in data:
self.armed = True
self.queue.append((now + 0.01, b"+"))
if b"b" in data and self.armed:
# The slim identity (version 5 + m328p signature) and a prompt.
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
def read_available(self, wait):
deadline = time.monotonic() + wait
while True:
data = self._visible()
if data:
self._consume(len(data))
return data
if time.monotonic() >= deadline:
return b""
time.sleep(0.005)
def read_exact(self, count, timeout):
deadline = time.monotonic() + timeout
data = b""
while len(data) < count:
visible = self._visible()
if visible:
take = visible[:count - len(data)]
self._consume(len(take))
data += take
elif time.monotonic() >= deadline:
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
else:
time.sleep(0.005)
return data
def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging."""
done = threading.Event()
def run():
try:
pb.Loader(port).connect_autobaud(wait)
except Exception:
pass
finally:
done.set()
threading.Thread(target=run, daemon=True).start()
return done.wait(budget)
def main():
# the hang: a flooding target must not spin the drain forever. With wait=0.5
# the whole handshake has to give up well inside a few seconds.
check("flooding target: handshake terminates, drain is bounded",
terminates(FloodPort(), wait=0.5, budget=4.0))
# the control: a real loader still connects and reads identity.
info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5)
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
# fresh window — the pre-knock drain absorbs it and the first real knock
# lands inside the window.
try:
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
except pb.Error as failed:
print(f" ({failed})")
stale_ok = False
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
print(f"\n {P} passed, {F} failed")
return 1 if F else 0
if __name__ == "__main__":
raise SystemExit(main())