From a6f8f456fd42aff63771036b091894b659884c8e Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 19 Apr 2026 12:45:42 -0500 Subject: [PATCH] fix(serial): treat USB unplug and open failure as transient Serial transports defaulted to isPermanent=true for any disconnect path, including USB unplug and port-open failure. Both conditions can resolve without explicit user re-selection: replug, OS re-enumeration, permission grant. Only an explicit close() (user disconnects) is a true permanent disconnect. - StreamTransport: flip onDeviceDisconnect default isPermanent to false; close() now passes isPermanent=true explicitly. - SerialRadioTransport (Android): pass isPermanent=false explicitly on USB unplug callback path. - SerialTransport (JVM): flip both the open-failure path and the read- loop teardown to isPermanent=false; both are recoverable conditions. --- .../core/network/radio/SerialRadioTransport.kt | 5 ++++- .../core/network/radio/StreamTransport.kt | 10 ++++++---- .../org/meshtastic/core/network/SerialTransport.kt | 13 +++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt index bc3558800..0f7985276 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt @@ -108,7 +108,10 @@ class SerialRadioTransport( "Uptime: ${uptime}ms, " + "Packets RX: $packetsReceived ($bytesReceived bytes)" } - onDeviceDisconnect(false) + // USB unplug / cable error is transient — the transport will reconnect when + // the device is replugged or the OS re-enumerates the port. Only an explicit + // close() (user disconnects) should signal a permanent disconnect. + onDeviceDisconnect(waitForStopped = false, isPermanent = false) } }, ) diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt index ac912346a..228362073 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt @@ -37,7 +37,7 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p override suspend fun close() { Logger.d { "Closing stream for good" } - onDeviceDisconnect(true) + onDeviceDisconnect(waitForStopped = true, isPermanent = true) } /** @@ -45,10 +45,12 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p * * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside * transport callbacks - * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g. - * TCP transient disconnect). Defaults to true for serial — subclasses may override with false. + * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O + * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS + * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to + * signal a user-initiated terminal disconnect. */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) { + protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) { callback.onDisconnect(isPermanent = isPermanent) } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index a3f34d67e..45ba70eb7 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -129,7 +129,10 @@ private constructor( // Ignore errors during port close } if (isActive) { - onDeviceDisconnect(true) + // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as + // transient — the user did not explicitly disconnect, and the port may come + // back when the device is replugged or the OS re-enumerates it. + onDeviceDisconnect(waitForStopped = true, isPermanent = false) } } } @@ -169,8 +172,10 @@ private constructor( private const val READ_TIMEOUT_MS = 100 /** - * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent - * disconnect to the [callback] and returns the (non-connected) instance. + * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient + * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as + * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the + * user grants permission); only an explicit close should signal a permanent disconnect. */ fun open( portName: String, @@ -183,7 +188,7 @@ private constructor( if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - callback.onDisconnect(isPermanent = true, errorMessage = errorMessage) + callback.onDisconnect(isPermanent = false, errorMessage = errorMessage) } return transport }