pbhw: the marker check assumed a board whose port-open is not a reset

Its comment said "opening the port does not reset a board whose DTR is
unwired, so this simply listens" — true of the tiny it was written against,
false of an Arduino, and this is the generic harness. Where DTR is wired to
reset, that open resets the part and the activation window comes first, so a
fixture emitting its banner once says it on the far side of a wait the suite
cannot know the length of: the window is a compile-time constant and nothing
on the wire reports it. The suite read the silence as an application that
never ran, on a board where it demonstrably had.

So --marker-wait, defaulting to the 2.5 s that was hardcoded, and a failure
that names the window as the candidate rather than leaving the next person to
suspect the loader. The other half is the fixture: PUREBOOT_HEARTBEAT makes
the observation independent of when the listener arrives, which is what the
rig's own builds now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 17:12:08 +02:00
parent 579ca81b27
commit 3e4bfbaf48

View File

@@ -132,18 +132,28 @@ class Suite:
got = erased.read_bytes() if erased.exists() else b"" got = erased.read_bytes() if erased.exists() else b""
self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B") self.check("EEPROM erase leaves 0xff", got == b"\xff" * size, f"{len(got)} B")
def application(self, info, app: pathlib.Path, marker: str) -> None: def application(self, info, app: pathlib.Path, marker: str,
marker_wait: float = 2.5) -> None:
rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app)) rc, out = self.rig.pureboot("--flash", str(app), "--verify-flash", str(app))
self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out)) self.check(f"application flash + verify ({app.name})", rc == 0, self._brief(out))
if marker: if marker:
# The tool hands over as it ends its session, so the application is # The tool hands over as it ends its session, so the application is
# already running; opening the port does not reset a board whose DTR # already running — but only on a board whose DTR is unwired, where
# is unwired, so this simply listens. # opening a port simply listens. Where DTR *is* wired to reset (an
data = self.rig.capture(seconds=2.5) # Arduino, most USB-serial dev boards), this open resets the part
# and the activation window comes first, so a marker emitted once at
# startup happens on the far side of a wait this cannot know the
# length of: the window is a compile-time constant and nothing on
# the wire reports it. Hence --marker-wait, and a fixture that
# repeats its banner (PUREBOOT_HEARTBEAT) rather than saying it once.
data = self.rig.capture(seconds=marker_wait)
seen = marker.encode() in data seen = marker.encode() in data
sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40]) sample = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:40])
self.check(f"application runs (emits {marker!r})", seen, f"|{sample}|") self.check(f"application runs (emits {marker!r})", seen,
f"|{sample}|" if seen or data else
f"nothing in {marker_wait:g} s — if this board resets when its port "
f"opens, that wait has to outlast the activation window")
back = self.work / "app-back.bin" back = self.work / "app-back.bin"
rc, out = self.rig.pureboot("--read-flash", str(back)) rc, out = self.rig.pureboot("--read-flash", str(back))
@@ -190,7 +200,7 @@ class Suite:
# ------------------------------------------------------------------- run # ------------------------------------------------------------------- run
def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None, def run(self, app: pathlib.Path | None, loader_image: pathlib.Path | None,
marker: str) -> int: marker: str, marker_wait: float = 2.5) -> int:
print("identity") print("identity")
info = self.identity() info = self.identity()
if info is None: if info is None:
@@ -206,7 +216,7 @@ class Suite:
if app: if app:
print("\napplication") print("\napplication")
self.application(info, app, marker) self.application(info, app, marker, marker_wait)
else: else:
print("\nskip application checks (pass --app <image.hex>)") print("\nskip application checks (pass --app <image.hex>)")
@@ -232,6 +242,10 @@ def main(argv: list[str] | None = None) -> int:
help="the resident loader's .bin, to prove the slot survives an erase") help="the resident loader's .bin, to prove the slot survives an erase")
parser.add_argument("--marker", default="", parser.add_argument("--marker", default="",
help="text the application emits when it runs, e.g. APP") help="text the application emits when it runs, e.g. APP")
parser.add_argument("--marker-wait", type=float, default=2.5,
help="seconds to listen for it. On a board whose DTR is wired to "
"reset, opening the port resets the part, so this must outlast "
"the activation window (default 2.5)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
rig = pbrig.Rig(pbrig.Deployment.from_args(args)) rig = pbrig.Rig(pbrig.Deployment.from_args(args))
@@ -239,7 +253,8 @@ def main(argv: list[str] | None = None) -> int:
f"{' (autobaud)' if args.autobaud else ''}") f"{' (autobaud)' if args.autobaud else ''}")
print("this overwrites the application flash and EEPROM\n") print("this overwrites the application flash and EEPROM\n")
with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary: with tempfile.TemporaryDirectory(prefix="pbhw-") as temporary:
return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker) return Suite(rig, pathlib.Path(temporary)).run(args.app, args.loader, args.marker,
args.marker_wait)
if __name__ == "__main__": if __name__ == "__main__":