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.
This commit is contained in:
James Rich 2026-04-19 12:45:42 -05:00
parent 0e47dc6717
commit a6f8f456fd
3 changed files with 19 additions and 9 deletions

View file

@ -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)
}
},
)

View file

@ -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)
}

View file

@ -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
}