Skip to content

fix(usb): Add serial presence recovery for USB replug#5923

Merged
jamesarich merged 8 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/usb-attach-detach-restart
Jun 24, 2026
Merged

fix(usb): Add serial presence recovery for USB replug#5923
jamesarich merged 8 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/usb-attach-detach-restart

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Overview

This pull request improves USB serial resilience by adding a platform abstraction for tracking currently connected serial devices and using it to recover the selected SERIAL transport after an unplug/replug cycle.

Previously, unplugging a selected USB serial device could leave the transport in a zombie ConnectionState.DeviceSleep state, and replugging the same device did not reliably restart the selected SERIAL transport. USB attach handling could also route through an Activity/deep-link reload path even when a device was already selected, causing unnecessary UI/ViewModel churn.

This change fixes the full selected-device replug flow by:

  1. tracking connected USB serial device keys,
  2. arming recovery only after the selected device is actually observed absent,
  3. restarting the selected SERIAL transport when that same device returns in a recoverable state,
  4. avoiding unnecessary Connections-page navigation when a selected device is already attached/replugged,
  5. and hardening explicit shutdown so close/disconnect callbacks are not duplicated or leaked into later recovery state.

Key Changes

Features

  • Added SerialDevicePresence, a common interface exposing deviceKeys: StateFlow<Set<String>> for currently connected SERIAL device keys.

  • Implemented SerialDevicePresence per platform:

    • Android: AndroidSerialDevicePresence maps UsbRepository.serialDevices to a hot StateFlow of serial device keys.
    • JVM/Desktop: JvmSerialDevicePresence emits an always-empty device-key set because desktop serial hot-plug observation is not currently provided.
  • Updated SharedRadioInterfaceService to observe:

    • the selected device address,
    • current serial device presence,
    • and transport _connectionState.
  • Added guarded USB replug recovery for the selected SERIAL device:

    • recovery is armed only after the selected serial key is observed absent,
    • recovery triggers when that same key returns while the transport is in ConnectionState.DeviceSleep,
    • initial presence emissions are ignored so startup/refresh ordering does not falsely arm recovery,
    • duplicate same-key emissions are de-duplicated,
    • and all recovery guards are revalidated under transportMutex before restart.
  • Restart is performed as silent recovery by stopping the zombie transport without permanent-disconnect notification and immediately starting the selected SERIAL transport again.

USB attach behavior

  • Updated MainActivity USB attach handling so attach/replug no longer always routes through Connections-page navigation.
  • If no device is selected, USB attach still navigates to the Connections screen so the user can pick a device.
  • If a device is already selected, USB attach refreshes USB state without forcing an Activity/deep-link reload or switching pages.
  • This preserves first-attach/no-selection behavior while avoiding unnecessary MainActivity and ViewModel churn during selected-device replug.

Fixes

  • Scoped USB recovery to the currently selected SERIAL device only.
  • Ignored unrelated USB device attach events.
  • Avoided restarting transports that are already Connected or Connecting.
  • Avoided automatic recovery after explicit service.disconnect().
  • Cleared stale recovery-arm state when the service reaches permanent Disconnected, preventing an old detach observation from leaking into a later reconnect session.
  • Gated recovery on a real detach edge so a disconnect callback that reaches DeviceSleep before USB presence updates does not immediately restart against stale presence data.
  • Covered the reverse ordering where USB presence returns before the transport emits DeviceSleep; the later state transition can still complete recovery.
  • Suppressed duplicate serial disconnect callback propagation during explicit transport close.

Shutdown / teardown changes

  • Added SerialRadioTransport.explicitCloseInProgress so reader-thread disconnect callbacks do not forward transient disconnects during explicit close().
  • Added a shared closeConnection(waitForStopped) teardown helper for SERIAL connections.
  • Reused the shared teardown path from both close() and onDeviceDisconnect(...).
  • Atomically clears the active serial connection reference before closing, so only the owner of the transition forwards disconnect handling.
  • Updated StreamTransport.close() so explicit close no longer invokes onDeviceDisconnect(...); explicit disconnect signaling is owned by the service layer.

Refactors

  • Centralized SERIAL teardown in SerialRadioTransport.
  • Extracted USB recovery flow setup into observeUsbRecoveryTriggers() to keep service initialization focused.
  • Reworked the USB recovery observer into a guarded, de-duplicated, race-tolerant state machine.
  • Removed the USB attach-only PendingIntent/TaskStackBuilder navigation path for already-selected device replug.

Testing

  • Extended SharedRadioInterfaceServiceLivenessTest with deterministic USB replug recovery scenarios covering:

    • selected-device replug recovery,
    • unrelated-device attach no-op,
    • already-connected transport no-op,
    • presence-before-DeviceSleep ordering,
    • disconnect-before-detach ordering,
    • initial empty presence snapshot no-op,
    • duplicate same-key emission de-duplication,
    • explicit disconnect disarming recovery,
    • and clearing stale recovery-arm state across explicit disconnect and later reconnect.
  • Added StreamTransportTest coverage confirming StreamTransport.close() does not emit a disconnect callback.

Breaking Changes / Migration Notes

  • StreamTransport.close() no longer calls onDeviceDisconnect(...) directly. Explicit close/disconnect notification is now owned by the service layer so transport recovery can close and recreate transports without emitting duplicate user-facing disconnect state.
  • SharedRadioInterfaceService now requires a SerialDevicePresence implementation in the DI graph. Android provides AndroidSerialDevicePresence; JVM/Desktop provides JvmSerialDevicePresence.

@github-actions github-actions Bot added the bugfix PR tag label Jun 23, 2026
@jeremiah-k jeremiah-k marked this pull request as ready for review June 23, 2026 22:34

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really nice work — the race analysis is sound and the test matrix covers both callback orderings, the flap/dedup case, and stale-arm-across-reconnect. New tests pass locally (:core:service:jvmTest, :core:network:jvmTest). One cross-platform gap to fix before merge, plus two small inline suggestions.

iOS DI gap (please fix)

SharedRadioInterfaceService is @Single in commonMain and now takes a hard SerialDevicePresence dependency, but the only @Single impls are AndroidSerialDevicePresence (androidMain) and JvmSerialDevicePresence (jvmMain). core:network/core:service both target iOS (compileKotlinIosArm64 / iosArm64MainKlibrary exist), so the iOS Koin graph has no binding for SerialDevicePresence. It compiles (the interface is in commonMain) — which is why CI is green — but Koin resolution of SharedRadioInterfaceService would throw NoDefinitionFoundException at runtime on iOS. Not a shipping bug today (iOS is compile-only), but it's a regression to the common graph's iOS-resolvability and the Migration Notes silently omit iOS.

Laziest correct fix — a 4-line core/network/src/iosMain/kotlin/org/meshtastic/core/network/repository/IosSerialDevicePresence.kt mirroring the Jvm one (expect/actual or a commonMain default would collide with Android's @Single for the same type, so two trivial copies is the path of least resistance):

@Single
class IosSerialDevicePresence : SerialDevicePresence {
    override val deviceKeys: StateFlow<Set<String>> = MutableStateFlow<Set<String>>(emptySet()).asStateFlow()
}

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

some small changes needed, otherwise appreciate this QOL fix for usb serial

@jeremiah-k jeremiah-k marked this pull request as draft June 23, 2026 23:08
SharedRadioInterfaceService.initStateListeners observed bluetooth and
network state recovery but not USB. When a USB device was unplugged and
replugged, the transport stayed disconnected and the user had to manually
re-select. Add a SerialDevicePresence abstraction wired into a USB
recovery observer that restarts the SERIAL transport when the selected
device transitions back into the presence set.

The recovery trigger combines the device address, the serial device-key
set, and the transport connection state, firing only when the selected
device is present AND the transport is in zombie DeviceSleep. Including
connection state in the trigger closes the unplug/replug race where
presence arrives before the I/O-death callback has flipped state: the
late Connected → DeviceSleep transition re-emits the flow so the zombie
transport is recovered. Both stop and start inside the recovery branch
run under ignoreExceptionSuspend so a transient USB error cannot escape
the onEach and terminate the recovery flow for the rest of the session.
The recovery observer is extracted into observeUsbRecoveryTriggers()
so initStateListeners stays under detekt's LongMethod cap.

SerialRadioTransport.close() now sets an explicitCloseInProgress guard
so the reader thread's synchronous onDisconnected callback during
port.close() does not emit a transient DeviceSleep on top of the
service-layer notification that owns post-close state transitions.
AndroidSerialDevicePresence publishes an immutable Set snapshot per
emission instead of a Map.keys view, and JvmSerialDevicePresence
exposes its perpetually-empty flow as a plain StateFlow.

Scoped to the selected device only; distinctUntilChanged coalesces
same-value re-emissions so a device that flaps N times produces at most
N restarts, never an unbounded loop. disconnect() immediately disarms
the observer via the connectionRequested gate.
Require the selected serial device key to be observed absent before a later present emission can trigger zombie transport recovery. This prevents a normal unplug sequence from restarting immediately when the transport disconnect callback reaches DeviceSleep before UsbRepository has removed the still-present key.

Keep recovery teardown and fresh start in separate ignoreExceptionSuspend blocks so a close failure cannot skip the restart attempt.

Add regression coverage for the inverse callback ordering: selected key already present, onDisconnect(false) first, then detach and reattach. The restart now happens only on the absent-to-present replug edge.
SerialConnection.close() may synchronously invoke SerialConnectionListener.onDisconnected through the reader error path. During a USB unplug, that can reenter onDeviceDisconnect while the outer call is still closing the same connection.

Return whether closeConnection actually cleared connRef, and only forward the transient disconnect callback from the invocation that owns that transition. This keeps the service from seeing duplicate DeviceSleep emissions for one unplug.
Prevent the serial replug observer from arming recovery when UsbRepository starts with its initial empty device-key snapshot and then refreshes to a present selected device while the transport is still healthy.

Model the real present-to-absent-to-present race in the liveness test and add a regression for initial empty-to-present refreshes.
USB attach intents still refresh UsbRepository so the serial device list stays current, but selected-device replug no longer sends a deep-link PendingIntent back to MainActivity.

Only route to the Connections screen in-process when no device is selected, preserving the first-attach flow without recreating the app during automatic USB recovery.
Reset the USB replug recovery reducer whenever the service is in the permanent Disconnected state so armed-by-absence state from a prior session cannot leak into a later reconnect.

Add a regression covering detach while connected, explicit disconnect, later reconnect, and a normal unplug callback with the selected key still present.
Address review feedback by adding an iOS no-op SerialDevicePresence binding so SharedRadioInterfaceService remains resolvable across current KMP targets.

Normalize selected serial key extraction through selectedSerialPresence and reuse it for the under-lock presence recheck, avoiding empty keys for bare serial addresses.

Convert the USB recovery flow rationale into concise KDoc so the race and gating contract remains discoverable.
@jeremiah-k jeremiah-k force-pushed the bugfix/usb-attach-detach-restart branch from e3af281 to 61b4b3c Compare June 23, 2026 23:31
@jeremiah-k jeremiah-k marked this pull request as ready for review June 23, 2026 23:46
@jeremiah-k jeremiah-k requested a review from jamesarich June 23, 2026 23:46
@jamesarich jamesarich enabled auto-merge June 23, 2026 23:52
@jamesarich jamesarich added this pull request to the merge queue Jun 23, 2026
Merged via the queue into meshtastic:main with commit 1d52857 Jun 24, 2026
22 checks passed
@jeremiah-k jeremiah-k deleted the bugfix/usb-attach-detach-restart branch June 24, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants