Skip to content

UART identity: never match a clone-serial sibling during reconnect (v0.31.9)#115

Merged
danielrmerskine merged 3 commits into
mainfrom
de/clone-serial-identity
Jul 13, 2026
Merged

UART identity: never match a clone-serial sibling during reconnect (v0.31.9)#115
danielrmerskine merged 3 commits into
mainfrom
de/clone-serial-identity

Conversation

@danielrmerskine

@danielrmerskine danielrmerskine commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Found during field validation of the v0.31.5 UART re-enumeration work, on a bench with several USB-serial adapters that share a non-unique programmed serial (CP210x units all reading 0001). When one of them dropped mid-session, the reconnect resolved the device's identity to a look-alike sibling with the same serial while the real device was still off the bus — the healed session came back attached to the wrong hardware (a different device's console).

Root cause

serial_id.resolve_identity matched by USB serial first and used the physical port only as a tie-break: when the serial matched several live devices but none of them sat on the recorded port (because the true device was absent), it fell back to the first serial match instead of failing.

Fix

  • resolve_identity: a serial matched by several live devices proves nothing — the physical port is then required, and resolution returns None (callers keep retrying with backoff) until the real device returns. A unique serial match still follows the cable across ports as before.
  • 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. Multi-interface chips (one device, several ttys) are not counted as duplicates. This also covers resolving 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. Note for operators: re-save/refresh uart nets only while every adapter sits on the tty its net's pin expects — enrichment snapshots whatever device the stored pin points at.

Includes the v0.31.9 release prep (version bump, CHANGELOG, release-notes page, nav).

Validation

  • Unit: 1449 passed; 6 new tests covering the exact failure (legacy clone-serial snapshot resolved while the device is absent returns None, then resolves on return; snapshot demotion; multi-interface serials kept; port-required multi-match).
  • Hardware, on the reporting bench (three clone-serial CP210x on individually switchable hub ports): refreshed snapshots show serial: null with distinct port paths; the previously failing mid-session power-cycle now waits through the gap, reconnects to the correct device, and the DUT answers commands through the healed session — verified against the DUT's own command set.

Notes for reviewers

  • Three unit-test failures exist on main and are unrelated to this branch (it touches only serial_id.py and its tests): the two documented in UART nets survive USB re-enumeration (v0.31.5) #111 (test_box_authorize.py::test_missing_key_runs_ssh_keygen, test_monitor_state.py::test_missing_protection_falls_back_to_none) plus a new one introduced with 0.31.6: test_lock_state.py::TestAcquire::test_unknown_holder_type_falls_back_to_ephemeral still asserts the pre-0.31.6 fallback behavior, while 0.31.6 deliberately changed unknown holder types to be preserved verbatim — the test needs updating to match the shipped behavior.
  • The validation box is pinned to this branch and should be repointed to the v0.31.9 tag after merge.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses a bug where UART reconnection could incorrectly attach to a look-alike sibling adapter sharing a clone serial. It introduces a uniqueness check for USB serials, recording duplicate serials as null to force resolution via the physical port path. Additionally, unit tests are added to cover clone-serial scenarios, and the project version is bumped to 0.31.8. Feedback suggests wrapping the sysfs directory iteration in a try-except OSError block to prevent crashes in environments where /sys/class/tty is inaccessible.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +175 to +187
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In environments where /sys/class/tty is missing, restricted, or inaccessible (such as non-Linux development environments or highly sandboxed containers), calling _SYS_TTY.iterdir() can raise OSError (e.g., FileNotFoundError or PermissionError). Wrapping the directory iteration in a try-except OSError block ensures the function fails gracefully and returns True (assuming the serial is unique) rather than crashing the application.

Suggested change
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
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:
pass
return True

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.
@danielrmerskine danielrmerskine force-pushed the de/clone-serial-identity branch from ddea8a1 to 1de6761 Compare July 13, 2026 21:47
@danielrmerskine danielrmerskine changed the title UART identity: never match a clone-serial sibling during reconnect (v0.31.8) UART identity: never match a clone-serial sibling during reconnect (v0.31.9) Jul 13, 2026
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.
@danielrmerskine danielrmerskine merged commit c9f026c into main Jul 13, 2026
1 check passed
@danielrmerskine danielrmerskine deleted the de/clone-serial-identity branch July 13, 2026 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant