From 04af10a75037189820dda6efbc63abcf401615d5 Mon Sep 17 00:00:00 2001 From: Evan Siegel Date: Fri, 25 Sep 2026 16:50:13 -0400 Subject: [PATCH] fix: don't crash-loop when more than one board is plugged in find_port() raises AmbiguousPortError when it sees several usbmodem ports, and run_forever called it outside any handler. Under launchd's KeepAlive that's a crash-loop: the helper exits, restarts ten seconds later, finds the same ports, and exits again, until someone unplugs a board. I hit this with a second ESP32-S3 board on the same hub; the log showed 13 crashes before I unplugged it. Treat it like any other absent board instead: keep looking every second, and connect as soon as there's one. Say why once per episode rather than once per retry, in the log and in the Checkup's Device row ("More than one board is plugged in"), so the fix is obvious. This doesn't pick the right board when there are several; it just stops the helper from dying while it waits for someone to unplug one. Co-Authored-By: Claude Opus 5.5 --- helper/dashtouch_helper/daemon.py | 25 +++++++++++++++--- helper/tests/test_daemon.py | 43 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/helper/dashtouch_helper/daemon.py b/helper/dashtouch_helper/daemon.py index e665a9f..c705663 100644 --- a/helper/dashtouch_helper/daemon.py +++ b/helper/dashtouch_helper/daemon.py @@ -65,7 +65,10 @@ def __init__(self, serial_number: str, port: str | None = None): # Mirrors the firmware's transient PAUSE state (see # docs/protocol.md). Updated from PAUSE_OK replies and # STATUS_OK's paused= field; never persisted. - "paused": False} + "paused": False, + # Why the board can't be picked out, while that's the + # case (currently: several usbmodem ports at once). + "port_problem": None} # -- connection lifecycle ----------------------------------------------- def _on_connect(self) -> None: @@ -297,7 +300,9 @@ def health(self) -> list[dict]: "label": "Device", "ok": connected, "state": "ok" if connected else "bad", - "detail": "Connected" if connected else "Not connected", + "detail": "Connected" if connected + else "More than one board is plugged in" if self.state["port_problem"] + else "Not connected", "fix": "help-not-connected", }) @@ -396,7 +401,21 @@ def run_forever(self) -> None: print(f"web UI: {origin}") print(f"(the full link, with your session token, is in {webui.URL_PATH})") while True: - port = self.port or serial_link.find_port() + try: + port = self.port or serial_link.find_port() + except serial_link.AmbiguousPortError as e: + # Letting this escape crash-looped the helper under + # launchd's KeepAlive until someone unplugged a board. Wait + # it out like any other absence, and say why once rather + # than every second. + if self.state["port_problem"] != str(e): + msg = f"{e} — unplug the one that isn't Dashboard Touch" + print(msg, file=sys.stderr) + self.log_event("helper", msg) + self.state["port_problem"] = str(e) + port = None + else: + self.state["port_problem"] = None if port is None: self.state["connected"] = False time.sleep(1.0) diff --git a/helper/tests/test_daemon.py b/helper/tests/test_daemon.py index 53e18ce..25e354a 100644 --- a/helper/tests/test_daemon.py +++ b/helper/tests/test_daemon.py @@ -752,3 +752,46 @@ def test_health_picks_up_a_repaired_keychain_without_a_restart(): rows = {r["id"]: r for r in d.health()} assert rows["pairing"]["state"] == "ok" assert rows["password"]["state"] == "ok" + + +def test_run_forever_waits_out_several_boards_instead_of_crashing(monkeypatch, capsys): + # With two usbmodem ports, find_port() raised straight out of + # run_forever: under launchd's KeepAlive, a crash-loop until a board was + # unplugged. The helper must keep looking, and connect once it can. + d = make_daemon() + monkeypatch.setattr(daemon.webui, "start", lambda self: "http://127.0.0.1:3274/?token=T") + found = iter([daemon.serial_link.AmbiguousPortError("several boards found: [a, b]")] * 3 + + ["/dev/fake"]) + + def find_port(): + r = next(found) + if isinstance(r, Exception): + raise r + return r + + monkeypatch.setattr(daemon.serial_link, "find_port", find_port) + monkeypatch.setattr(daemon.time, "sleep", lambda s: None) + opened = [] + monkeypatch.setattr(daemon.serial_link, "open_port", + lambda port: opened.append(port) or mock.Mock()) + + def stop(ser): + raise RuntimeError("stop test loop") + + monkeypatch.setattr(daemon.serial_link, "read_line", stop) + with pytest.raises(RuntimeError, match="stop test loop"): + d.run_forever() + assert opened == ["/dev/fake"] + assert d.state["port_problem"] is None + # Said once, not once per retry. + said = [e for e in d.events if "several boards found" in e["text"]] + assert len(said) == 1 + assert capsys.readouterr().err.count("several boards found") == 1 + + +def test_health_says_when_several_boards_are_the_reason(): + d = make_daemon() + d.state["port_problem"] = "several boards found: [a, b]" + rows = {r["id"]: r for r in d.health()} + assert rows["device"]["state"] == "bad" + assert rows["device"]["detail"] == "More than one board is plugged in"