Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ jobs:
- name: Golden parity (host)
run: cargo test -p android-usb-serial --features fake-transport

- name: JNI bridge regression (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: cargo test --manifest-path tests/jni-bridge/Cargo.toml

- name: Clippy android-usb-serial (Android target)
run: cargo clippy -p android-usb-serial --target aarch64-linux-android -- -D warnings

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Clear pending Java exceptions on failed USB JNI operations before detaching
native threads, preventing an Android crash when reconnecting an unplugged dongle.


## [3.0.7](https://github.com/s00d/tauri-plugin-serialplugin/compare/v3.0.6...v3.0.7) (2026-09-11)

Expand Down
6 changes: 6 additions & 0 deletions crates/android-usb-serial/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Return `Disconnected` for I/O and control operations on closed serial handles,
preventing stale adapter clones from panicking after USB detach.
- Add regression coverage for stale clones, closed-handle operations, and reopening.

## 0.1.1

- Track CDC ACM interrupt-IN notifications and expose DCD, DSR, and ring state
Expand Down
5 changes: 5 additions & 0 deletions crates/android-usb-serial/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,8 @@ base64 = "0.23"
name = "golden_record"
path = "src/bin/golden_record.rs"
required-features = ["fake-transport"]

[[test]]
name = "disconnect_test"
path = "tests/disconnect_test.rs"
required-features = ["fake-transport", "serialport-compat"]
23 changes: 22 additions & 1 deletion crates/android-usb-serial/src/port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use crate::config::{FlowControl, LineConfig, PurgeKind};
use crate::drivers::{Driver, ModemStatus};
use crate::error::Result;
use crate::error::{Result, UsbSerialError};
use crate::reader::SerialReader;
use crate::transport::SharedTransport;

Expand All @@ -27,39 +27,57 @@ impl SerialPortHandle {
}
}

// Adapter clones share this handle under a mutex. A detach can close it
// before a queued operation acquires that mutex; never enter a torn-down driver.
fn ensure_open(&self) -> Result<()> {
if self.closed {
Err(UsbSerialError::Disconnected)
} else {
Ok(())
}
}

/// Bulk OUT write. Opens OUT only — IN belongs to the optional [`Self::start_reader`].
pub fn write(&mut self, data: &[u8]) -> Result<usize> {
self.ensure_open()?;
self.driver.write(data)
}

/// Blocking/synchronous bulk IN read through the driver (not the background reader).
pub fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
self.ensure_open()?;
self.driver.read(buf)
}

/// Baud / framing line coding.
pub fn set_line_config(&mut self, cfg: LineConfig) -> Result<()> {
self.ensure_open()?;
self.driver.set_line_config(cfg)
}

pub fn set_flow_control(&mut self, flow: FlowControl) -> Result<()> {
self.ensure_open()?;
self.driver.set_flow_control(flow)
}

pub fn set_dtr(&mut self, value: bool) -> Result<()> {
self.ensure_open()?;
self.driver.set_dtr(value)
}

pub fn set_rts(&mut self, value: bool) -> Result<()> {
self.ensure_open()?;
self.driver.set_rts(value)
}

pub fn set_break(&mut self, enabled: bool) -> Result<()> {
self.ensure_open()?;
self.driver.set_break(enabled)
}

/// Clear RX and/or TX driver buffers (host-side purge).
pub fn purge(&mut self, kind: PurgeKind) -> Result<()> {
self.ensure_open()?;
self.driver.purge(kind)
}

Expand All @@ -70,6 +88,7 @@ impl SerialPortHandle {

/// Latched modem status lines (CTS/DSR/RI/CD), when the chip reports them.
pub fn modem_status(&mut self) -> Result<ModemStatus> {
self.ensure_open()?;
self.driver.modem_status()
}

Expand All @@ -87,6 +106,7 @@ impl SerialPortHandle {
///
/// Call **after** [`Self::set_line_config`] and DTR/RTS on weak OTG / CH340 adapters.
pub fn start_reader(&mut self) -> Result<()> {
self.ensure_open()?;
if self.reader.is_some() {
return Ok(());
}
Expand All @@ -97,6 +117,7 @@ impl SerialPortHandle {

/// Non-blocking read from the background reader if running; else [`Self::read`].
pub fn try_read(&mut self, buf: &mut [u8]) -> Result<usize> {
self.ensure_open()?;
if let Some(reader) = &mut self.reader {
return reader.try_read(buf);
}
Expand Down
4 changes: 1 addition & 3 deletions crates/android-usb-serial/src/serialport_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ fn usb_err(e: UsbSerialError) -> io::Error {
// Map fatal USB transport failures to disconnect-class kinds so PortRxHub tears down
// (BrokenPipe / …) instead of treating them as soft `ErrorKind::Other` and looping forever.
match &e {
UsbSerialError::Disconnected => {
io::Error::new(io::ErrorKind::BrokenPipe, e.to_string())
}
UsbSerialError::Disconnected => io::Error::new(io::ErrorKind::BrokenPipe, e.to_string()),
UsbSerialError::Io(msg) => io::Error::new(io::ErrorKind::BrokenPipe, msg.clone()),
other => io::Error::other(other.to_string()),
}
Expand Down
68 changes: 68 additions & 0 deletions crates/android-usb-serial/tests/disconnect_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use android_usb_serial::config::{FlowControl, LineConfig, PurgeKind};
use android_usb_serial::error::UsbSerialError;
use android_usb_serial::fake::FakeTransport;
use android_usb_serial::open_port;
use android_usb_serial::serialport_compat::SerialPortAdapter;
use serialport::SerialPort;
use std::io::{Read, Write};
use std::sync::Arc;

#[test]
fn stale_clone_write_after_detach_returns_error() {
let handle = open_port(Arc::new(FakeTransport::cdc_iad()), 0).unwrap();
let adapter =
SerialPortAdapter::new(handle, "usb#0", LineConfig::default(), FlowControl::None).unwrap();
adapter.start_reader().unwrap();
let mut pending_writer = adapter.clone();
adapter.shutdown();
assert!(pending_writer.write(b"1").is_err());
assert!(pending_writer.read(&mut [0; 8]).is_err());
assert!(pending_writer.write_data_terminal_ready(true).is_err());
adapter.shutdown();
}

#[test]
fn closed_handle_rejects_io_and_controls() {
let mut handle = open_port(Arc::new(FakeTransport::cdc_iad()), 0).unwrap();
handle.close();
assert_eq!(handle.write(b"1"), Err(UsbSerialError::Disconnected));
assert_eq!(handle.read(&mut [0; 8]), Err(UsbSerialError::Disconnected));
assert_eq!(
handle.try_read(&mut [0; 8]),
Err(UsbSerialError::Disconnected)
);
assert_eq!(handle.start_reader(), Err(UsbSerialError::Disconnected));
assert_eq!(
handle.set_line_config(LineConfig::default()),
Err(UsbSerialError::Disconnected)
);
assert_eq!(
handle.set_flow_control(FlowControl::None),
Err(UsbSerialError::Disconnected)
);
assert_eq!(handle.set_dtr(true), Err(UsbSerialError::Disconnected));
assert_eq!(handle.set_rts(true), Err(UsbSerialError::Disconnected));
assert_eq!(handle.set_break(true), Err(UsbSerialError::Disconnected));
assert_eq!(
handle.purge(PurgeKind::Both),
Err(UsbSerialError::Disconnected)
);
assert_eq!(
handle.clear(PurgeKind::Both),
Err(UsbSerialError::Disconnected)
);
assert!(matches!(
handle.modem_status(),
Err(UsbSerialError::Disconnected)
));
handle.close();
}

#[test]
fn fresh_handle_can_write_after_previous_session_closes() {
let fake = FakeTransport::cdc_iad();
let mut old = open_port(Arc::new(fake.clone()), 0).unwrap();
old.close();
let mut fresh = open_port(Arc::new(fake), 0).unwrap();
assert_eq!(fresh.write(b"1").unwrap(), 1);
}
Loading
Loading