Most serial port libraries in Rust are synchronous and blocking — fine for scripts, but not for a real-time system where you need to read and react to serial data with sub-millisecond latency. This post walks through how cycbox-serialport wraps a patched fork of serialport-rs to expose a proper tokio::io::AsyncRead / AsyncWrite interface, with platform-native async I/O on both POSIX and Windows.

Why not just use blocking I/O with spawn_blocking?

The obvious way to bolt async onto a blocking serial API is to run reads and writes on a blocking thread pool via tokio::task::spawn_blocking. It works, but it costs you a thread-pool round trip on every single I/O operation, and it doesn’t compose well with select! or cancellation — you can’t just drop a future to cancel a blocking read that’s already in the kernel.

For a library whose entire reason to exist is shaving latency down toward 1ms, that overhead is the thing we’re trying to eliminate. So cycbox-serialport takes the other path: use each platform’s native asynchronous I/O facility directly, and drive it from Future::poll the way tokio’s own TcpStream does.

That native facility differs completely between POSIX and Windows, which is why the crate has two independent backend implementations behind the same public SerialStream type.

Two platforms, two async models

cycbox-serialport/src/
├── posix/
│   ├── mio_stream.rs     // mio::event::Source wrapper around a TTYPort fd
│   └── tokio_stream.rs   // AsyncFd<MioStream> -> AsyncRead/AsyncWrite
└── windows/
    └── tokio_stream.rs   // OVERLAPPED I/O + thread-pool wait callbacks

On POSIX, the kernel already gives you readiness-based async I/O for free: a serial device file descriptor behaves like any other pollable fd. On Windows, ReadFile/WriteFile on a COMMTIMEOUTS-configured handle are still fundamentally synchronous unless the handle is opened with FILE_FLAG_OVERLAPPED, which switches the API to a completion-based model instead. Fitting a completion-based API under poll_read/poll_write (which is a readiness-based contract — “come back and try again”) takes noticeably more machinery than fitting a readiness-based one.

POSIX: mio + AsyncFd, plus one termios flag

The POSIX backend is almost boring by design, which is the point — reuse what tokio already does well.

MioStream (posix/mio_stream.rs) is a thin wrapper around the upstream serialport::TTYPort that implements mio::event::Source by delegating registration to SourceFd on the underlying raw fd:

impl Source for MioStream {
    fn register(&mut self, registry: &Registry, token: Token, interests: Interest) -> io::Result<()> {
        SourceFd(&self.as_raw_fd()).register(registry, token, interests)
    }
    // reregister / deregister follow the same pattern
}

SerialStream then wraps that in tokio’s AsyncFd<MioStream>, and poll_read/poll_write are the textbook AsyncFd pattern: wait for readiness, attempt a raw libc::read/libc::write, and loop back to waiting if the kernel says WouldBlock:

fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
    loop {
        let mut guard = ready!(self.inner.poll_read_ready(cx))?;
        match guard.try_io(|inner| inner.get_ref().read(buf.initialize_unfilled())) {
            Ok(Ok(bytes_read)) => { buf.advance(bytes_read); return Poll::Ready(Ok(())); }
            Ok(Err(err)) => return Poll::Ready(Err(err)),
            Err(_would_block) => continue,
        }
    }
}

There’s no custom reactor, no manual epoll registration bookkeeping — AsyncFd and the tokio runtime’s own epoll/kqueue reactor do all of that. This is the same pattern tokio itself uses for TcpStream, UnixStream, and friends.

The one place we had to reach below serialport-rs’s public API was latency, not async plumbing. On Linux, a USB-serial adapter’s line discipline can batch received bytes for several milliseconds before waking up the reading process, which alone can blow the sub-1ms budget regardless of how fast the async plumbing is. The classic knob for this is ASYNC_LOW_LATENCY, set via the TIOCSSERIAL ioctl — something upstream serialport-rs doesn’t expose. Our fork adds it as a builder option:

if builder.low_latency {
    let mut serial_info = MaybeUninit::<SerialStruct>::uninit();
    if unsafe { tiocgserial(fd.0, serial_info.as_mut_ptr()) }.is_ok() {
        let mut serial_info = unsafe { serial_info.assume_init() };
        serial_info.flags |= ASYNC_LOW_LATENCY as i32;
        let _ = unsafe { tiocsserial(fd.0, &serial_info) };
    }
}

Errors here are deliberately ignored: not every USB-serial driver implements TIOCGSERIAL/TIOCSSERIAL (many CDC-ACM devices don’t), and we’d rather silently fall back to default latency than fail to open the port over a best-effort optimization.

Windows: overlapped I/O

cycbox-serialport open the port with FILE_FLAG_OVERLAPPED, and register a manual-reset event with the Windows thread pool via RegisterWaitForSingleObject. When the kernel signals the event on I/O completion, the thread pool invokes our callback on one of its own worker threads.

Opening the handle for overlapped I/O

The fork’s changes to serialport-rs start at the CreateFileW call, which needs the overlapped flag and, when async I/O is requested, timeouts tuned so that a pending ReadFile blocks indefinitely at the driver level rather than timing out and returning early:

let file_attributes = if builder.async_io {
    FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED
} else {
    FILE_ATTRIBUTE_NORMAL
};

let handle = unsafe {
    CreateFileW(name.as_ptr(), GENERIC_READ | GENERIC_WRITE, share_mode,
                ptr::null_mut(), OPEN_EXISTING, file_attributes, 0 as HANDLE)
};
fn set_async_timeout(&mut self) -> Result<()> {
    let timeouts = COMMTIMEOUTS {
        ReadIntervalTimeout: 0xFFFFFFFF,
        ReadTotalTimeoutMultiplier: 0xFFFFFFFF,
        ReadTotalTimeoutConstant: 0xEFFFFFFF,
        WriteTotalTimeoutMultiplier: 0,
        WriteTotalTimeoutConstant: 0,
    };
    if unsafe { SetCommTimeouts(self.handle, &timeouts) } == 0 {
        return Err(super::error::last_os_error());
    }
    Ok(())
}

This specific combination of COMMTIMEOUTS fields is documented Win32 behavior for making ReadFile return immediately with whatever bytes are already buffered, without blocking to fill the caller’s buffer — the actual waiting for more data happens asynchronously via the overlapped completion, not inside ReadFile itself.

low_latency() and async_io() are both new builder methods added on the fork’s SerialPortBuilder (src/lib.rs) so they read the same way on every platform even though only one branch does anything on POSIX and the other on Windows:

pub fn low_latency(mut self, enable: bool) -> Self { self.low_latency = enable; self }
pub fn async_io(mut self, enable: bool) -> Self { self.async_io = enable; self }

Driving ReadFile/WriteFile from poll_read/poll_write

This is the part that has no equivalent on the POSIX side. Each SerialStream owns a ReadState and a WriteState, each holding an OVERLAPPED struct, a manual-reset event HANDLE, an optional thread-pool wait handle, a Waker, and a scratch buffer (overlapped I/O requires the buffer to stay alive and unmoved for the duration of the operation, so it can’t just borrow the caller’s &mut [u8] across a Pending boundary — it’s copied into state.buffer instead):

struct ReadState {
    overlapped: Box<OVERLAPPED>,
    event: HANDLE,
    wait_handle: Option<HANDLE>,
    waker: Option<Waker>,
    buffer: Vec<u8>,
    pending: bool,
    completed: bool,
    bytes_transferred: u32,
}

poll_read walks through four states on every call:

  1. A previous operation already completed (state.completed): call GetOverlappedResult with a zero timeout to retrieve the byte count without blocking, copy from the scratch buffer into the caller’s ReadBuf, reset state, and return Ready.
  2. An operation is still in flight (state.pending): just stash the new Waker and return Pending. The thread-pool callback will wake it later.
  3. Nothing in flight yet: peek at buffered bytes with ClearCommError/COMSTAT as a cheap optimization, then issue a fresh overlapped ReadFile.
  4. ReadFile result: if it returns immediately (small, already-buffered reads often do), handle the data synchronously and return Ready — no need to touch the thread pool at all. If it returns FALSE with GetLastError() == ERROR_IO_PENDING, register the event with RegisterWaitForSingleObject and go Pending.
let result = unsafe {
    ReadFile(handle, state.buffer.as_mut_ptr() as *mut _, bytes_to_read as u32,
             &mut bytes_read, state.overlapped.as_mut() as *mut _)
};

if result != 0 {
    // completed synchronously — copy out and return Ready immediately
    ...
    return Poll::Ready(Ok(()));
}

let err = unsafe { GetLastError() };
if err != ERROR_IO_PENDING {
    return Poll::Ready(Err(std::io::Error::from_raw_os_error(err as i32)));
}

let result = unsafe {
    RegisterWaitForSingleObject(&mut wait_handle, state.event,
        Some(read_completion_callback), state_ptr, INFINITE, WT_EXECUTEONLYONCE)
};
state.wait_handle = Some(wait_handle);
state.pending = true;
state.waker = Some(cx.waker().clone());
Poll::Pending

The callback that fires on the thread pool’s worker thread is deliberately minimal — it only flips a flag and wakes the task, leaving all the real work (retrieving the result, copying bytes) to the next poll_read call on the executor:

unsafe extern "system" fn read_completion_callback(context: *mut std::ffi::c_void, _timer_fired: bool) {
    let state = context as *const Mutex<ReadState>;
    if let Some(state) = state.as_ref() {
        if let Ok(mut state) = state.lock() {
            state.completed = true;
            if let Some(waker) = state.waker.take() {
                waker.wake();
            }
        }
    }
}

poll_write mirrors this exactly, with WriteFile in place of ReadFile.

A few details worth calling out because they’re easy to get wrong with overlapped I/O:

  • WT_EXECUTEONLYONCE unregisters the wait after it fires once, so each read/write cycle gets a clean registration rather than accumulating repeat callbacks.
  • The event is manual-reset, not auto-reset — CreateEventW(ptr::null(), 1, 0, ptr::null()). With an auto-reset event there’s a race between the kernel signaling completion and the thread pool’s wait resetting it versus our own GetOverlappedResult call observing it; manual-reset avoids that class of bug at the cost of having to explicitly clear state ourselves on reset().
  • Drop uses UnregisterWaitEx(wait_handle, INVALID_HANDLE_VALUE), not UnregisterWait. Passing INVALID_HANDLE_VALUE makes the call block until any in-flight callback has finished executing — without that, dropping a SerialStream while a callback is mid-flight could leave the thread pool invoking a callback into memory that’s already been freed.

Tying it together: one API, two backends

None of this backend complexity leaks into the public API. Both platforms implement the same AsyncRead/AsyncWrite/SerialControl traits, so application code is identical regardless of OS:

use cycbox_serialport::{SerialPortBuilderExt, DataBits, Parity, StopBits, FlowControl};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

let mut port = cycbox_serialport::new("/dev/ttyUSB0", 115200)
    .data_bits(DataBits::Eight)
    .parity(Parity::None)
    .stop_bits(StopBits::One)
    .flow_control(FlowControl::None)
    .low_latency(true)   // Linux: ASYNC_LOW_LATENCY via TIOCSSERIAL
    .async_io(true)      // Windows: FILE_FLAG_OVERLAPPED + IOCP-backed waits
    .open_native_async()?;

port.write_all(b"Hello, serial port!").await?;
port.flush().await?;

let mut buffer = vec![0u8; 128];
let n = port.read(&mut buffer).await?;

.low_latency() is a no-op on Windows and .async_io() is a no-op on Linux — each flag only affects the platform it was designed for, but it’s harmless to set both unconditionally in cross-platform application code.

What this bought us

Compared to spawn_blocking-wrapped I/O, both backends remove the thread-pool hop entirely: reads and writes are driven directly by the runtime’s own reactor (epoll/kqueue via AsyncFd on POSIX, the NT thread pool’s wait callbacks on Windows) instead of bouncing through a separate blocking-thread queue. Combined with ASYNC_LOW_LATENCY cutting down driver-side buffering on Linux, that’s what makes the sub-1ms read/write latency goal realistic rather than aspirational — see this demo of a 1ms hardware timer driven over serial with CycBox.