diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ae6b48..11c27a1a 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). +## [0.31.9] - 2026-07-13 + +### 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..a8eaf8cd 100644 --- a/box/lager/devices/serial_id.py +++ b/box/lager/devices/serial_id.py @@ -165,6 +165,33 @@ 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() + 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 + + def identity_for_tty(tty: str) -> Optional[Dict[str, Optional[str]]]: """Snapshot the durable USB identity of a live tty node. @@ -172,6 +199,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 +219,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 +235,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 +282,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/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/) diff --git a/test/unit/box/test_serial_id_cables.py b/test/unit/box/test_serial_id_cables.py index 4b11f065..8af4a94f 100644 --- a/test/unit/box/test_serial_id_cables.py +++ b/test/unit/box/test_serial_id_cables.py @@ -207,6 +207,68 @@ 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_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") + 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):