Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions helper/dashtouch_helper/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
})

Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions helper/tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading