Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8ce2975
WIP: consolidate sync adapter tests
puddly Mar 5, 2026
8246be6
WIP: consolidate async adapter tests
puddly Mar 5, 2026
35309bf
Finish cleanup
puddly Mar 5, 2026
0afafde
Create `Serial.from_url`
puddly Mar 5, 2026
ec4205f
Clean up README
puddly Mar 5, 2026
8688116
Fix Windows tests
puddly Mar 5, 2026
3941d7e
Skip rts/dtr tests for virtual adapters
puddly Mar 5, 2026
5eb65dd
Simplify naming
puddly Mar 5, 2026
6ccf483
Skip bad tests on Windows
puddly Mar 5, 2026
5057fbb
Remove flaky Linux-only test
puddly Mar 5, 2026
ad77092
One more bad Windows test
puddly Mar 5, 2026
f2f6bb1
Improve coverage
puddly Mar 5, 2026
594e43d
Remove unnecessary skips
puddly Mar 5, 2026
c6b051b
Clean up backpressure tests
puddly Mar 5, 2026
a66cbaa
Test socket disconnect via socat
puddly Mar 5, 2026
b230d01
Ignore backpressure with com0com
puddly Mar 5, 2026
fb9e4bd
Implement custom markers for skipping/requiring backends
puddly Mar 5, 2026
8578165
Remove unnecessary timeout in join causing flaky Linux tests
puddly Mar 5, 2026
6bdeb57
Make existing tests pass on real adapters
puddly Mar 6, 2026
944ab6a
Fix flaky Windows timing
puddly Mar 6, 2026
8d09b9e
Do not call `connection_lost` without `connection_made`
puddly Mar 6, 2026
70ee57b
WIP: test fix?
puddly Mar 6, 2026
4dfd279
Add regression tests
puddly Mar 6, 2026
b32e5af
Consolidate `wait_closed` into `BaseSerialTransport`
puddly Mar 6, 2026
2f7eca3
Add generic transport tests for some missing functionality
puddly Mar 6, 2026
3cf7437
Fix windows failure
puddly Mar 6, 2026
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
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ with serialx.Serial("/dev/serial/by-id/port", baudrate=115200) as serial:

serial.set_modem_pins(rts=True, dtr=True)
pins = serial.get_modem_pins()
assert pins.rts is True
assert pins.dtr is True
assert pins.rts is serialx.PinState.HIGH
assert pins.dtr is serialx.PinState.HIGH
```

A high-level asynchronous serial `(reader, writer)` pair:
Expand Down Expand Up @@ -89,17 +89,13 @@ Set up pre-commit hooks with `pre-commit install`. Your code will then be type c
and auto-formatted when you run `git commit`. You can do this on-demand with
`pre-commit run`.

Serialx relies on automated testing. We do not have a custom GitHub Actions runners with
real hardware (yet) so GitHub Actions CI will only run `socat` tests.

To run tests locally with loopback adapters and pairs of real adapters, pass CLI flags
to `pytest`:
Serialx relies on automated testing. CI runs tests using both `socat` virtual PTYs
(Linux/macOS) and socket-based serial pairs. To also test with physical adapter pairs,
pass CLI flags to `pytest`:

```bash
pytest --loopback-adapter=/dev/serial/by-id/my-loopback-adapter \
--adapter-pair=/dev/serial/by-id/left1:/dev/serial/by-id/right1 \
pytest --adapter-pair=/dev/serial/by-id/left1:/dev/serial/by-id/right1 \
--adapter-pair=/dev/serial/by-id/left2:/dev/serial/by-id/right2
```

By default, this will run tests in parallel across all adapter pairs. You can disable
this by passing `-n 0` to `pytest`, which will execute the tests sequentially.
By default, tests run in parallel. You can disable this by passing `-n 0` to `pytest`.
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ show_error_context = true
source = ["serialx"]
relative_files = true

[tool.coverage.paths]
source = [
"serialx/",
"serialx\\",
]

[tool.ruff]
target-version = "py310"

Expand Down
40 changes: 40 additions & 0 deletions serialx/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ def __init__(

self._auto_close = False

@classmethod
def from_url(cls, url: str, *args: Any, **kwargs: Any) -> BaseSerial:
"""Create the appropriate serial port subclass for the given URL."""
serial_cls, _ = get_serial_classes(url)
return serial_cls(url, *args, **kwargs)

def open(self) -> None:
"""Open the serial port."""
self._open()
Expand Down Expand Up @@ -368,11 +374,41 @@ def __init__(

self._serial: BaseSerial | None = None
self._closing: bool = False
self._closed_waiter: asyncio.Future[None] = loop.create_future()

def is_closing(self) -> bool:
"""Return whether the transport is closing."""
return self._closing

def _resolve_closed_waiter(self) -> None:
if not self._closed_waiter.done():
self._closed_waiter.set_result(None)

def _call_protocol_connection_lost(self, exc: Exception | None) -> None:
try:
self._protocol.connection_lost(exc)
except (SystemExit, KeyboardInterrupt):
raise
except BaseException as protocol_exc:
self._loop.call_exception_handler(
{
"message": "protocol.connection_lost() failed",
"exception": protocol_exc,
"transport": self,
"protocol": self._protocol,
}
)
finally:
self._resolve_closed_waiter()

def get_protocol(self) -> asyncio.Protocol:
"""Get the protocol used by this transport."""
return self._protocol

def set_protocol(self, protocol: asyncio.Protocol) -> None: # type: ignore[override]
"""Set the protocol to use with this transport."""
self._protocol = protocol

@property
def serial(self) -> BaseSerial:
"""Get the serial port instance."""
Expand Down Expand Up @@ -462,6 +498,10 @@ async def flush(self) -> None:
"""Flush write buffers, waiting until all data is written."""
raise NotImplementedError

async def wait_closed(self) -> None:
"""Wait until transport is fully closed."""
await self._closed_waiter


def get_serial_classes(
url: str,
Expand Down
101 changes: 72 additions & 29 deletions serialx/descriptor_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import logging
import os
import typing
from typing import Any
import warnings

from .common import BaseSerialTransport

LOGGER = logging.getLogger(__name__)
LOG_THRESHOLD_FOR_CONNLOST_WRITES = 5

Expand All @@ -27,7 +28,18 @@ def _create_background_task(coro: Coroutine) -> asyncio.Task[None]:
return task


class DescriptorTransport(asyncio.Transport):
def _safe_close(fd: int) -> None:
"""Close a file descriptor but do not error if it is already closed."""
try:
os.close(fd)
except OSError as exc:
if exc.errno != errno.EBADF:
raise

LOGGER.debug("File descriptor %d is already closed")


class DescriptorTransport(BaseSerialTransport):
"""File descriptor transport using asyncio."""

max_size = 256 * 1024 # max bytes we read in one event loop iteration
Expand All @@ -40,30 +52,31 @@ def __init__(
extra: dict[str, typing.Any] | None = None,
) -> None:
"""Initialize the descriptor transport."""
super().__init__(loop, protocol)
self._fileno: int | None = None

self._loop: asyncio.AbstractEventLoop = loop
self._set_write_buffer_limits()
self._protocol_paused = False

self._protocol = protocol
self._buffer = bytearray()
self._conn_lost_count = 0
self._closing = False
self._paused = False
self._empty_waiter: asyncio.Future | None = None
self._extra: dict[str, Any] = {}
if extra is not None:
self._extra.update(extra)

self._close_task: asyncio.Task[None] | None = None
self._connection_made: bool = False

async def _open(self, path: os.PathLike) -> None:
self._fileno = await self._loop.run_in_executor(
None, os.open, path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK
)

async def _connect(self) -> None:
async def _connect(self, **_kwargs) -> None:
assert self._fileno is not None
self._loop.add_reader(self._fileno, self._read_ready)
self._connection_made = True

def _read_ready(self) -> None:
LOGGER.debug("Event loop woke up reader")
Expand Down Expand Up @@ -285,14 +298,6 @@ def write_eof(self) -> None:
self._loop.remove_reader(self._fileno)
self._maybe_background_close(None)

def set_protocol(self, protocol: asyncio.Protocol) -> None: # type: ignore[override]
"""Set the protocol to use with this transport."""
self._protocol = protocol

def get_protocol(self) -> asyncio.Protocol:
"""Get the protocol used by this transport."""
return self._protocol

def close(self) -> None:
"""Close the transport."""
LOGGER.debug("Closing at the request of the application")
Expand All @@ -316,7 +321,7 @@ def __del__(self) -> None:
if self._loop is not None:
self._loop.remove_reader(self._fileno)

os.close(self._fileno)
_safe_close(self._fileno)

def _fatal_error(
self,
Expand Down Expand Up @@ -360,26 +365,64 @@ def _maybe_background_close(self, exc: Exception | None) -> None:
return

self._close_task = _create_background_task(self._call_connection_lost(exc))
self._close_task.add_done_callback(self._on_close_task_done)

def _on_close_task_done(self, task: asyncio.Task[None]) -> None:
if self._close_task is task:
self._close_task = None

if task.cancelled():
self._resolve_closed_waiter()
return

task_exc = task.exception()
if task_exc is not None:
if self._loop is not None:
self._loop.call_exception_handler(
{
"message": "Unhandled exception in background close task",
"exception": task_exc,
"transport": self,
"protocol": self._protocol,
}
)
self._resolve_closed_waiter()

async def _call_connection_lost(self, exc: Exception | None) -> None:
LOGGER.debug("Closing connection: %r", exc)

try:
assert self._fileno is not None
self._loop.remove_reader(self._fileno)
loop = self._loop
fileno = self._fileno

# For serial ports it would make sense to flush here BUT no modern serial
# driver requires this: once the data is enqueued, even `os.close` blocks
# for the entire transmit duration.
LOGGER.debug("Closing file descriptor %s", self._fileno)
await self._loop.run_in_executor(None, os.close, self._fileno)

self._fileno = None
try:
if fileno is not None:
loop.remove_reader(fileno)
self._fileno = None

# For serial ports it would make sense to flush here BUT no modern serial
# driver requires this: once the data is enqueued, even `os.close` blocks
# for the entire transmit duration.
LOGGER.debug("Closing file descriptor %s", fileno)
await loop.run_in_executor(None, _safe_close, fileno)
finally:
protocol = self._protocol
self._loop = None # type: ignore[assignment]
self._protocol = None # type: ignore[assignment]
self._close_task = None

LOGGER.debug("Calling protocol `connection_lost` with exc=%r", exc)
protocol.connection_lost(exc)
if self._connection_made:
LOGGER.debug("Calling protocol `connection_lost` with exc=%r", exc)
try:
protocol.connection_lost(exc)
except (SystemExit, KeyboardInterrupt):
raise
except BaseException as protocol_exc:
loop.call_exception_handler(
{
"message": "protocol.connection_lost() failed",
"exception": protocol_exc,
"transport": self,
"protocol": protocol,
}
)

self._resolve_closed_waiter()
12 changes: 2 additions & 10 deletions serialx/platforms/serial_posix.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,7 @@

from typing_extensions import Buffer

from ..common import (
BaseSerial,
BaseSerialTransport,
ModemPins,
Parity,
PinState,
SerialPortInfo,
StopBits,
)
from ..common import BaseSerial, ModemPins, Parity, PinState, SerialPortInfo, StopBits
from ..descriptor_transport import DescriptorTransport

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -517,7 +509,7 @@ def write(self, data: Buffer) -> int:
return os.write(self._fileno, data) # type: ignore[arg-type]


class PosixSerialTransport(DescriptorTransport, BaseSerialTransport):
class PosixSerialTransport(DescriptorTransport):
"""POSIX serial port transport using asyncio."""

_serial_cls = PosixSerial
Expand Down
13 changes: 12 additions & 1 deletion serialx/platforms/serial_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def _connection_lost(self, exc: Exception | None) -> None:
self._connection_lost_called = True
self._closing = True
self._tcp_transport = None
self._protocol.connection_lost(exc)
self._call_protocol_connection_lost(exc)

def _tcp_connection_lost(self) -> None:
"""Track the underlying TCP transport's connection_lost callback."""
Expand Down Expand Up @@ -234,6 +234,17 @@ def is_closing(self) -> bool:
"""Return whether the transport is closing."""
return self._closing

def abort(self) -> None:
"""Abort the transport immediately."""
if self._connection_lost_called:
return
self._closing = True

if self._tcp_transport is not None:
self._tcp_transport.abort()
else:
self._connection_lost(None)

def close(self) -> None:
"""Close the transport."""
if self._connection_lost_called:
Expand Down
Loading