From 6280855431fb9ea48d3de15722bc0f3e4d003ddf Mon Sep 17 00:00:00 2001 From: DanielRMErskine Date: Mon, 13 Jul 2026 11:19:02 -0700 Subject: [PATCH 1/3] UART identity: never match a clone-serial sibling during reconnect Many USB-serial adapters ship with a non-unique programmed serial (e.g. CP210x units all reading "0001"). With several such adapters on one box, a v0.31.5 reconnect could resolve a dropped device's identity to a look-alike sibling while the real device was still off the bus - the healed session came back attached to the wrong hardware (found during field validation when a session landed on a different device's console). Two changes in serial_id: - resolve_identity: a serial matched by several live devices proves nothing; the physical port is then required, and resolution fails (callers keep retrying) while the true device is absent. - identity_for_tty: a serial shared with another live device is recorded as null at snapshot time, pinning the net to its physical port outright - which also covers resolving a legacy snapshot while exactly one sibling is present. Snapshots taken under v0.31.5 on clone-serial adapters pick up the correction on their next re-save. --- CHANGELOG.md | 16 +++++++ box/lager/devices/serial_id.py | 59 +++++++++++++++++++++----- test/unit/box/test_serial_id_cables.py | 56 ++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ae6b48..1a42bffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to the Lager platform are documented here. For detailed release notes, see [docs.lagerdata.com](https://docs.lagerdata.com). +## [Unreleased] + +### Fixed +- **UART reconnect can no longer land on a look-alike adapter with a clone + serial.** Many USB-serial adapters ship with a non-unique programmed serial + (e.g. several CP210x units all reading "0001"). If such a device dropped + mid-session, the v0.31.5 reconnect could match a sibling adapter with the + same serial while the real device was still off the bus — attaching the + session to the wrong hardware. Identity resolution now treats a serial + shared by multiple live devices as untrusted (the physical port must match, + and it keeps retrying until the real device returns), and new identity + snapshots record a bus-duplicated serial as null so the net is pinned to + its physical port outright. Nets on clone-serial adapters that were + enriched under v0.31.5 pick up the corrected snapshot on their next + re-save. + ## [0.31.8] - 2026-07-13 `lager uninstall` now actually removes what the modern `lager install` creates. diff --git a/box/lager/devices/serial_id.py b/box/lager/devices/serial_id.py index ee4c1499..c7663212 100644 --- a/box/lager/devices/serial_id.py +++ b/box/lager/devices/serial_id.py @@ -165,6 +165,28 @@ def _interface_for_tty(tty_dev: Path) -> Optional[int]: return None +def _serial_is_unique(vid: str, pid: str, serial: str, own_dir_name: str) -> bool: + """False when another live USB device shares vid/pid/serial. + + Clone adapters (e.g. CP210x units all programmed with serial "0001") make + the serial worthless as identity. Sibling interfaces of one multi-port + chip share a device dir and are not counted as duplicates. + """ + seen = set() + for tty_dev in _SYS_TTY.iterdir(): + if not tty_dev.name.startswith(("ttyUSB", "ttyACM")): + continue + usb_dir = _usb_device_dir_for_tty(tty_dev) + if usb_dir is None or usb_dir.name == own_dir_name or usb_dir.name in seen: + continue + seen.add(usb_dir.name) + if ((_read_sysfs_text(usb_dir / "idVendor") or "").lower() == vid + and (_read_sysfs_text(usb_dir / "idProduct") or "").lower() == pid + and _read_sysfs_text(usb_dir / "serial") == serial): + return False + return True + + def identity_for_tty(tty: str) -> Optional[Dict[str, Optional[str]]]: """Snapshot the durable USB identity of a live tty node. @@ -172,6 +194,11 @@ def identity_for_tty(tty: str) -> Optional[Dict[str, Optional[str]]]: ``/dev/serial/by-id/...`` path). Returns ``{"vid", "pid", "serial", "port_path", "interface"}`` or None when the tty is not backed by a USB device (or is gone). + + A serial shared with another live device (clone serials) is recorded as + None: it cannot identify the device, so the snapshot pins to the physical + port instead. Otherwise a later resolution while this device is briefly + off the bus could match a look-alike sibling. """ if not tty or not isinstance(tty, str): return None @@ -187,10 +214,13 @@ def identity_for_tty(tty: str) -> Optional[Dict[str, Optional[str]]]: pid = (_read_sysfs_text(usb_dir / "idProduct") or "").lower() if not vid or not pid: return None + serial = _read_sysfs_text(usb_dir / "serial") + if serial and not _serial_is_unique(vid, pid, serial, usb_dir.name): + serial = None return { "vid": vid, "pid": pid, - "serial": _read_sysfs_text(usb_dir / "serial"), + "serial": serial, "port_path": usb_dir.name, "interface": _interface_for_tty(tty_dev), } @@ -200,11 +230,13 @@ def resolve_identity(ident) -> Optional[str]: """Resolve an ``identity_for_tty`` snapshot back to the live tty node. Match rules: vid/pid always; interface when both sides know it; then USB - serial when the snapshot has one (falling back to the port path to break - ties between clone adapters sharing a serial), else the physical port - path. Returns ``/dev/tty...`` or None if the device is not (yet) back. - Tolerates arbitrary garbage input — a malformed snapshot resolves to None - rather than raising. + serial when the snapshot has one. A serial that several live devices + share (clone adapters) proves nothing, so the physical port is then + REQUIRED — with the true device absent this returns None so callers keep + retrying instead of grabbing a look-alike sibling. Serial-less snapshots + resolve by physical port path. Returns ``/dev/tty...`` or None if the + device is not (yet) back. Tolerates arbitrary garbage input — a malformed + snapshot resolves to None rather than raising. """ if not isinstance(ident, dict): return None @@ -245,10 +277,17 @@ def resolve_identity(ident) -> Optional[str]: matches = [c for c in candidates if c[2] == serial] if not matches: return None - if len(matches) > 1 and port_path: - ported = [c for c in matches if c[1] == port_path] - if ported: - matches = ported + if len(matches) > 1: + # Several live devices share this serial (clone serials): the + # serial proves nothing, so the physical port is REQUIRED. When + # our port's device is absent, fail — the caller keeps retrying — + # rather than grabbing a look-alike sibling mid-re-enumeration. + if not port_path: + return None + for c in matches: + if c[1] == port_path: + return f"/dev/{c[0]}" + return None return f"/dev/{matches[0][0]}" if port_path: for c in candidates: diff --git a/test/unit/box/test_serial_id_cables.py b/test/unit/box/test_serial_id_cables.py index 4b11f065..3ac2850a 100644 --- a/test/unit/box/test_serial_id_cables.py +++ b/test/unit/box/test_serial_id_cables.py @@ -207,6 +207,62 @@ def test_resolve_identity_clone_serials_port_tiebreak(self): ident = serial_id.identity_for_tty("/dev/ttyUSB1") self.assertEqual(serial_id.resolve_identity(ident), "/dev/ttyUSB1") + # ---- clone-serial regression (v0.31.5 reconnected to a sibling) --------- + + def test_identity_for_tty_demotes_clone_serial(self): + # Two live devices share vid/pid/serial: the serial is not identity. + self._add_cable("ttyUSB0", "1-1.2", serial="0001") + self._add_cable("ttyUSB1", "1-1.3", serial="0001") + ident = serial_id.identity_for_tty("/dev/ttyUSB0") + self.assertIsNone(ident["serial"]) + self.assertEqual(ident["port_path"], "1-1.2") + + def test_identity_for_tty_keeps_unique_serial(self): + self._add_cable("ttyUSB0", "1-1.2", serial="UNIQ-A") + self._add_cable("ttyUSB1", "1-1.3", serial="UNIQ-B") + self.assertEqual(serial_id.identity_for_tty("/dev/ttyUSB0")["serial"], + "UNIQ-A") + + def test_identity_for_tty_multi_interface_keeps_serial(self): + # Four ttys of ONE multi-port chip share the device dir; that is not + # a clone serial. + for n in range(4): + self._add_cable(f"ttyUSB{n}", "1-1.3", serial="QUAD", iface=n) + self.assertEqual(serial_id.identity_for_tty("/dev/ttyUSB2")["serial"], + "QUAD") + + def test_resolve_identity_clone_absent_never_matches_sibling(self): + # The exact v0.31.5 field failure: a legacy snapshot carrying the + # clone serial, resolved while the true device is off the bus, must + # return None (keep retrying) — NOT a look-alike sibling. + self._add_cable("ttyUSB0", "1-1.2", serial="0001") + self._add_cable("ttyUSB1", "1-1.3", serial="0001") + self._add_cable("ttyUSB2", "1-1.4", serial="0001") + legacy_ident = {"vid": VID, "pid": PID, "serial": "0001", + "port_path": "1-1.4", "interface": 0} + shutil.rmtree(self._sys_tty / "ttyUSB2") # our device drops + self.assertIsNone(serial_id.resolve_identity(legacy_ident)) + self._add_cable("ttyUSB5", "1-1.4", serial="0001") # it returns + self.assertEqual(serial_id.resolve_identity(legacy_ident), "/dev/ttyUSB5") + + def test_resolve_identity_clone_serial_without_port_is_unresolvable(self): + self._add_cable("ttyUSB0", "1-1.2", serial="0001") + self._add_cable("ttyUSB1", "1-1.3", serial="0001") + ident = {"vid": VID, "pid": PID, "serial": "0001"} + self.assertIsNone(serial_id.resolve_identity(ident)) + + def test_reconnect_snapshot_cycle_with_clones(self): + # End-to-end shape of the JUL-16 heal: snapshot (serial demoted), + # device drops (None while absent), returns renumbered on the same + # port (resolved by port). + self._add_cable("ttyUSB0", "1-1.2", serial="0001") + self._add_cable("ttyUSB1", "1-1.3", serial="0001") + ident = serial_id.identity_for_tty("/dev/ttyUSB0") + shutil.rmtree(self._sys_tty / "ttyUSB0") + self.assertIsNone(serial_id.resolve_identity(ident)) + self._add_cable("ttyUSB4", "1-1.2", serial="0001") + self.assertEqual(serial_id.resolve_identity(ident), "/dev/ttyUSB4") + def test_resolve_identity_multi_interface_picks_channel(self): # FT4232H: one USB device (no serial), four ttys on interfaces 0-3. for n in range(4): From 1de67614a55858b657db3be96061af163edb6323 Mon Sep 17 00:00:00 2001 From: DanielRMErskine Date: Mon, 13 Jul 2026 12:48:39 -0700 Subject: [PATCH 2/3] v0.31.9 --- CHANGELOG.md | 2 +- cli/__init__.py | 2 +- docs/docs.json | 1 + docs/source/release-notes/v0.31.9.mdx | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 docs/source/release-notes/v0.31.9.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a42bffd..11c27a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the Lager platform are documented here. For detailed release notes, see [docs.lagerdata.com](https://docs.lagerdata.com). -## [Unreleased] +## [0.31.9] - 2026-07-13 ### Fixed - **UART reconnect can no longer land on a look-alike adapter with a clone diff --git a/cli/__init__.py b/cli/__init__.py index 2af5f4bd..b0ceb27f 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -7,4 +7,4 @@ A Command Line Interface for Lager Data """ -__version__ = '0.31.8' +__version__ = '0.31.9' diff --git a/docs/docs.json b/docs/docs.json index 0135d573..f99a4c28 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -211,6 +211,7 @@ { "group": "Version History", "pages": [ + "source/release-notes/v0.31.9", "source/release-notes/v0.31.8", "source/release-notes/v0.31.7", "source/release-notes/v0.31.6", diff --git a/docs/source/release-notes/v0.31.9.mdx b/docs/source/release-notes/v0.31.9.mdx new file mode 100644 index 00000000..2e1b8123 --- /dev/null +++ b/docs/source/release-notes/v0.31.9.mdx @@ -0,0 +1,26 @@ +--- +title: "Version 0.31.9" +description: "July 13, 2026" +--- + +## Bug Fixes + +- **UART reconnect can no longer land on a look-alike adapter with a clone serial.** Many USB-serial adapters ship with a non-unique programmed serial (e.g. several CP210x units all reading "0001"). If such a device dropped mid-session, the v0.31.5 reconnect could match a sibling adapter with the same serial while the real device was still off the bus — attaching the session to the wrong hardware. Identity resolution now treats a serial shared by multiple live devices as untrusted: the physical port must match, and reconnection keeps retrying until the real device returns. New identity snapshots record a bus-duplicated serial as null, pinning the net to its physical port outright. Nets on clone-serial adapters that were enriched under v0.31.5 pick up the corrected snapshot on their next re-save — re-save only while every adapter sits on the tty its net expects, since enrichment snapshots whatever device the stored pin currently points at. + +## Installation + +To install this version: + +```bash +pip install lager-cli==0.31.9 +``` + +To upgrade from a previous version: + +```bash +pip install --upgrade lager-cli +``` + +## Resources + +[View Release on PyPI](https://pypi.org/project/lager-cli/0.31.9/) From 79b027d95ba20895fd9ff23df4d198eab72cdd26 Mon Sep 17 00:00:00 2001 From: DanielRMErskine Date: Mon, 13 Jul 2026 15:02:43 -0700 Subject: [PATCH 3/3] Guard the clone-serial uniqueness walk against unreadable sysfs Review feedback: _serial_is_unique iterated /sys/class/tty without the missing-sysfs guard every other walker in this module has. The path is unreachable in practice (the caller has already resolved the tty's own device dir through sysfs), but the walk is best-effort by design - an OSError now degrades to assuming the serial is unique instead of propagating. --- box/lager/devices/serial_id.py | 27 +++++++++++++++----------- test/unit/box/test_serial_id_cables.py | 6 ++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/box/lager/devices/serial_id.py b/box/lager/devices/serial_id.py index c7663212..a8eaf8cd 100644 --- a/box/lager/devices/serial_id.py +++ b/box/lager/devices/serial_id.py @@ -173,17 +173,22 @@ def _serial_is_unique(vid: str, pid: str, serial: str, own_dir_name: str) -> boo chip share a device dir and are not counted as duplicates. """ seen = set() - for tty_dev in _SYS_TTY.iterdir(): - if not tty_dev.name.startswith(("ttyUSB", "ttyACM")): - continue - usb_dir = _usb_device_dir_for_tty(tty_dev) - if usb_dir is None or usb_dir.name == own_dir_name or usb_dir.name in seen: - continue - seen.add(usb_dir.name) - if ((_read_sysfs_text(usb_dir / "idVendor") or "").lower() == vid - and (_read_sysfs_text(usb_dir / "idProduct") or "").lower() == pid - and _read_sysfs_text(usb_dir / "serial") == serial): - return False + try: + for tty_dev in _SYS_TTY.iterdir(): + if not tty_dev.name.startswith(("ttyUSB", "ttyACM")): + continue + usb_dir = _usb_device_dir_for_tty(tty_dev) + if usb_dir is None or usb_dir.name == own_dir_name or usb_dir.name in seen: + continue + seen.add(usb_dir.name) + if ((_read_sysfs_text(usb_dir / "idVendor") or "").lower() == vid + and (_read_sysfs_text(usb_dir / "idProduct") or "").lower() == pid + and _read_sysfs_text(usb_dir / "serial") == serial): + return False + except OSError: + # Unreadable sysfs mid-walk: assume unique (degrades to trusting the + # serial, same as the other walkers' missing-_SYS_TTY guards). + pass return True diff --git a/test/unit/box/test_serial_id_cables.py b/test/unit/box/test_serial_id_cables.py index 3ac2850a..8af4a94f 100644 --- a/test/unit/box/test_serial_id_cables.py +++ b/test/unit/box/test_serial_id_cables.py @@ -245,6 +245,12 @@ def test_resolve_identity_clone_absent_never_matches_sibling(self): self._add_cable("ttyUSB5", "1-1.4", serial="0001") # it returns self.assertEqual(serial_id.resolve_identity(legacy_ident), "/dev/ttyUSB5") + def test_serial_is_unique_tolerates_unreadable_sysfs(self): + # Best-effort walk: an inaccessible /sys/class/tty must not raise and + # assumes the serial is unique (degrades to trusting it). + serial_id._SYS_TTY = Path(self._tmp) / "does-not-exist" + self.assertTrue(serial_id._serial_is_unique(VID, PID, "0001", "1-1.2")) + def test_resolve_identity_clone_serial_without_port_is_unresolvable(self): self._add_cable("ttyUSB0", "1-1.2", serial="0001") self._add_cable("ttyUSB1", "1-1.3", serial="0001")