diff --git a/README.md b/README.md index 0fd02b1..69991fb 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index aa035ac..1b99a38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,12 @@ show_error_context = true source = ["serialx"] relative_files = true +[tool.coverage.paths] +source = [ + "serialx/", + "serialx\\", +] + [tool.ruff] target-version = "py310" diff --git a/serialx/common.py b/serialx/common.py index b944fc8..90be024 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -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() @@ -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.""" @@ -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, diff --git a/serialx/descriptor_transport.py b/serialx/descriptor_transport.py index a81bf8a..4a1ed78 100644 --- a/serialx/descriptor_transport.py +++ b/serialx/descriptor_transport.py @@ -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 @@ -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 @@ -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") @@ -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") @@ -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, @@ -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() diff --git a/serialx/platforms/serial_posix.py b/serialx/platforms/serial_posix.py index d8064af..f157752 100644 --- a/serialx/platforms/serial_posix.py +++ b/serialx/platforms/serial_posix.py @@ -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__) @@ -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 diff --git a/serialx/platforms/serial_socket.py b/serialx/platforms/serial_socket.py index 913f4ee..0130ceb 100644 --- a/serialx/platforms/serial_socket.py +++ b/serialx/platforms/serial_socket.py @@ -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.""" @@ -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: diff --git a/serialx/platforms/serial_win32.py b/serialx/platforms/serial_win32.py index 67ec8af..4f91aa6 100644 --- a/serialx/platforms/serial_win32.py +++ b/serialx/platforms/serial_win32.py @@ -402,6 +402,7 @@ def __init__( self._handle: int | None = None self._internal_transport = None self._closing: bool = False + self._connect_in_progress: bool = False def serial_close(self): """Close the serial port.""" @@ -415,7 +416,7 @@ def _close_then_notify() -> None: except Exception as e: exc = e - self._loop.call_soon_threadsafe(self._protocol.connection_lost, exc) + self._loop.call_soon_threadsafe(self._call_protocol_connection_lost, exc) self._loop.run_in_executor(None, _close_then_notify) @@ -440,7 +441,7 @@ def protocol_connection_made(self, transport: asyncio.Transport) -> None: def protocol_connection_lost(self, exc: Exception | None) -> None: """Forward connection_lost to the protocol.""" - pass + self._resolve_closed_waiter() def protocol_pause_writing(self) -> None: """Forward pause_writing to the protocol.""" @@ -468,6 +469,11 @@ async def _open(self, path: os.PathLike) -> None: async def _connect(self, **kwargs) -> None: """Connect to the serial port.""" + if self._closing: + self._resolve_closed_waiter() + return + + self._connect_in_progress = True path = kwargs.pop("path") await self._open(path) @@ -488,6 +494,13 @@ async def _connect(self, **kwargs) -> None: await self._loop.run_in_executor(None, self._serial.configure_port) + if self._closing: + await self._loop.run_in_executor(None, self._serial.close) + self._serial = None + self._handle = None + self._resolve_closed_waiter() + return + # Use the internal _make_duplex_pipe_transport to create a true overlapping # bidirectional transport on the single handle. assert hasattr(self._loop, "_make_duplex_pipe_transport") @@ -513,10 +526,15 @@ async def _connect(self, **kwargs) -> None: ), extra=self._extra, ) + if self._closing: + self._internal_transport.close() except BaseException: await self._loop.run_in_executor(None, _safe_close_handle, self._handle) + self._serial = None self._handle = None raise + finally: + self._connect_in_progress = False def get_write_buffer_size(self) -> int: """Return the current size of the write buffer.""" @@ -525,6 +543,20 @@ def get_write_buffer_size(self) -> int: return self._internal_transport.get_write_buffer_size() + def get_write_buffer_limits(self) -> tuple[int, int]: + """Return the write buffer limits.""" + if self._internal_transport is None: + return (0, 0) + + return self._internal_transport.get_write_buffer_limits() + + def set_write_buffer_limits(self, high=None, low=None) -> None: + """Set the write buffer limits.""" + if self._internal_transport is None: + raise RuntimeError("Transport not connected") + + self._internal_transport.set_write_buffer_limits(high=high, low=low) + def write(self, data): """Write data to the transport.""" if self._internal_transport is None: @@ -538,12 +570,16 @@ def close(self) -> None: if self._internal_transport is not None: # Internal transport closes self._serial via sock.close() self._internal_transport.close() + elif not self._connect_in_progress: + self._resolve_closed_waiter() def abort(self) -> None: """Abort the transport immediately.""" self._closing = True if self._internal_transport is not None: self._internal_transport.abort() + elif not self._connect_in_progress: + self._resolve_closed_waiter() def pause_reading(self): """Pause reading from the transport.""" @@ -561,10 +597,6 @@ def set_protocol(self, protocol: asyncio.Protocol) -> None: # type: ignore[over if self._internal_transport is not None: self._internal_transport.set_protocol(protocol) - def get_protocol(self) -> asyncio.Protocol: - """Return the current protocol.""" - return self._protocol - async def flush(self) -> None: """Flush write buffers, waiting until all data is written.""" assert self._serial is not None diff --git a/tests/common.py b/tests/common.py index 85b9b0f..ab3e938 100644 --- a/tests/common.py +++ b/tests/common.py @@ -8,7 +8,7 @@ import subprocess import tempfile import time -from typing import Any +from typing import Any, NamedTuple import pytest @@ -18,6 +18,23 @@ SOCAT_BINARY = shutil.which("socat") +class SerialPair(NamedTuple): + """A connected pair of serial port paths with backend metadata.""" + + left: str + right: str + backend: str # "socat", "socket", "adapter", or "com0com" + + +class BridgedSocatPair(NamedTuple): + """A bridged socat pair where killing one side propagates EOF to the other.""" + + left: str + right: str + left_process: asyncio.subprocess.Process + right_process: asyncio.subprocess.Process + + @contextlib.contextmanager def create_socat_pair() -> Iterator[tuple[str, str]]: """Create a pair of virtual PTYs using socat (synchronous).""" @@ -76,6 +93,66 @@ async def async_create_socat_pair() -> AsyncIterator[tuple[str, str]]: await proc.wait() +@contextlib.asynccontextmanager +async def async_create_bridged_socat_pair() -> AsyncIterator[BridgedSocatPair]: + """Create a pair of PTYs bridged via two socat processes over a Unix socket. + + Unlike `async_create_socat_pair`, killing one socat process propagates + through the bridge and tears down the other side, triggering a real EOF. + """ + with tempfile.TemporaryDirectory() as tmpdir: + left_tty = os.path.join(tmpdir, "ttyLeft") + right_tty = os.path.join(tmpdir, "ttyRight") + sock_path = os.path.join(tmpdir, "bridge.sock") + + listener = await asyncio.create_subprocess_exec( + "socat", + f"PTY,link={left_tty},raw,echo=0", + f"UNIX-LISTEN:{sock_path}", + stderr=asyncio.subprocess.DEVNULL, + ) + + # Wait for the socket to appear + for _ in range(100): + if os.path.exists(sock_path): + break + await asyncio.sleep(0.01) + else: + raise RuntimeError("socat listener socket was not created in time") + + connector = await asyncio.create_subprocess_exec( + "socat", + f"PTY,link={right_tty},raw,echo=0", + f"UNIX-CONNECT:{sock_path}", + stderr=asyncio.subprocess.DEVNULL, + ) + + # Wait for both PTYs to appear + for _ in range(100): + if os.path.exists(left_tty) and os.path.exists(right_tty): + break + await asyncio.sleep(0.01) + else: + raise RuntimeError("socat PTYs were not created in time") + + assert listener.returncode is None + assert connector.returncode is None + + yield BridgedSocatPair( + left=left_tty, + right=right_tty, + left_process=listener, + right_process=connector, + ) + + if connector.returncode is None: + connector.terminate() + await connector.wait() + if listener.returncode is None: + listener.terminate() + await listener.wait() + + @contextlib.asynccontextmanager async def async_create_reader_writer( port: str | None, @@ -126,79 +203,6 @@ async def async_create_reader_writer_pair( await writer_right.wait_closed() -@contextlib.asynccontextmanager -async def async_create_dual_loopback( - left_port: str, - right_port: str, - **kwargs: Any, -) -> AsyncIterator[ - tuple[ - asyncio.StreamReader, - serialx.SerialStreamWriter[BaseSerialTransport], - asyncio.StreamReader, - serialx.SerialStreamWriter[BaseSerialTransport], - ] -]: - """Create reader/writer pairs for dual loopback configuration. - - Returns (reader_left, writer_left, reader_right, writer_right). - """ - reader_left, writer_left = await serialx.open_serial_connection(left_port, **kwargs) - reader_right, writer_right = await serialx.open_serial_connection( - right_port, **kwargs - ) - - try: - yield (reader_left, writer_left, reader_right, writer_right) - finally: - writer_left.close() - writer_right.close() - await writer_left.wait_closed() - await writer_right.wait_closed() - - -@contextlib.contextmanager -def create_connected_pair( - **kwargs: Any, -) -> Iterator[tuple[serialx.Serial, serialx.Serial]]: - """Create a connected pair of serial ports with socat.""" - left_kwargs = {} - right_kwargs = {} - shared_kwargs = {} - - for key, value in kwargs.items(): - if key.startswith("left_"): - left_kwargs[key[5:]] = value - elif key.startswith("right_"): - right_kwargs[key[6:]] = value - else: - shared_kwargs[key] = value - - with create_socat_pair() as (in_tty, out_tty): - with ( - serialx.Serial(in_tty, **left_kwargs, **shared_kwargs) as left_serial, - serialx.Serial(out_tty, **right_kwargs, **shared_kwargs) as right_serial, - ): - yield (left_serial, right_serial) - - -@contextlib.contextmanager -def create_dual_loopback( - left_port: str, - right_port: str, - **kwargs: Any, -) -> Iterator[tuple[serialx.Serial, serialx.Serial]]: - """Create a connected pair of serial ports with dual loopback hardware. - - Returns (serial_left, serial_right). - """ - with ( - serialx.Serial(left_port, **kwargs) as serial_left, - serialx.Serial(right_port, **kwargs) as serial_right, - ): - yield (serial_left, serial_right) - - @contextlib.contextmanager def measure_time() -> Iterator[Callable[[], float]]: """Measure elapsed time in a context.""" diff --git a/tests/conftest.py b/tests/conftest.py index 10b4cde..7bb6e7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,17 @@ """Pytest configuration for serialx tests.""" +from collections.abc import Generator +import re import sys import time import pytest import serialx +from tests.common import SOCAT_BINARY, SerialPair, create_socat_pair +from tests.socket_relay import create_socket_pair + +COM0COM_RE = re.compile(r"^CNC[A-Z]\d+$", re.IGNORECASE) def pytest_configure(config: pytest.Config) -> None: @@ -14,16 +20,18 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "xdist_group(name): group tests for pytest-xdist parallel execution control", ) + config.addinivalue_line( + "markers", + "skip_backends(*backends): skip test for the listed serial_pair backends", + ) + config.addinivalue_line( + "markers", + "require_backends(*backends): only run test for the listed serial_pair backends", + ) def pytest_addoption(parser: pytest.Parser) -> None: """Add custom command line options for serial adapter configuration.""" - parser.addoption( - "--loopback-adapter", - action="append", - default=[], - help="Serial loopback adapter device path (can be specified multiple times)", - ) parser.addoption( "--adapter-pair", action="append", @@ -32,11 +40,6 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) -def _get_loopback_adapters(config: pytest.Config) -> list[str]: - """Get list of loopback adapters from config.""" - return config.getoption("--loopback-adapter") - - def _get_adapter_pairs(config: pytest.Config) -> list[tuple[str, str]]: """Get list of adapter pairs from config.""" pairs = [] @@ -54,53 +57,91 @@ def _get_adapter_pairs(config: pytest.Config) -> list[tuple[str, str]]: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Parametrize tests based on configured adapters.""" - if "loopback_adapter" in metafunc.fixturenames: - adapters = _get_loopback_adapters(metafunc.config) - - if adapters: - metafunc.parametrize( - "loopback_adapter", - [ - pytest.param( - adapter, - marks=[pytest.mark.xdist_group(name=f"adapter:{adapter}")], - id=f"{adapter}", - ) - for adapter in adapters - ], + """Parametrize tests based on available backends.""" + if "serial_pair" in metafunc.fixturenames: + params = [] + + if SOCAT_BINARY: + params.append(pytest.param(("socat",), id="socat")) + + params.append(pytest.param(("socket",), id="socket")) + + for left, right in _get_adapter_pairs(metafunc.config): + if COM0COM_RE.match(left) or COM0COM_RE.match(right): + backend = "com0com" + else: + backend = "adapter" + + params.append( + pytest.param( + (backend, left, right), + marks=[pytest.mark.xdist_group(name=f"pair:{left}:{right}")], + id=f"{left}:{right}", + ) ) - else: - pytest.skip("No loopback adapters configured via --loopback-adapter") + + metafunc.parametrize("serial_pair", params, indirect=True) if "adapter_pair" in metafunc.fixturenames: pairs = _get_adapter_pairs(metafunc.config) - if pairs: - metafunc.parametrize( - "adapter_pair", - [ - pytest.param( - (left, right), - marks=[pytest.mark.xdist_group(name=f"pair:{left}:{right}")], - id=f"{left}:{right}", - ) - for left, right in pairs - ], - ) - else: - pytest.skip("No adapter pairs configured via --adapter-pair") + metafunc.parametrize( + "adapter_pair", + [ + pytest.param( + (left, right), + marks=[pytest.mark.xdist_group(name=f"pair:{left}:{right}")], + id=f"{left}:{right}", + ) + for left, right in pairs + ], + ) + + +@pytest.fixture +def serial_pair(request: pytest.FixtureRequest) -> Generator[SerialPair]: + """Yield a connected serial port pair with backend metadata. + + Parametrized over all available backends: socat, socket, and any + physical adapter pairs passed via --adapter-pair. + """ + backend_info = request.param + backend = backend_info[0] + + for marker in request.node.iter_markers("skip_backends"): + if backend in marker.args: + pytest.skip(f"Skipped for backend {backend!r}") + + for marker in request.node.iter_markers("require_backends"): + if backend not in marker.args: + pytest.skip(f"Requires backend in {marker.args!r}, got {backend!r}") + + if backend == "socat": + with create_socat_pair() as (left, right): + yield SerialPair(left, right, "socat") + elif backend == "socket": + with create_socket_pair() as (left, right): + yield SerialPair(left, right, "socket") + elif backend in ("adapter", "com0com"): + yield SerialPair(backend_info[1], backend_info[2], backend) @pytest.fixture(autouse=True) -def _purge_adapter_pair(request: pytest.FixtureRequest) -> None: - """Purge both sides of a com0com pair to prevent data leakage between tests. +def _purge_com0com(request: pytest.FixtureRequest) -> None: + """Purge com0com buffers to prevent data leakage between tests. com0com buffers data in its virtual cable even when the receiving port is closed. Previous tests that write without reading leave stale bytes that pollute the next test. """ - if sys.platform != "win32" or "adapter_pair" not in request.fixturenames: + if sys.platform != "win32": + return + + if "serial_pair" not in request.fixturenames: + return + + candidate: SerialPair = request.getfixturevalue("serial_pair") + if candidate.backend != "com0com": return from win32file import ( # noqa: PLC0415 @@ -111,13 +152,9 @@ def _purge_adapter_pair(request: pytest.FixtureRequest) -> None: PurgeComm, ) - pair = request.getfixturevalue("adapter_pair") - left, right = pair - - # Drain any in-flight bytes from the emulated cable (EmuBR=yes simulates baudrates) with ( - serialx.Serial(left, baudrate=10_000_000) as serial_left, - serialx.Serial(right, baudrate=10_000_000) as serial_right, + serialx.Serial.from_url(candidate.left, baudrate=10_000_000) as serial_left, + serialx.Serial.from_url(candidate.right, baudrate=10_000_000) as serial_right, ): flags = PURGE_TXABORT | PURGE_RXABORT | PURGE_TXCLEAR | PURGE_RXCLEAR PurgeComm(serial_left._handle, flags) # type: ignore[attr-defined] diff --git a/tests/socket_relay.py b/tests/socket_relay.py index 0142ce2..d1120f2 100644 --- a/tests/socket_relay.py +++ b/tests/socket_relay.py @@ -199,7 +199,7 @@ def close(self) -> None: for conn in connections: self._close_socket(conn) for relay_thread in self.relay_threads: - relay_thread.join(timeout=1) + relay_thread.join() LOGGER.debug("stopped socket pair servers") diff --git a/tests/test_async_transports.py b/tests/test_async_transports.py index deec97c..bbefbcd 100644 --- a/tests/test_async_transports.py +++ b/tests/test_async_transports.py @@ -1,7 +1,6 @@ """Async transport tests.""" import asyncio -from collections.abc import AsyncIterator import contextlib import logging import os @@ -19,197 +18,90 @@ ModemPins, Parity, PinState, - SerialStreamWriter, StopBits, create_serial_connection, + get_serial_classes, ) -from serialx.common import get_serial_classes from tests.common import ( SOCAT_BINARY, + SerialPair, + async_create_bridged_socat_pair, async_create_reader_writer, async_create_reader_writer_pair, - async_create_socat_pair, ) -from tests.socket_relay import async_create_socket_pair LOGGER = logging.getLogger(__name__) -@pytest.fixture(params=["socat", "socket"]) -async def async_transport_pair( - request: pytest.FixtureRequest, -) -> AsyncIterator[tuple[str, str]]: - """Yield a connected pair of async transport paths/URLs.""" - backend = request.param - - if backend == "socat": - if not SOCAT_BINARY: - pytest.skip("socat binary is missing") - async with async_create_socat_pair() as (left, right): - yield (left, right) - elif backend == "socket": - # Introduce a small delay to simulate network latency/backpressure - async with async_create_socket_pair(relay_read_delay=0.001) as (left, right): - yield (left, right) - else: - raise ValueError(f"Unknown backend: {backend}") - - -@pytest.fixture -async def async_serial_pair( - async_transport_pair: tuple[str, str], -) -> AsyncIterator[ - tuple[ - asyncio.StreamReader, - SerialStreamWriter[BaseSerialTransport], - asyncio.StreamReader, - SerialStreamWriter[BaseSerialTransport], - ] -]: - """Yield a connected pair of reader/writers with default settings.""" - left_path, right_path = async_transport_pair - async with async_create_reader_writer_pair( - left_path, right_path, baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - yield reader_left, writer_left, reader_right, writer_right - - -async def test_async_backpressure_callbacks( - async_transport_pair: tuple[str, str], -) -> None: - """Test backpressure pause/resume callbacks through public async APIs.""" - left_path, right_path = async_transport_pair - output_pause_count = 0 - output_resume_count = 0 - - loop = asyncio.get_running_loop() - input_lost = loop.create_future() - output_lost = loop.create_future() - - class Input(asyncio.Protocol): - def data_received(self, data: bytes) -> None: - return - - def connection_lost(self, exc: Exception | None) -> None: - if not input_lost.done(): - input_lost.set_result(None) - - class Output(asyncio.Protocol): - _transport: BaseSerialTransport | None = None - - def connection_made(self, transport: asyncio.BaseTransport) -> None: - assert isinstance(transport, BaseSerialTransport) - self._transport = transport +# --- Data transmission --- - def pause_writing(self) -> None: - nonlocal output_pause_count - output_pause_count += 1 - - def resume_writing(self) -> None: - nonlocal output_resume_count - output_resume_count += 1 - - def connection_lost(self, exc: Exception | None) -> None: - if not output_lost.done(): - output_lost.set_result(None) - - in_transport, _ = await create_serial_connection( - loop, Input, left_path, baudrate=115200 - ) - out_transport, _ = await create_serial_connection( - loop, Output, right_path, baudrate=115200 - ) - await asyncio.sleep(0.1) - # Write enough data to fill the buffer and trigger pause - payload = b"X" * 65536 - for _ in range(64): - if out_transport.is_closing(): - break - out_transport.write(payload) - # Yield to allow loop to process writes and trigger pause - await asyncio.sleep(0) - - # Wait for the buffer to drain - try: - async with asyncio_timeout(10): - while out_transport.get_write_buffer_size() > 0: - await asyncio.sleep(0.05) - except asyncio.TimeoutError: - pass # Buffer didn't drain in time, but maybe enough to trigger pause - - await asyncio.sleep(0.1) - - # Check callbacks were called - assert output_pause_count > 0 - assert output_resume_count > 0 - - out_transport.close() - in_transport.close() - await asyncio.gather(input_lost, output_lost) - - -async def test_async_all_bytes(async_serial_pair) -> None: +async def test_async_all_bytes(serial_pair: SerialPair) -> None: """Test that all bytes 0-255 can be transmitted.""" - _, writer_left, reader_right, _ = async_serial_pair - data = bytes(range(256)) - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - assert result == data + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + data = bytes(range(256)) + writer_left.write(data) + result = await reader_right.readexactly(len(data)) + assert result == data -async def test_async_segmented_binary_data(async_serial_pair) -> None: +async def test_async_segmented_binary_data(serial_pair: SerialPair) -> None: """Test binary data sent in segments.""" - _, writer_left, reader_right, _ = async_serial_pair - segment_size = 16 - data = bytes(range(256)) + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + segment_size = 16 + data = bytes(range(256)) - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - writer_left.write(segment) - result = await reader_right.readexactly(len(segment)) - assert result == segment + for i in range(0, 256, segment_size): + segment = data[i : i + segment_size] + writer_left.write(segment) + result = await reader_right.readexactly(len(segment)) + assert result == segment @pytest.mark.parametrize("size", [1, 16, 64, 256, 512, 1024]) -async def test_async_binary_payload_sizes(async_serial_pair, size: int) -> None: +async def test_async_binary_payload_sizes(serial_pair: SerialPair, size: int) -> None: """Test various binary payload sizes.""" - _, writer_left, reader_right, _ = async_serial_pair - data = bytes([i % 256 for i in range(size)]) - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - assert result == data + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + data = bytes([i % 256 for i in range(size)]) + writer_left.write(data) + result = await reader_right.readexactly(len(data)) + assert result == data -async def test_async_null_bytes(async_serial_pair) -> None: +async def test_async_null_bytes(serial_pair: SerialPair) -> None: """Test that null bytes (0x00) can be transmitted.""" - _, writer_left, reader_right, _ = async_serial_pair - null_data = b"\x00" * 64 - writer_left.write(null_data) - result = await reader_right.readexactly(len(null_data)) - assert result == null_data + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + null_data = b"\x00" * 64 + writer_left.write(null_data) + result = await reader_right.readexactly(len(null_data)) + assert result == null_data -async def test_async_overlapping_read_write(async_serial_pair) -> None: +async def test_async_overlapping_read_write(serial_pair: SerialPair) -> None: """Test that read and write can overlap, data is buffered.""" - _, writer_left, reader_right, _ = async_serial_pair - data = bytes(range(256)) - read = b"" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + data = bytes(range(256)) + read = b"" - writer_left.write(data[:100]) - read += await reader_right.readexactly(10) - writer_left.write(data[100:150]) - read += await reader_right.readexactly(10) - writer_left.write(data[150:]) - read += await reader_right.readexactly(10) - read += await reader_right.readexactly(256 - 30) + writer_left.write(data[:100]) + read += await reader_right.readexactly(10) + writer_left.write(data[100:150]) + read += await reader_right.readexactly(10) + writer_left.write(data[150:]) + read += await reader_right.readexactly(10) + read += await reader_right.readexactly(256 - 30) - assert read == data + assert read == data @pytest.mark.parametrize( @@ -227,16 +119,12 @@ async def test_async_overlapping_read_write(async_serial_pair) -> None: ], ) async def test_async_random_large( - async_transport_pair: tuple[str, str], baudrate: int, chunk_size: int + serial_pair: SerialPair, baudrate: int, chunk_size: int ) -> None: """Test random read/write at various speeds.""" - left, right = async_transport_pair - async with async_create_reader_writer_pair(left, right, baudrate=baudrate) as ( - _rl, - writer_left, - reader_right, - _wr, - ): + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=baudrate + ) as (_, writer_left, reader_right, _): data = os.urandom(chunk_size) writer_left.write(data) read_data = await reader_right.readexactly(chunk_size) @@ -245,81 +133,77 @@ async def test_async_random_large( @pytest.mark.parametrize("iterations", [16, 32, 64]) async def test_async_repeated_write_read_cycles( - async_serial_pair, iterations: int + serial_pair: SerialPair, iterations: int ) -> None: """Test repeated write/read cycles.""" - _, writer_left, reader_right, _ = async_serial_pair - data = bytes(range(256)) + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + data = bytes(range(256)) - for _ in range(iterations): - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - assert result == data + for _ in range(iterations): + writer_left.write(data) + result = await reader_right.readexactly(len(data)) + assert result == data -async def test_async_buffered_writes_then_read(async_serial_pair) -> None: +async def test_async_buffered_writes_then_read(serial_pair: SerialPair) -> None: """Test multiple writes followed by a single read.""" - _, writer_left, reader_right, _ = async_serial_pair - chunk = bytes(range(256)) - iterations = 4 + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + chunk = bytes(range(256)) + iterations = 4 - for _ in range(iterations): - writer_left.write(chunk) + for _ in range(iterations): + writer_left.write(chunk) - total_size = len(chunk) * iterations - result = await reader_right.readexactly(total_size) - expected = chunk * iterations - assert result == expected + total_size = len(chunk) * iterations + result = await reader_right.readexactly(total_size) + expected = chunk * iterations + assert result == expected @pytest.mark.parametrize("payload_size", [1024, 2048]) -async def test_async_large_payload( - async_transport_pair: tuple[str, str], payload_size: int -) -> None: +async def test_async_large_payload(serial_pair: SerialPair, payload_size: int) -> None: """Test large payload transmission.""" - left, right = async_transport_pair - async with async_create_reader_writer_pair(left, right, baudrate=921600) as ( - _rl, - writer_left, - reader_right, - _wr, - ): + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=921600 + ) as (_, writer_left, reader_right, _): data = bytes([i % 256 for i in range(payload_size)]) writer_left.write(data) result = await reader_right.readexactly(len(data)) assert result == data -async def test_async_rapid_small_writes(async_serial_pair) -> None: +async def test_async_rapid_small_writes(serial_pair: SerialPair) -> None: """Test rapid succession of small writes.""" - _, writer_left, reader_right, _ = async_serial_pair - iterations = 256 - received = bytearray() + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + iterations = 256 + received = bytearray() - for i in range(iterations): - data = bytes([i % 256]) - writer_left.write(data) - result = await reader_right.readexactly(1) - received.extend(result) + for i in range(iterations): + data = bytes([i % 256]) + writer_left.write(data) + result = await reader_right.readexactly(1) + received.extend(result) - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected + expected = bytes([i % 256 for i in range(iterations)]) + assert bytes(received) == expected @pytest.mark.parametrize( "baudrate,iterations", [(9600, 8), (115200, 64), (921600, 512)] ) async def test_async_sustained_throughput( - async_transport_pair: tuple[str, str], baudrate: int, iterations: int + serial_pair: SerialPair, baudrate: int, iterations: int ) -> None: """Test sustained data throughput at various baudrates.""" - left, right = async_transport_pair - async with async_create_reader_writer_pair(left, right, baudrate=baudrate) as ( - _rl, - writer_left, - reader_right, - _wr, - ): + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=baudrate + ) as (_, writer_left, reader_right, _): chunk = os.urandom(1024) for _ in range(iterations): writer_left.write(chunk) @@ -327,15 +211,18 @@ async def test_async_sustained_throughput( assert result == chunk +# --- Configuration --- + + @pytest.mark.parametrize( "baudrate", [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] ) -async def test_async_valid_baudrates( - async_transport_pair: tuple[str, str], baudrate: int -) -> None: +async def test_async_valid_baudrates(serial_pair: SerialPair, baudrate: int) -> None: """Test that valid baudrates are accepted.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=baudrate) as (_, writer): + async with async_create_reader_writer(serial_pair.left, baudrate=baudrate) as ( + _, + writer, + ): assert writer.transport.baudrate == baudrate writer.write(b"test") @@ -343,15 +230,11 @@ async def test_async_valid_baudrates( @pytest.mark.parametrize( "parity", [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE] ) -async def test_async_valid_parity( - async_transport_pair: tuple[str, str], parity: Parity -) -> None: +async def test_async_valid_parity(serial_pair: SerialPair, parity: Parity) -> None: """Test that valid parity settings are accepted.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200, parity=parity) as ( - _, - writer, - ): + async with async_create_reader_writer( + serial_pair.left, baudrate=115200, parity=parity + ) as (_, writer): assert writer.transport.parity == parity writer.write(b"test") @@ -368,95 +251,316 @@ async def test_async_valid_parity( ], ) async def test_async_valid_stopbits( - async_transport_pair: tuple[str, str], + serial_pair: SerialPair, stopbits: StopBits | int | float, expected: StopBits, ) -> None: """Test that valid stopbits settings are accepted.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200, stopbits=stopbits) as ( - _, - writer, - ): + async with async_create_reader_writer( + serial_pair.left, baudrate=115200, stopbits=stopbits + ) as (_, writer): assert writer.transport.stopbits == expected writer.write(b"test") @pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -async def test_async_valid_byte_size( - async_transport_pair: tuple[str, str], byte_size: int -) -> None: +async def test_async_valid_byte_size(serial_pair: SerialPair, byte_size: int) -> None: """Test that valid byte sizes are accepted.""" - left, _ = async_transport_pair async with async_create_reader_writer( - left, baudrate=115200, byte_size=byte_size + serial_pair.left, baudrate=115200, byte_size=byte_size ) as (_, writer): writer.write(b"test") @pytest.mark.parametrize("xonxoff", [True, False]) -async def test_async_xonxoff_setting( - async_transport_pair: tuple[str, str], xonxoff: bool -) -> None: +async def test_async_xonxoff_setting(serial_pair: SerialPair, xonxoff: bool) -> None: """Test that xonxoff setting is accepted.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200, xonxoff=xonxoff) as ( - _, - writer, - ): + async with async_create_reader_writer( + serial_pair.left, baudrate=115200, xonxoff=xonxoff + ) as (_, writer): writer.write(b"test") @pytest.mark.parametrize("rtscts", [True, False]) -async def test_async_rtscts_setting( - async_transport_pair: tuple[str, str], rtscts: bool -) -> None: +async def test_async_rtscts_setting(serial_pair: SerialPair, rtscts: bool) -> None: """Test that rtscts setting is accepted.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200, rtscts=rtscts) as ( + async with async_create_reader_writer(serial_pair.right, baudrate=115200): + async with async_create_reader_writer( + serial_pair.left, baudrate=115200, rtscts=rtscts + ) as (_, writer): + writer.write(b"test") + + +# --- Lifecycle --- + + +async def test_async_concurrent_writes(serial_pair: SerialPair) -> None: + """Test concurrent writes from multiple tasks.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + + async def write_data(data: bytes) -> None: + writer_left.write(data) + + data1 = b"A" * 100 + data2 = b"B" * 100 + data3 = b"C" * 100 + + await asyncio.gather( + write_data(data1), + write_data(data2), + write_data(data3), + ) + + total_data = await reader_right.readexactly(300) + assert total_data == b"A" * 100 + b"B" * 100 + b"C" * 100 + + +async def test_async_read_with_timeout(serial_pair: SerialPair) -> None: + """Test reading with timeout.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + writer_left.write(b"test") + + result = await asyncio.wait_for(reader_right.readexactly(4), timeout=1.0) + assert result == b"test" + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(reader_right.readexactly(1), timeout=0.1) + + +async def test_async_close_is_idempotent(serial_pair: SerialPair) -> None: + """Test closing writer multiple times is safe and drains buffer state.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, _, _): + writer_left.close() + await writer_left.wait_closed() + assert writer_left.transport.get_write_buffer_size() == 0 + + # Second close should be no-op + writer_left.close() + await writer_left.wait_closed() + + +async def test_async_pause_resume(serial_pair: SerialPair) -> None: + """Test transport pause and resume.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (reader_left, writer_left, _, writer_right): + writer_left.transport.pause_reading() + + writer_right.write(b"A long message") + await writer_right.drain() + + # Nothing can be read + with pytest.raises(asyncio.TimeoutError): + async with asyncio_timeout(1): + await reader_left.read(1) + + writer_left.transport.resume_reading() + assert (await reader_left.read(14)) == b"A long message" + + +async def test_async_abort(serial_pair: SerialPair) -> None: + """Test aborting a transport discards buffered data and closes.""" + connection_lost_event = asyncio.Event() + + class AbortProtocol(asyncio.Protocol): + def connection_lost(self, exc: Exception | None) -> None: + connection_lost_event.set() + + transport, _ = await create_serial_connection( + asyncio.get_running_loop(), + AbortProtocol, + serial_pair.left, + baudrate=115200, + ) + + transport.write(b"data that will be discarded on abort") + transport.abort() + + await asyncio.wait_for(connection_lost_event.wait(), timeout=2.0) + await asyncio.wait_for(transport.wait_closed(), timeout=2.0) + assert transport.is_closing() + + +async def test_async_close_before_connect(serial_pair: SerialPair) -> None: + """Test close during connect does not crash and stays idempotent.""" + + class ProbeProtocol(asyncio.Protocol): + def __init__(self) -> None: + self.connection_made_calls = 0 + self.connection_lost_calls = 0 + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.connection_made_calls += 1 + + def connection_lost(self, exc: Exception | None) -> None: + self.connection_lost_calls += 1 + + loop = asyncio.get_running_loop() + _serial_cls, transport_cls = get_serial_classes(serial_pair.left) + protocol = ProbeProtocol() + transport = transport_cls(loop=loop, protocol=protocol) + + connect_task = asyncio.create_task( + transport.connect(path=serial_pair.left, baudrate=115200) + ) + await asyncio.sleep(0) + transport.close() + transport.close() + + with contextlib.suppress(Exception): + await asyncio.wait_for(connect_task, timeout=2.0) + + await asyncio.wait_for(transport.wait_closed(), timeout=2.0) + + assert transport.is_closing() + assert protocol.connection_made_calls <= 1 + assert protocol.connection_lost_calls <= 1 + + +async def test_async_write_bytearray(serial_pair: SerialPair) -> None: + """Test writing bytearray data.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + data = bytearray(b"hello bytearray") + writer_left.write(data) + result = await reader_right.readexactly(len(data)) + assert result == b"hello bytearray" + + +async def test_async_write_empty(serial_pair: SerialPair) -> None: + """Test writing empty data is a no-op.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + writer_left.write(b"") + writer_left.write(b"after_empty") + result = await reader_right.readexactly(len(b"after_empty")) + assert result == b"after_empty" + + +async def test_async_transport_api(serial_pair: SerialPair) -> None: + """Test transport public API methods.""" + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( _, writer, ): - writer.write(b"test") + transport = writer.transport + # get/set protocol + protocol = transport.get_protocol() + assert protocol is not None + transport.set_protocol(protocol) + assert transport.get_protocol() is protocol -async def test_async_concurrent_writes(async_serial_pair) -> None: - """Test concurrent writes from multiple tasks.""" - _, writer_left, reader_right, _ = async_serial_pair + # write buffer size starts at 0 + assert transport.get_write_buffer_size() == 0 - async def write_data(data: bytes) -> None: - writer_left.write(data) - data1 = b"A" * 100 - data2 = b"B" * 100 - data3 = b"C" * 100 +@pytest.mark.skipif( + sys.platform == "win32", + reason="Only DescriptorTransport implements write buffer limits", +) +@pytest.mark.skip_backends("socket") +async def test_async_transport_write_buffer_limits(serial_pair: SerialPair) -> None: + """Test get/set write buffer limits and can_write_eof.""" - await asyncio.gather( - write_data(data1), - write_data(data2), - write_data(data3), - ) + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + _, + writer, + ): + transport = writer.transport - total_data = await reader_right.readexactly(300) - assert total_data == b"A" * 100 + b"B" * 100 + b"C" * 100 + low, high = transport.get_write_buffer_limits() + assert low >= 0 + assert high >= low + transport.set_write_buffer_limits(high=128 * 1024, low=32 * 1024) + assert transport.get_write_buffer_limits() == (32 * 1024, 128 * 1024) + + assert transport.can_write_eof() is True -async def test_async_read_with_timeout(async_serial_pair) -> None: - """Test reading with timeout.""" - _, writer_left, reader_right, _ = async_serial_pair - writer_left.write(b"test") - result = await asyncio.wait_for(reader_right.readexactly(4), timeout=1.0) - assert result == b"test" +async def test_async_flush(serial_pair: SerialPair) -> None: + """Test flushing async transport write buffers.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, reader_right, _): + writer_left.write(b"flush test data") + await writer_left.transport.flush() + + result = await reader_right.readexactly(len(b"flush test data")) + assert result == b"flush test data" + + +async def test_async_resume_reading_when_not_paused(serial_pair: SerialPair) -> None: + """Test that resume_reading when not paused is a no-op.""" + async with async_create_reader_writer_pair( + serial_pair.left, serial_pair.right, baudrate=115200 + ) as (_, writer_left, _, _): + # resume without prior pause should be a no-op + writer_left.transport.resume_reading() + + +@pytest.mark.skipif(not SOCAT_BINARY, reason="socat binary is missing") +async def test_async_peer_close_triggers_connection_lost() -> None: + """Test that killing one socat process triggers connection_lost on the other.""" + async with async_create_bridged_socat_pair() as pair: + connection_lost_event = asyncio.Event() + + class Receiver(asyncio.Protocol): + def connection_lost(self, exc: Exception | None) -> None: + connection_lost_event.set() + + loop = asyncio.get_running_loop() + + recv_transport, _ = await create_serial_connection( + loop, Receiver, pair.left, baudrate=115200 + ) + send_transport, _ = await create_serial_connection( + loop, asyncio.Protocol, pair.right, baudrate=115200 + ) - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(reader_right.readexactly(1), timeout=0.1) + send_transport.write(b"goodbye") + # Kill the right-side socat process; this tears down the bridge + # and causes EOF on the left side + pair.right_process.terminate() + await pair.right_process.wait() -async def test_async_get_modem_pins(async_transport_pair: tuple[str, str]) -> None: + await asyncio.wait_for(connection_lost_event.wait(), timeout=5.0) + + send_transport.close() + + if not recv_transport.is_closing(): + recv_transport.close() + + +async def test_async_invalid_uri() -> None: + """Test invalid URIs are rejected by public async API.""" + loop = asyncio.get_running_loop() + + with pytest.raises(ValueError, match="expected both host and port"): + await create_serial_connection( + loop, + asyncio.Protocol, + "socket://127.0.0.1", + baudrate=115200, + ) + + +async def test_async_get_modem_pins(serial_pair: SerialPair) -> None: """Test reading modem control bits.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200) as (_, writer): + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + _, + writer, + ): modem_pins = await writer.transport.get_modem_pins() assert isinstance(modem_pins, ModemPins) for field in ["le", "dtr", "rts", "st", "sr", "cts", "car", "rng", "dsr"]: @@ -464,41 +568,144 @@ async def test_async_get_modem_pins(async_transport_pair: tuple[str, str]) -> No assert value in (PinState.HIGH, PinState.LOW, PinState.UNDEFINED) -async def test_async_set_modem_pins(async_transport_pair: tuple[str, str]) -> None: - """Test setting modem control bits.""" - left, _ = async_transport_pair - async with async_create_reader_writer(left, baudrate=115200) as (_, writer): +async def test_async_set_modem_pins_api(serial_pair: SerialPair) -> None: + """Test modem pin writes are accepted on all backends.""" + + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + _, + writer, + ): await writer.transport.set_modem_pins(dtr=True, rts=True) - assert isinstance(await writer.transport.get_modem_pins(), ModemPins) + pins_high = await writer.transport.get_modem_pins() + + await writer.transport.set_modem_pins(dtr=False, rts=False) + pins_low = await writer.transport.get_modem_pins() + + for pins in (pins_high, pins_low): + assert isinstance(pins, ModemPins) + for field in ["le", "dtr", "rts", "st", "sr", "cts", "car", "rng", "dsr"]: + value = getattr(pins, field) + assert value in (PinState.HIGH, PinState.LOW, PinState.UNDEFINED) + + if ( + pins_high.dtr is not PinState.UNDEFINED + and pins_low.dtr is not PinState.UNDEFINED + ): + assert pins_high.dtr is PinState.HIGH + assert pins_low.dtr is PinState.LOW + + if ( + pins_high.rts is not PinState.UNDEFINED + and pins_low.rts is not PinState.UNDEFINED + ): + assert pins_high.rts is PinState.HIGH + assert pins_low.rts is PinState.LOW + + +@pytest.mark.skipif( + sys.platform == "win32", reason="GetCommModemStatus cannot read back DTR/RTS" +) +@pytest.mark.skip_backends("socket", "socat") +async def test_async_set_modem_pins(serial_pair: SerialPair) -> None: + """Test setting modem control bits and verifying readback.""" + + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + _, + writer, + ): + await writer.transport.set_modem_pins(dtr=True, rts=True) + modem_pins = await writer.transport.get_modem_pins() + assert modem_pins.dtr is PinState.HIGH + assert modem_pins.rts is PinState.HIGH await writer.transport.set_modem_pins(dtr=False) - assert isinstance(await writer.transport.get_modem_pins(), ModemPins) + modem_pins = await writer.transport.get_modem_pins() + assert modem_pins.dtr is PinState.LOW + assert modem_pins.rts is PinState.HIGH await writer.transport.set_modem_pins(dtr=False, rts=False) - assert isinstance(await writer.transport.get_modem_pins(), ModemPins) + modem_pins = await writer.transport.get_modem_pins() + assert modem_pins.dtr is PinState.LOW + assert modem_pins.rts is PinState.LOW -async def test_async_pause_resume(async_serial_pair) -> None: - """Test transport pause and resume.""" - reader_left, writer_left, _, writer_right = async_serial_pair +# --- Backpressure --- +# These tests use a dedicated async socket relay with read delay to reliably +# trigger backpressure conditions. + - writer_left.transport.pause_reading() +@pytest.mark.skip_backends("socket", "com0com", "socat") +async def test_async_backpressure_callbacks(serial_pair: SerialPair) -> None: + """Test backpressure pause/resume callbacks through public async APIs.""" - writer_right.write(b"A long message") - await writer_right.drain() + output_pause_count = 0 + output_resume_count = 0 - # Nothing can be read - with pytest.raises(asyncio.TimeoutError): - async with asyncio_timeout(1): - await reader_left.read(1) + loop = asyncio.get_running_loop() + input_lost = loop.create_future() + output_lost = loop.create_future() + + class Input(asyncio.Protocol): + def data_received(self, data: bytes) -> None: + return - writer_left.transport.resume_reading() - assert (await reader_left.read(14)) == b"A long message" + def connection_lost(self, exc: Exception | None) -> None: + if not input_lost.done(): + input_lost.set_result(None) + class Output(asyncio.Protocol): + _transport: BaseSerialTransport | None = None -async def test_async_backpressure_writer_removal( - async_transport_pair: tuple[str, str], -) -> None: + def connection_made(self, transport: asyncio.BaseTransport) -> None: + assert isinstance(transport, BaseSerialTransport) + self._transport = transport + + def pause_writing(self) -> None: + nonlocal output_pause_count + output_pause_count += 1 + + def resume_writing(self) -> None: + nonlocal output_resume_count + output_resume_count += 1 + + def connection_lost(self, exc: Exception | None) -> None: + if not output_lost.done(): + output_lost.set_result(None) + + in_transport, _ = await create_serial_connection( + loop, Input, serial_pair.left, baudrate=115200 + ) + out_transport, _ = await create_serial_connection( + loop, Output, serial_pair.right, baudrate=115200 + ) + await asyncio.sleep(0.1) + + out_transport.set_write_buffer_limits(high=1024, low=256) + + # Write enough to overflow the kernel buffer (~4KB for most serial drivers) + # so that the userspace buffer exceeds `high` and triggers pause_writing. + payload = b"X" * 8192 + for _ in range(4): + if out_transport.is_closing(): + break + out_transport.write(payload) + await asyncio.sleep(0) + + assert output_pause_count > 0 + + assert out_transport.get_write_buffer_size() > 0 + await out_transport.flush() + assert out_transport.get_write_buffer_size() == 0 + + assert output_resume_count > 0 + + out_transport.close() + in_transport.close() + await asyncio.gather(input_lost, output_lost) + + +@pytest.mark.skip_backends("socket", "com0com") +async def test_async_backpressure_writer_removal(serial_pair: SerialPair) -> None: """Test that large writes with backpressure are handled correctly. This test catches three issue categories: @@ -507,7 +714,7 @@ async def test_async_backpressure_writer_removal( 3. Timing failures from writer not being added when buffering data Source: https://github.com/home-assistant-libs/pyserial-asyncio-fast/pull/36 """ - left_path, right_path = async_transport_pair + TEXT = b"Hello, World!" COUNT = 8 * 1024 output_resume_event = asyncio.Event() @@ -526,8 +733,6 @@ def data_received(self, data: bytes) -> None: self._transport.write(data) class Output(asyncio.Protocol): - """Provides backpressure to writer via output_resume_event.""" - _transport: BaseSerialTransport def connection_made(self, transport: asyncio.BaseTransport) -> None: @@ -544,128 +749,194 @@ def resume_writing(self) -> None: loop = asyncio.get_running_loop() in_transport, _ = await create_serial_connection( - loop, Input, left_path, baudrate=115200 + loop, Input, serial_pair.left, baudrate=115200 ) out_transport, _ = await create_serial_connection( - loop, Output, right_path, baudrate=115200 + loop, Output, serial_pair.right, baudrate=115200 ) - # Wait for connections to be established await asyncio.sleep(0.1) try: - # Write a bunch of data so that we create a buffer and trigger backpressure for _ in range(COUNT): - # Increase timeout to avoid flakiness on slow CI - # On some systems, the buffer drain might be slower than expected try: async with asyncio_timeout(10): await output_resume_event.wait() except asyncio.TimeoutError: - # If we timeout, check if the transport is actually closed or if we are just stuck if out_transport.is_closing(): break raise out_transport.write(TEXT) - # Ensure that the write buffer eventually drains completely async with asyncio_timeout(10): while out_transport.get_write_buffer_size() > 0: await asyncio.sleep(0.1) - # Wait a bit more for the data to propagate - # The relay task needs time to move data from queue to socket for _ in range(50): if data_received_count > 0: break await asyncio.sleep(0.1) - # Verify we received some data on the input side assert data_received_count > 0 finally: out_transport.close() in_transport.close() -async def test_async_close_is_idempotent(async_serial_pair) -> None: - """Test closing writer multiple times is safe and drains buffer state.""" - _, writer_left, _, _ = async_serial_pair - - writer_left.close() - await writer_left.wait_closed() - assert writer_left.transport.get_write_buffer_size() == 0 - - # Second close should be no-op - writer_left.close() - await writer_left.wait_closed() +# --- Adapter-specific tests --- +# These tests require physical adapter pairs (com0com, real hardware) +# and verify cross-port behavior that virtual backends can't emulate. -async def test_async_transport_close_before_connect_completes( - async_transport_pair: tuple[str, str], -) -> None: - """Test close-before-connect race is handled without duplicate callbacks.""" - left_path, _ = async_transport_pair - - _, transport_cls = get_serial_classes(left_path) - - class ProbeProtocol(asyncio.Protocol): - def __init__(self) -> None: - self.connection_made_calls = 0 - self.connection_lost_calls = 0 - self.connection_lost_future = asyncio.get_running_loop().create_future() +async def test_async_fast_open_close(serial_pair: SerialPair) -> None: + """Test quickly opening and closing a port doesn't crash.""" + connection_lost_event = asyncio.Event() + class FastCloseProtocol(asyncio.Protocol): def connection_made(self, transport: asyncio.BaseTransport) -> None: assert isinstance(transport, BaseSerialTransport) - self.connection_made_calls += 1 + transport.write(b"data that will be discarded on abort") + transport.abort() def connection_lost(self, exc: Exception | None) -> None: - self.connection_lost_calls += 1 - if not self.connection_lost_future.done(): - self.connection_lost_future.set_result(None) - - loop = asyncio.get_running_loop() - protocol = ProbeProtocol() - transport = transport_cls(loop, protocol) + connection_lost_event.set() - connect_task = asyncio.create_task( - transport.connect(path=left_path, baudrate=115200) + transport, _ = await create_serial_connection( + asyncio.get_running_loop(), + FastCloseProtocol, + serial_pair.left, + baudrate=115200, ) - transport.close() - # We await the connect task. It should complete (possibly with exception or just return) - with contextlib.suppress(Exception): - await connect_task + await connection_lost_event.wait() - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(protocol.connection_lost_future, timeout=5) - await asyncio.sleep(0) +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +async def test_async_deassert_on_open(serial_pair: SerialPair) -> None: + """Test DTR/CTS deassertion on open.""" - # connection_made should never fire in this race path. - assert protocol.connection_made_calls == 0 + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + reader_left, + writer_left, + ): + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtsdtr_on_open=PinState.HIGH, + rtsdtr_on_close=PinState.HIGH, + ) as (reader_right, writer_right): + await writer_right.transport.set_modem_pins(dtr=True) + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtsdtr_on_open=PinState.LOW, + rtsdtr_on_close=PinState.HIGH, + ) as (reader_right, writer_right): + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.LOW + await writer_right.transport.set_modem_pins(dtr=True) - # If we successfully started 'connect', we expect one connection_lost? - # SocketSerialTransport guarantees it. PosixSerialTransport might not if closed before open finishes? - # But let's assert it for now to see if it passes for both. + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - # assert protocol.connection_lost_calls == 1 <-- this might be strict for Posix if closed very early - assert transport.is_closing() is True +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +async def test_async_hang_up_on_close(serial_pair: SerialPair) -> None: + """Test DTR/CTS hang up on close.""" - # Additional closes are idempotent - transport.close() - await asyncio.sleep(0) + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + reader_left, + writer_left, + ): + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.HIGH, + rtsdtr_on_open=PinState.HIGH, + ) as (reader_right, writer_right): + await writer_right.transport.set_modem_pins(dtr=True) + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.HIGH, + rtsdtr_on_open=PinState.HIGH, + ) as (reader_right, writer_right): + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH -async def test_async_invalid_uri() -> None: - """Test invalid URIs are rejected by public async API.""" - loop = asyncio.get_running_loop() + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.LOW, + rtsdtr_on_open=PinState.HIGH, + ) as (reader_right, writer_right): + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - with pytest.raises(ValueError, match="expected both host and port"): - await create_serial_connection( - loop, - asyncio.Protocol, - "socket://127.0.0.1", + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.LOW + + +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +@pytest.mark.parametrize( + ("rtscts", "rtsdtr_on_open", "expected_state"), + [ + (False, PinState.HIGH, PinState.HIGH), + (False, PinState.LOW, PinState.LOW), + (True, PinState.HIGH, PinState.HIGH), + (True, PinState.LOW, PinState.LOW), + ], +) +async def test_async_deassert_on_open_with_rtscts( + serial_pair: SerialPair, + rtscts: bool, + rtsdtr_on_open: PinState, + expected_state: PinState, +) -> None: + """Test interaction of rtsdtr_on_open with rtscts.""" + + async with async_create_reader_writer(serial_pair.left, baudrate=115200) as ( + reader_left, + writer_left, + ): + async with async_create_reader_writer( + serial_pair.right, baudrate=115200, - ) + rtscts=False, + rtsdtr_on_open=PinState.HIGH, + rtsdtr_on_close=PinState.HIGH, + ) as (reader_right, writer_right): + await writer_right.transport.set_modem_pins(dtr=True) + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH + + async with async_create_reader_writer( + serial_pair.right, + baudrate=115200, + rtscts=rtscts, + rtsdtr_on_open=rtsdtr_on_open, + ) as (reader_right, writer_right): + await asyncio.sleep(0.05) + assert (await writer_left.transport.get_modem_pins()).cts is expected_state diff --git a/tests/test_loopback_async.py b/tests/test_loopback_async.py deleted file mode 100644 index c3b8b88..0000000 --- a/tests/test_loopback_async.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Test async APIs with real loopback adapter.""" - -import asyncio -import os -from typing import cast - -import pytest - -from serialx import Parity, PinState, SerialTransport, StopBits -from tests.common import async_create_reader_writer - - -async def test_all_bytes_loopback_async(loopback_adapter: str) -> None: - """Test that all bytes 0-255 can be transmitted.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Create a byte array with all possible byte values - data = bytes(range(256)) - - writer.write(data) - result = await reader.readexactly(len(data)) - - assert result == data - - -async def test_segmented_binary_data_loopback_async(loopback_adapter: str) -> None: - """Test binary data sent in segments.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Send all bytes in smaller segments - segment_size = 16 - data = bytes(range(256)) - - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - writer.write(segment) - result = await reader.readexactly(len(segment)) - assert result == segment - - -@pytest.mark.parametrize( - "size", - [1, 16, 64, 256, 512, 1024], -) -async def test_binary_payload_sizes_loopback_async( - loopback_adapter: str, size: int -) -> None: - """Test various binary payload sizes.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Create binary data with repeating pattern - data = bytes([i % 256 for i in range(size)]) - - writer.write(data) - result = await reader.readexactly(len(data)) - - assert result == data - - -async def test_null_bytes_loopback_async(loopback_adapter: str) -> None: - """Test that null bytes (0x00) can be transmitted.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Send a bunch of null bytes - null_data = b"\x00" * 64 - - writer.write(null_data) - result = await reader.readexactly(len(null_data)) - - assert result == null_data - - -async def test_overlapping_read_write_loopback_async(loopback_adapter: str) -> None: - """Test that read and write can overlap, data is buffered.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - data = bytes(range(256)) - read = b"" - - writer.write(data[:100]) - read += await reader.readexactly(10) - writer.write(data[100:150]) - read += await reader.readexactly(10) - writer.write(data[150:]) - read += await reader.readexactly(10) - read += await reader.readexactly(256 - 30) - - assert read == data - - -@pytest.mark.parametrize( - ("baudrate", "chunk_size"), - [ - (9600, 1), - (9600, 16), - (115200, 1), - (115200, 16), - (115200, 64), - (921600, 1), - (921600, 16), - (921600, 256), - (921600, 1024), - ], -) -async def test_random_large_loopback_async( - loopback_adapter: str, baudrate: int, chunk_size: int -) -> None: - """Test random read/write at various speeds.""" - async with async_create_reader_writer(loopback_adapter, baudrate=baudrate) as ( - reader, - writer, - ): - data = os.urandom(chunk_size) - writer.write(data) - - read_data = await reader.readexactly(chunk_size) - assert read_data == data - - -@pytest.mark.parametrize( - "iterations", - [16, 32, 64], -) -async def test_repeated_write_read_cycles_loopback_async( - loopback_adapter: str, iterations: int -) -> None: - """Test repeated write/read cycles.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - data = bytes(range(256)) - - for _i in range(iterations): - writer.write(data) - result = await reader.readexactly(len(data)) - assert result == data - - -async def test_buffered_writes_then_read_loopback_async(loopback_adapter: str) -> None: - """Test multiple writes followed by a single read.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - chunk = bytes(range(256)) - iterations = 4 - - # Write multiple chunks - for _ in range(iterations): - writer.write(chunk) - - # Read all data back - total_size = len(chunk) * iterations - result = await reader.readexactly(total_size) - - # Verify all data was received correctly - expected = chunk * iterations - assert result == expected - - -@pytest.mark.parametrize( - "payload_size", - [1024, 2048], # Kernel buffers are typically ~4KB, stay well below that -) -async def test_large_payload_loopback_async( - loopback_adapter: str, payload_size: int -) -> None: - """Test large payload transmission.""" - async with async_create_reader_writer(loopback_adapter, baudrate=921600) as ( - reader, - writer, - ): - data = bytes([i % 256 for i in range(payload_size)]) - - writer.write(data) - result = await reader.readexactly(len(data)) - - assert result == data - - -async def test_rapid_small_writes_loopback_async(loopback_adapter: str) -> None: - """Test rapid succession of small writes.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Send many small writes - iterations = 256 - received = bytearray() - - for i in range(iterations): - data = bytes([i % 256]) - writer.write(data) - result = await reader.readexactly(1) - received.extend(result) - - # Verify all bytes were received in order - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected - - -@pytest.mark.parametrize( - ("baudrate", "iterations"), - [ - (9600, 8), - (115200, 64), - (921600, 128), # Reduced from 512 for hardware loopback adapters - ], -) -async def test_sustained_throughput_loopback_async( - loopback_adapter: str, baudrate: int, iterations: int -) -> None: - """Test sustained data throughput at various baudrates.""" - async with async_create_reader_writer(loopback_adapter, baudrate=baudrate) as ( - reader, - writer, - ): - chunk = os.urandom(1024) - - for _ in range(iterations): - writer.write(chunk) - result = await reader.readexactly(len(chunk)) - assert result == chunk - - -@pytest.mark.parametrize( - "baudrate", - [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600], -) -async def test_valid_baudrates_loopback_async( - loopback_adapter: str, baudrate: int -) -> None: - """Test that valid baudrates are accepted.""" - async with async_create_reader_writer(loopback_adapter, baudrate=baudrate) as ( - reader, - writer, - ): - # Verify baudrate was set (check transport) - transport = cast(SerialTransport, writer.transport) - assert transport.baudrate == baudrate - writer.write(b"test") - - -@pytest.mark.parametrize( - "parity", - [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE], -) -async def test_valid_parity_loopback_async( - loopback_adapter: str, parity: Parity -) -> None: - """Test that valid parity settings are accepted.""" - async with async_create_reader_writer( - loopback_adapter, baudrate=115200, parity=parity - ) as ( - reader, - writer, - ): - transport = cast(SerialTransport, writer.transport) - assert transport.parity == parity - writer.write(b"test") - - -@pytest.mark.parametrize( - ("stopbits", "expected"), - [ - (StopBits.ONE, StopBits.ONE), - pytest.param( - StopBits.ONE_POINT_FIVE, - StopBits.ONE_POINT_FIVE, - marks=pytest.mark.xfail(reason="Not all drivers support 1.5 stop bits"), - ), - (StopBits.TWO, StopBits.TWO), - (1, StopBits.ONE), - pytest.param( - 1.5, - StopBits.ONE_POINT_FIVE, - marks=pytest.mark.xfail(reason="Not all drivers support 1.5 stop bits"), - ), - (2, StopBits.TWO), - ], -) -async def test_valid_stopbits_loopback_async( - loopback_adapter: str, - stopbits: StopBits | int | float, - expected: StopBits, -) -> None: - """Test that valid stopbits settings are accepted.""" - async with async_create_reader_writer( - loopback_adapter, baudrate=115200, stopbits=stopbits - ) as ( - reader, - writer, - ): - transport = cast(SerialTransport, writer.transport) - assert transport.stopbits == expected - writer.write(b"test") - - -@pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -async def test_valid_byte_size_loopback_async( - loopback_adapter: str, byte_size: int -) -> None: - """Test that valid byte sizes are accepted.""" - async with async_create_reader_writer( - loopback_adapter, baudrate=115200, byte_size=byte_size - ) as ( - reader, - writer, - ): - writer.write(b"test") - - -@pytest.mark.parametrize("xonxoff", [True, False]) -async def test_xonxoff_setting_loopback_async( - loopback_adapter: str, xonxoff: bool -) -> None: - """Test that xonxoff setting is accepted.""" - async with async_create_reader_writer( - loopback_adapter, baudrate=115200, xonxoff=xonxoff - ) as ( - reader, - writer, - ): - writer.write(b"test") - - -@pytest.mark.parametrize( - "rtscts", - [True, False], -) -async def test_rtscts_setting_loopback_async( - loopback_adapter: str, rtscts: bool -) -> None: - """Test that rtscts setting is accepted.""" - async with async_create_reader_writer( - loopback_adapter, baudrate=115200, rtscts=rtscts - ) as ( - reader, - writer, - ): - writer.write(b"test") - - -async def test_read_with_timeout_loopback_async(loopback_adapter: str) -> None: - """Test reading with timeout.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - # Write some data - writer.write(b"test") - - # Read with timeout should succeed - result = await asyncio.wait_for(reader.readexactly(4), timeout=1.0) - assert result == b"test" - - # Reading without data should timeout - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(reader.readexactly(1), timeout=0.1) - - -async def test_set_modem_pins_loopback_async(loopback_adapter: str) -> None: - """Test setting modem control bits with loopback adapter.""" - async with async_create_reader_writer(loopback_adapter, baudrate=115200) as ( - reader, - writer, - ): - transport = cast(SerialTransport, writer.transport) - # With a real loopback adapter, modem control signals should work - await transport.set_modem_pins(dtr=True, rts=True) - modem_pins = await transport.get_modem_pins() - assert modem_pins.dtr is PinState.HIGH - assert modem_pins.rts is PinState.HIGH - - # Set DTR low, leave RTS unchanged - await transport.set_modem_pins(dtr=False) - modem_pins = await transport.get_modem_pins() - assert modem_pins.dtr is PinState.LOW - assert modem_pins.rts is PinState.HIGH - - # Set both low - await transport.set_modem_pins(dtr=False, rts=False) - modem_pins = await transport.get_modem_pins() - assert modem_pins.dtr is PinState.LOW - assert modem_pins.rts is PinState.LOW diff --git a/tests/test_loopback_dual_async.py b/tests/test_loopback_dual_async.py deleted file mode 100644 index 04af3cb..0000000 --- a/tests/test_loopback_dual_async.py +++ /dev/null @@ -1,631 +0,0 @@ -"""Test async APIs with dual loopback hardware.""" - -import asyncio -import os -import sys - -import pytest - -from serialx import ( - BaseSerialTransport, - Parity, - PinState, - StopBits, - create_serial_connection, -) -from tests.common import async_create_dual_loopback, async_create_reader_writer - - -async def test_all_bytes_async(adapter_pair: tuple[str, str]) -> None: - """Test that all bytes 0-255 can be transmitted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Create a byte array with all possible byte values - data = bytes(range(256)) - - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - - assert result == data - - -async def test_segmented_binary_data_async(adapter_pair: tuple[str, str]) -> None: - """Test binary data sent in segments.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Send all bytes in smaller segments - segment_size = 16 - data = bytes(range(256)) - - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - writer_left.write(segment) - result = await reader_right.readexactly(len(segment)) - assert result == segment - - -@pytest.mark.parametrize( - "size", - [1, 16, 64, 256, 512, 1024], -) -async def test_binary_payload_sizes_async( - adapter_pair: tuple[str, str], size: int -) -> None: - """Test various binary payload sizes.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Create binary data with repeating pattern - data = bytes([i % 256 for i in range(size)]) - - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - - assert result == data - - -async def test_null_bytes_async(adapter_pair: tuple[str, str]) -> None: - """Test that null bytes (0x00) can be transmitted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Send a bunch of null bytes - null_data = b"\x00" * 64 - - writer_left.write(null_data) - result = await reader_right.readexactly(len(null_data)) - - assert result == null_data - - -async def test_overlapping_read_write_async(adapter_pair: tuple[str, str]) -> None: - """Test that read and write can overlap, data is buffered.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - data = bytes(range(256)) - read = b"" - - writer_left.write(data[:100]) - read += await reader_right.readexactly(10) - writer_left.write(data[100:150]) - read += await reader_right.readexactly(10) - writer_left.write(data[150:]) - read += await reader_right.readexactly(10) - read += await reader_right.readexactly(256 - 30) - - assert read == data - - -@pytest.mark.parametrize( - ("baudrate", "chunk_size"), - [ - (9600, 1), - (9600, 16), - (115200, 1), - (115200, 16), - (115200, 64), - # (921600, 1), # Hardware can't reliably handle 921600 with dual loopback - # (921600, 16), - # (921600, 256), - # (921600, 1024), - ], -) -async def test_random_large_async( - adapter_pair: tuple[str, str], baudrate: int, chunk_size: int -) -> None: - """Test random read/write at various speeds.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=baudrate - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - data = os.urandom(chunk_size) - writer_left.write(data) - - read_data = await reader_right.readexactly(chunk_size) - assert read_data == data - - -@pytest.mark.parametrize( - "iterations", - [16, 32, 64], -) -async def test_repeated_write_read_cycles_async( - adapter_pair: tuple[str, str], iterations: int -) -> None: - """Test repeated write/read cycles.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - data = bytes(range(256)) - - for _i in range(iterations): - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - assert result == data - - -async def test_buffered_writes_then_read_async(adapter_pair: tuple[str, str]) -> None: - """Test multiple writes followed by a single read.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - chunk = bytes(range(256)) - iterations = 4 - - # Write multiple chunks - for _ in range(iterations): - writer_left.write(chunk) - - # Read all data back - total_size = len(chunk) * iterations - result = await reader_right.readexactly(total_size) - - # Verify all data was received correctly - expected = chunk * iterations - assert result == expected - - -@pytest.mark.parametrize( - "payload_size", - [1024, 2048], # Kernel buffers are typically ~4KB, stay well below that -) -async def test_large_payload_async( - adapter_pair: tuple[str, str], payload_size: int -) -> None: - """Test large payload transmission.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=460800 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - data = bytes([i % 256 for i in range(payload_size)]) - - writer_left.write(data) - result = await reader_right.readexactly(len(data)) - - assert result == data - - -async def test_rapid_small_writes_async(adapter_pair: tuple[str, str]) -> None: - """Test rapid succession of small writes.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Send many small writes - iterations = 256 - received = bytearray() - - for i in range(iterations): - data = bytes([i % 256]) - writer_left.write(data) - result = await reader_right.readexactly(1) - received.extend(result) - - # Verify all bytes were received in order - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected - - -@pytest.mark.parametrize( - ("baudrate", "iterations"), - [ - (9600, 8), - (115200, 64), - # (921600, 512), # Hardware can't reliably handle 921600 with dual loopback - ], -) -async def test_sustained_throughput_async( - adapter_pair: tuple[str, str], baudrate: int, iterations: int -) -> None: - """Test sustained data throughput at various baudrates.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=baudrate - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - chunk = os.urandom(1024) - - for _ in range(iterations): - writer_left.write(chunk) - result = await reader_right.readexactly(len(chunk)) - assert result == chunk - - -@pytest.mark.parametrize( - "baudrate", - [ - 9600, - 19200, - 38400, - 57600, - 115200, - 230400, - 460800, - # 921600, # Hardware can't reliably handle 921600 with dual loopback - ], -) -async def test_valid_baudrates_async( - adapter_pair: tuple[str, str], baudrate: int -) -> None: - """Test that valid baudrates are accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=baudrate - ) as ( - reader, - writer, - _, - _writer_right, - ): - # Verify baudrate was set (check transport) - assert writer.transport.baudrate == baudrate - writer.write(b"test") - - -@pytest.mark.parametrize( - "parity", - [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE], -) -async def test_valid_parity_async( - adapter_pair: tuple[str, str], parity: Parity -) -> None: - """Test that valid parity settings are accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200, parity=parity - ) as ( - reader, - writer, - _, - _writer_right, - ): - assert writer.transport.parity == parity - writer.write(b"test") - - -@pytest.mark.parametrize( - ("stopbits", "expected"), - [ - (StopBits.ONE, StopBits.ONE), - (StopBits.ONE_POINT_FIVE, StopBits.ONE_POINT_FIVE), - (StopBits.TWO, StopBits.TWO), - (1, StopBits.ONE), - (1.5, StopBits.ONE_POINT_FIVE), - (2, StopBits.TWO), - ], -) -async def test_valid_stopbits_async( - adapter_pair: tuple[str, str], - stopbits: StopBits | int | float, - expected: StopBits, -) -> None: - """Test that valid stopbits settings are accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200, stopbits=stopbits - ) as ( - reader, - writer, - _, - _writer_right, - ): - assert writer.transport.stopbits == expected - writer.write(b"test") - - -@pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -async def test_valid_byte_size_async( - adapter_pair: tuple[str, str], byte_size: int -) -> None: - """Test that valid byte sizes are accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200, byte_size=byte_size - ) as ( - reader, - writer, - _, - _writer_right, - ): - writer.write(b"test") - - -@pytest.mark.parametrize("xonxoff", [True, False]) -async def test_xonxoff_setting_async( - adapter_pair: tuple[str, str], xonxoff: bool -) -> None: - """Test that xonxoff setting is accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200, xonxoff=xonxoff - ) as ( - reader, - writer, - _, - _writer_right, - ): - writer.write(b"test") - - -@pytest.mark.parametrize( - "rtscts", - [True, False], -) -async def test_rtscts_setting_async( - adapter_pair: tuple[str, str], rtscts: bool -) -> None: - """Test that rtscts setting is accepted.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200, rtscts=rtscts - ) as ( - reader, - writer, - _, - _writer_right, - ): - writer.write(b"test") - - -async def test_concurrent_writes_async(adapter_pair: tuple[str, str]) -> None: - """Test concurrent writes from multiple tasks.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - - async def write_data(data: bytes) -> None: - writer_left.write(data) - - # Write different data concurrently - data1 = b"A" * 100 - data2 = b"B" * 100 - data3 = b"C" * 100 - - await asyncio.gather( - write_data(data1), - write_data(data2), - write_data(data3), - ) - - # Read all data back - total_data = await reader_right.readexactly(300) - assert total_data == b"A" * 100 + b"B" * 100 + b"C" * 100 - - -async def test_read_with_timeout_async(adapter_pair: tuple[str, str]) -> None: - """Test reading with timeout.""" - async with async_create_dual_loopback( - adapter_pair[0], adapter_pair[1], baudrate=115200 - ) as ( - reader_left, - writer_left, - reader_right, - writer_right, - ): - # Write some data - writer_left.write(b"test") - - # Read with timeout should succeed - result = await asyncio.wait_for(reader_right.readexactly(4), timeout=1.0) - assert result == b"test" - - # Reading without data should timeout - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(reader_right.readexactly(1), timeout=0.1) - - -async def test_fast_open_close(adapter_pair: tuple[str, str]) -> None: - """Test quickly opening and closing a port doesn't crash.""" - connection_lost_event = asyncio.Event() - - class FastCloseProtocol(asyncio.Protocol): - def connection_made(self, transport: asyncio.BaseTransport) -> None: - assert isinstance(transport, BaseSerialTransport) - transport.write(b"data that will be discarded on abort") - transport.abort() - - def connection_lost(self, exc: Exception | None) -> None: - connection_lost_event.set() - - transport, _ = await create_serial_connection( - asyncio.get_running_loop(), - FastCloseProtocol, - adapter_pair[0], - baudrate=115200, - ) - - await connection_lost_event.wait() - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -async def test_deassert_on_open_async(adapter_pair: tuple[str, str]) -> None: - """Test DTR/CTS deassertion on open.""" - async with async_create_reader_writer(adapter_pair[0], baudrate=115200) as ( - reader_left, - writer_left, - ): - # Open and set DTR/CTS - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtsdtr_on_open=PinState.HIGH, - rtsdtr_on_close=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - await writer_right.transport.set_modem_pins(dtr=True) - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # It persists - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # When we deassert on open, it should clear - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtsdtr_on_open=PinState.LOW, - rtsdtr_on_close=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - assert (await writer_left.transport.get_modem_pins()).cts is PinState.LOW - await writer_right.transport.set_modem_pins(dtr=True) - - # Nothing changes on close - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -async def test_hang_up_on_close_async(adapter_pair: tuple[str, str]) -> None: - """Test DTR/CTS hang up on close.""" - async with async_create_reader_writer(adapter_pair[0], baudrate=115200) as ( - reader_left, - writer_left, - ): - # Open and set DTR/CTS - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtsdtr_on_close=PinState.HIGH, - rtsdtr_on_open=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - await writer_right.transport.set_modem_pins(dtr=True) - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # It persists - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # Without hang up on close, it still persists - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtsdtr_on_close=PinState.HIGH, - rtsdtr_on_open=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # When we hang up on close, it should clear - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtsdtr_on_close=PinState.LOW, - rtsdtr_on_open=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - assert (await writer_left.transport.get_modem_pins()).cts is PinState.LOW - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -@pytest.mark.parametrize( - ("rtscts", "rtsdtr_on_open", "expected_state"), - [ - (False, PinState.HIGH, PinState.HIGH), # No flow control, preserve pins - (False, PinState.LOW, PinState.LOW), # No flow control, clear pins - (True, PinState.HIGH, PinState.HIGH), # Flow control, preserve pins - (True, PinState.LOW, PinState.LOW), # Flow control, clear pins - ], -) -async def test_deassert_on_open_with_rtscts_async( - adapter_pair: tuple[str, str], - rtscts: bool, - rtsdtr_on_open: PinState, - expected_state: PinState, -) -> None: - """Test interaction of rtsdtr_on_open with rtscts.""" - async with async_create_reader_writer(adapter_pair[0], baudrate=115200) as ( - reader_left, - writer_left, - ): - # Set DTR on right side, verify CTS appears on left - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtscts=False, - rtsdtr_on_open=PinState.HIGH, - ) as ( - reader_right, - writer_right, - ): - await writer_right.transport.set_modem_pins(dtr=True) - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # DTR persists after close - assert (await writer_left.transport.get_modem_pins()).cts is PinState.HIGH - - # Open with test parameters - async with async_create_reader_writer( - adapter_pair[1], - baudrate=115200, - rtscts=rtscts, - rtsdtr_on_open=rtsdtr_on_open, - ) as ( - reader_right, - writer_right, - ): - assert (await writer_left.transport.get_modem_pins()).cts is expected_state diff --git a/tests/test_loopback_dual_sync.py b/tests/test_loopback_dual_sync.py deleted file mode 100644 index 7f8f8cf..0000000 --- a/tests/test_loopback_dual_sync.py +++ /dev/null @@ -1,696 +0,0 @@ -"""Test sync APIs with dual loopback hardware.""" - -from asyncio import IncompleteReadError -import os -import sys - -import pytest - -from serialx import Parity, PinState, Serial, StopBits -from tests.common import create_dual_loopback, measure_time - - -def test_all_bytes_dual(adapter_pair: tuple[str, str]) -> None: - """Test that all bytes 0-255 can be transmitted.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - # Create a byte array with all possible byte values - data = bytes(range(256)) - - serial_left.write(data) - result = serial_right.readexactly(len(data)) - - assert result == data - - -def test_segmented_binary_data_dual(adapter_pair: tuple[str, str]) -> None: - """Test binary data sent in segments.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - # Send all bytes in smaller segments - segment_size = 16 - data = bytes(range(256)) - - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - serial_left.write(segment) - result = serial_right.readexactly(len(segment)) - assert result == segment - - -@pytest.mark.parametrize( - "size", - [1, 16, 64, 256, 512, 1024], -) -def test_binary_payload_sizes_dual(adapter_pair: tuple[str, str], size: int) -> None: - """Test various binary payload sizes.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - # Create binary data with repeating pattern - data = bytes([i % 256 for i in range(size)]) - - serial_left.write(data) - result = serial_right.readexactly(len(data)) - - assert result == data - - -def test_null_bytes_dual(adapter_pair: tuple[str, str]) -> None: - """Test that null bytes (0x00) can be transmitted.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - # Send a bunch of null bytes - null_data = b"\x00" * 64 - - serial_left.write(null_data) - result = serial_right.readexactly(len(null_data)) - - assert result == null_data - - -def test_overlapping_read_write_dual(adapter_pair: tuple[str, str]) -> None: - """Test that read and write can overlap, data is buffered.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - data = bytes(range(256)) - read = b"" - - serial_left.write(data[:100]) - read += serial_right.readexactly(10) - serial_left.write(data[100:150]) - read += serial_right.readexactly(10) - serial_left.write(data[150:]) - read += serial_right.readexactly(10) - read += serial_right.readexactly(256 - 30) - - assert read == data - - -@pytest.mark.parametrize( - ("baudrate", "chunk_size"), - [ - (9600, 1), - (9600, 16), - (115200, 1), - (115200, 16), - (115200, 64), - # (921600, 1), # Hardware can't reliably handle 921600 with dual loopback - # (921600, 16), - # (921600, 256), - # (921600, 1024), - ], -) -def test_random_large_dual( - adapter_pair: tuple[str, str], baudrate: int, chunk_size: int -) -> None: - """Test loopback adapter random read/write.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=baudrate) as ( - serial_left, - serial_right, - ): - data = os.urandom(chunk_size) - serial_left.write(data) - - read_data = serial_right.readexactly(chunk_size) - assert read_data == data - - -@pytest.mark.parametrize( - "iterations", - [16, 32, 64], -) -def test_repeated_write_read_cycles_dual( - adapter_pair: tuple[str, str], iterations: int -) -> None: - """Test repeated write/read cycles.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - data = bytes(range(256)) - - for _i in range(iterations): - serial_left.write(data) - result = serial_right.readexactly(len(data)) - assert result == data - - -def test_buffered_writes_then_read_dual(adapter_pair: tuple[str, str]) -> None: - """Test multiple writes followed by a single read.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - chunk = bytes(range(256)) - iterations = 4 - - # Write multiple chunks - for _ in range(iterations): - serial_left.write(chunk) - - # Read all data back - total_size = len(chunk) * iterations - result = serial_right.readexactly(total_size) - - # Verify all data was received correctly - expected = chunk * iterations - assert result == expected - - -@pytest.mark.parametrize( - "payload_size", - [1024, 2048], # Kernel buffers are typically ~4KB, stay well below that -) -def test_large_payload_dual(adapter_pair: tuple[str, str], payload_size: int) -> None: - """Test large payload transmission.""" - left_port, right_port = adapter_pair - # Using 115200 instead of 921600 due to hardware limitations - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - data = bytes([i % 256 for i in range(payload_size)]) - - serial_left.write(data) - result = serial_right.readexactly(len(data)) - - assert result == data - - -def test_rapid_small_writes_dual(adapter_pair: tuple[str, str]) -> None: - """Test rapid succession of small writes.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - # Send many small writes - iterations = 256 - received = bytearray() - - for i in range(iterations): - data = bytes([i % 256]) - serial_left.write(data) - result = serial_right.readexactly(1) - received.extend(result) - - # Verify all bytes were received in order - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected - - -@pytest.mark.parametrize( - ("baudrate", "iterations"), - [ - (9600, 8), - (115200, 64), - # (921600, 512), # Hardware can't reliably handle 921600 with dual loopback - ], -) -def test_sustained_throughput_dual( - adapter_pair: tuple[str, str], baudrate: int, iterations: int -) -> None: - """Test sustained data throughput at various baudrates.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=baudrate) as ( - serial_left, - serial_right, - ): - chunk = os.urandom(1024) - - for _ in range(iterations): - serial_left.write(chunk) - result = serial_right.readexactly(len(chunk)) - assert result == chunk - - -@pytest.mark.parametrize( - "baudrate", - [ - 9600, - 19200, - 38400, - 57600, - 115200, - 230400, - 460800, - # 921600, # Hardware can't reliably handle 921600 with dual loopback - ], -) -def test_valid_baudrates_dual(adapter_pair: tuple[str, str], baudrate: int) -> None: - """Test that valid baudrates are accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=baudrate) as serial: - assert serial.baudrate == baudrate - serial.write(b"test") - - -@pytest.mark.parametrize( - "parity", - [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE], -) -def test_valid_parity_dual(adapter_pair: tuple[str, str], parity: Parity) -> None: - """Test that valid parity settings are accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, parity=parity) as serial: - assert serial.parity == parity - serial.write(b"test") - - -@pytest.mark.parametrize( - ("stopbits", "expected"), - [ - (StopBits.ONE, StopBits.ONE), - (StopBits.ONE_POINT_FIVE, StopBits.ONE_POINT_FIVE), - (StopBits.TWO, StopBits.TWO), - (1, StopBits.ONE), - (1.5, StopBits.ONE_POINT_FIVE), - (2, StopBits.TWO), - ], -) -def test_valid_stopbits_dual( - adapter_pair: tuple[str, str], - stopbits: StopBits | int | float, - expected: StopBits, -) -> None: - """Test that valid stopbits settings are accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, stopbits=stopbits) as serial: - assert serial.stopbits == expected - serial.write(b"test") - - -@pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -def test_valid_byte_size_dual(adapter_pair: tuple[str, str], byte_size: int) -> None: - """Test that valid byte sizes are accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, byte_size=byte_size) as serial: - serial.write(b"test") - - -@pytest.mark.parametrize("xonxoff", [True, False]) -def test_xonxoff_setting_dual(adapter_pair: tuple[str, str], xonxoff: bool) -> None: - """Test that xonxoff setting is accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, xonxoff=xonxoff) as serial: - serial.write(b"test") - - -@pytest.mark.parametrize( - "rtscts", - [True, False], -) -def test_rtscts_setting_dual(adapter_pair: tuple[str, str], rtscts: bool) -> None: - """Test that rtscts setting is accepted.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, rtscts=rtscts) as serial_left: - with Serial(adapter_pair[1], baudrate=115200) as serial_right: - serial_right.set_modem_pins(dtr=True) - serial_left.write(b"test") - - -def test_exclusive_dual(adapter_pair: tuple[str, str]) -> None: - """Test that exclusive setting is respected.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, exclusive=True) as serial: - assert serial.exclusive is True - - # Opening a second one will fail - with pytest.raises(OSError): - with Serial(adapter_pair[0], baudrate=115200, exclusive=True): - pass - - -@pytest.mark.skipif( - sys.platform == "win32", reason="com0com does not support shared access" -) -def test_exclusive_disabled_dual(adapter_pair: tuple[str, str]) -> None: - """Test that exclusive setting is respected.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200, exclusive=False) as serial1: - assert serial1.exclusive is False - - # Can open the same port again without exclusive - with Serial(adapter_pair[0], baudrate=115200, exclusive=False) as serial2: - assert serial2.exclusive is False - # Both can write without error - serial2.write(b"test") - - -def test_context_manager_multiple_times_dual(adapter_pair: tuple[str, str]) -> None: - """Test that context manager can be used multiple times.""" - left_port, right_port = adapter_pair - serial_left = Serial(adapter_pair[0], baudrate=115200) - serial_right = Serial(adapter_pair[1], baudrate=115200) - - # First context - with serial_left, serial_right: - serial_left.write(b"test1") - result = serial_right.readexactly(5) - assert result == b"test1" - - # Second context - with serial_left, serial_right: - serial_left.write(b"test2") - result = serial_right.readexactly(5) - assert result == b"test2" - - -def test_open_close_cycles_dual(adapter_pair: tuple[str, str]) -> None: - """Test multiple open/close cycles.""" - left_port, right_port = adapter_pair - serial_left = Serial(adapter_pair[0], baudrate=115200) - serial_right = Serial(adapter_pair[1], baudrate=115200) - - # Cycle 1 - serial_left.open() - serial_right.open() - serial_left.write(b"1") - assert serial_right.readexactly(1) == b"1" - serial_left.close() - serial_right.close() - - # Cycle 2 - serial_left.open() - serial_right.open() - serial_left.write(b"2") - assert serial_right.readexactly(1) == b"2" - serial_left.close() - serial_right.close() - - # Cycle 3 - serial_left.open() - serial_right.open() - serial_left.write(b"3") - assert serial_right.readexactly(1) == b"3" - serial_left.close() - serial_right.close() - - -def test_flush_after_write_dual(adapter_pair: tuple[str, str]) -> None: - """Test flushing after write operation.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - serial_left.flush() - - data = b"Test flush operation" - serial_left.write(data) - serial_left.flush() - - result = serial_right.read(len(data)) - assert result == data - - -def test_multiple_flush_calls_dual(adapter_pair: tuple[str, str]) -> None: - """Test multiple consecutive flush calls.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - data = b"" - - for i in range(5): - chunk = f"Test data {i}".encode("ascii") - data += chunk - - serial_left.write(chunk) - serial_left.flush() - - result = serial_right.readexactly(len(data)) - assert result == data - - -def test_read_timeout_dual(adapter_pair: tuple[str, str]) -> None: - """Test that reading with a timeout returns 0 bytes after the timeout.""" - left_port, _right_port = adapter_pair - - with Serial(left_port, baudrate=115200, read_timeout=0.2) as serial: - assert serial.read_timeout == 0.2 - - with measure_time() as elapsed: - result = serial.read(10) - - assert len(result) == 0 - assert elapsed() >= 0.15 - - -def test_read_timeout_with_data_dual(adapter_pair: tuple[str, str]) -> None: - """Test that reading with a timeout returns available data immediately.""" - left_port, right_port = adapter_pair - - with create_dual_loopback( - left_port, right_port, baudrate=115200, read_timeout=2.0 - ) as ( - serial_left, - serial_right, - ): - data = b"hello" - serial_left.write(data) - - with measure_time() as elapsed: - result = serial_right.readexactly(len(data)) - - assert result == data - assert elapsed() < 1.0 - - -def test_readexactly_partial_timeout_dual(adapter_pair: tuple[str, str]) -> None: - """Test that readexactly(10) with only 5 bytes raises IncompleteReadError.""" - left_port, right_port = adapter_pair - - with create_dual_loopback( - left_port, right_port, baudrate=115200, read_timeout=0.5 - ) as ( - serial_left, - serial_right, - ): - serial_left.write(b"hello") - serial_left.flush() - - with measure_time() as elapsed: - with pytest.raises(IncompleteReadError) as exc_info: - serial_right.readexactly(10) - - assert exc_info.value.partial == b"hello" - assert 0.5 <= elapsed() < 1.0 - - -def test_fast_open_close(adapter_pair: tuple[str, str]) -> None: - """Test quickly opening and closing a port.""" - - message = b"Fast write and close test" - - with Serial(adapter_pair[0], baudrate=115200) as serial_left: - with Serial(adapter_pair[1], baudrate=115200) as serial_right: - serial_right.write(message) - serial_right.flush() - - assert serial_left.readexactly(len(message)) == message - - -def test_deprecated_dtr_cts_dual(adapter_pair: tuple[str, str]) -> None: - """Test DTR and CTS properties (deprecated alias).""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - serial_left.set_modem_pins(rts=False) - serial_right.set_modem_pins(rts=False) - - serial_left.dtr = True - assert serial_right.get_modem_pins().cts is PinState.HIGH - - serial_right.dtr = True - assert serial_left.get_modem_pins().cts is PinState.HIGH - - serial_left.dtr = False - assert serial_right.get_modem_pins().cts is PinState.LOW - - serial_right.dtr = False - assert serial_left.get_modem_pins().cts is PinState.LOW - - -def test_dtr_cts_dual(adapter_pair: tuple[str, str]) -> None: - """Test DTR and CTS.""" - left_port, right_port = adapter_pair - with create_dual_loopback(left_port, right_port, baudrate=115200) as ( - serial_left, - serial_right, - ): - serial_left.set_modem_pins(rts=False) - serial_right.set_modem_pins(rts=False) - - serial_left.set_modem_pins(dtr=True) - assert serial_right.get_modem_pins().cts is PinState.HIGH - - serial_right.set_modem_pins(dtr=True) - assert serial_left.get_modem_pins().cts is PinState.HIGH - - serial_left.set_modem_pins(dtr=False) - assert serial_right.get_modem_pins().cts is PinState.LOW - - serial_right.set_modem_pins(dtr=False) - assert serial_left.get_modem_pins().cts is PinState.LOW - - -@pytest.mark.skipif( - sys.platform != "win32", reason="CTS flow control test requires com0com" -) -def test_write_timeout_cts_held_dual(adapter_pair: tuple[str, str]) -> None: - """Test that write timeout fires when CTS is deasserted (flow control hold).""" - left_port, right_port = adapter_pair - - # Open right side first and deassert DTR so left's CTS goes low - with Serial(right_port, baudrate=9600) as serial_right: - serial_right.set_modem_pins(dtr=False) - - with Serial( - left_port, - baudrate=9600, - rtscts=True, - write_timeout=0.5, - ) as serial_left: - with measure_time() as elapsed: - with pytest.raises(TimeoutError): - # Write enough data to fill the tiny com0com buffer and block - serial_left.write(b"x" * 1024) - - assert 0.5 <= elapsed() < 1.5 - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -def test_deassert_on_open(adapter_pair: tuple[str, str]) -> None: - """Test DTR/CTS deassertion on open.""" - left_port, right_port = adapter_pair - with Serial(left_port, baudrate=115200) as serial_left: - # Open and set DTR/CTS - with Serial( - right_port, - baudrate=115200, - rtsdtr_on_open=PinState.HIGH, - rtsdtr_on_close=PinState.HIGH, - ) as serial_right: - serial_right.set_modem_pins(dtr=True) - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # It persists - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # When we deassert on open, it should clear - with Serial( - right_port, - baudrate=115200, - rtsdtr_on_open=PinState.LOW, - rtsdtr_on_close=PinState.HIGH, - ) as serial_right: - assert serial_left.get_modem_pins().cts is PinState.LOW - serial_right.set_modem_pins(dtr=True) - - # Nothing changes on close - assert serial_left.get_modem_pins().cts is PinState.HIGH - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -def test_hang_up_on_close(adapter_pair: tuple[str, str]) -> None: - """Test DTR/CTS hang up on close.""" - left_port, right_port = adapter_pair - with Serial(adapter_pair[0], baudrate=115200) as serial_left: - # Open and set DTR/CTS - with Serial( - right_port, - baudrate=115200, - rtsdtr_on_close=PinState.HIGH, - rtsdtr_on_open=PinState.HIGH, - ) as serial_right: - serial_right.set_modem_pins(dtr=True) - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # It persists - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # Without hang up on close, it still persists - with Serial( - right_port, - baudrate=115200, - rtsdtr_on_close=PinState.HIGH, - rtsdtr_on_open=PinState.HIGH, - ) as serial_right: - assert serial_left.get_modem_pins().cts is PinState.HIGH - - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # When we hang up on close, it should clear - with Serial( - right_port, - baudrate=115200, - rtsdtr_on_close=PinState.LOW, - rtsdtr_on_open=PinState.HIGH, - ) as serial_right: - assert serial_left.get_modem_pins().cts is PinState.HIGH - - assert serial_left.get_modem_pins().cts is PinState.LOW - - -@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") -@pytest.mark.parametrize( - ("rtscts", "rtsdtr_on_open", "expected_state"), - [ - (False, PinState.HIGH, PinState.HIGH), # No flow control, preserve pins - (False, PinState.LOW, PinState.LOW), # No flow control, clear pins - (True, PinState.HIGH, PinState.HIGH), # Flow control, preserve pins - (True, PinState.LOW, PinState.LOW), # Flow control, clear pins - ], -) -def test_deassert_on_open_with_rtscts( - adapter_pair: tuple[str, str], - rtscts: bool, - rtsdtr_on_open: PinState, - expected_state: PinState, -) -> None: - """Test interaction of rtsdtr_on_open with rtscts.""" - left_port, right_port = adapter_pair - with Serial(left_port, baudrate=115200) as serial_left: - # Set DTR on right side, verify CTS appears on left - with Serial( - right_port, - baudrate=115200, - rtscts=False, - rtsdtr_on_open=PinState.HIGH, - ) as serial_right: - serial_right.set_modem_pins(dtr=True) - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # DTR persists after close - assert serial_left.get_modem_pins().cts is PinState.HIGH - - # Open with test parameters - with Serial( - right_port, - baudrate=115200, - rtscts=rtscts, - rtsdtr_on_open=rtsdtr_on_open, - ) as serial_right: - assert serial_left.get_modem_pins().cts is expected_state diff --git a/tests/test_loopback_sync.py b/tests/test_loopback_sync.py deleted file mode 100644 index ccc22e3..0000000 --- a/tests/test_loopback_sync.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Test binary data transmission.""" - -import os - -import pytest - -from serialx import Parity, PinState, Serial, StopBits - - -def test_all_bytes_loopback(loopback_adapter: str) -> None: - """Test that all bytes 0-255 can be transmitted.""" - - # We intentionally use a non-POSIX baudrate - with Serial(loopback_adapter, baudrate=12345) as serial: - # Create a byte array with all possible byte values - data = bytes(range(256)) - - serial.write(data) - result = serial.readexactly(len(data)) - - assert result == data - - -def test_segmented_binary_data_loopback(loopback_adapter: str) -> None: - """Test binary data sent in segments.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - # Send all bytes in smaller segments - segment_size = 16 - data = bytes(range(256)) - - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - serial.write(segment) - result = serial.readexactly(len(segment)) - assert result == segment - - -@pytest.mark.parametrize( - "size", - [1, 16, 64, 256, 512, 1024], -) -def test_binary_payload_sizes_loopback(loopback_adapter: str, size: int) -> None: - """Test various binary payload sizes.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - # Create binary data with repeating pattern - data = bytes([i % 256 for i in range(size)]) - - serial.write(data) - result = serial.readexactly(len(data)) - - assert result == data - - -def test_null_bytes_loopback(loopback_adapter: str) -> None: - """Test that null bytes (0x00) can be transmitted.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - # Send a bunch of null bytes - null_data = b"\x00" * 64 - - serial.write(null_data) - result = serial.readexactly(len(null_data)) - - assert result == null_data - - -def test_overlapping_read_write_loopback(loopback_adapter: str) -> None: - """Test that read and write can overlap, data is buffered.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - data = bytes(range(256)) - read = b"" - - serial.write(data[:100]) - read += serial.readexactly(10) - serial.write(data[100:150]) - read += serial.readexactly(10) - serial.write(data[150:]) - read += serial.readexactly(10) - read += serial.readexactly(256 - 30) - - assert read == data - - -@pytest.mark.parametrize( - ("baudrate", "chunk_size"), - [ - (9600, 1), - (9600, 16), - (115200, 1), - (115200, 16), - (115200, 64), - (921600, 1), - (921600, 16), - (921600, 256), - (921600, 1024), - ], -) -def test_random_large_loopback( - loopback_adapter: str, baudrate: int, chunk_size: int -) -> None: - """Test loopback adapter random read/write.""" - with Serial(loopback_adapter, baudrate=baudrate) as serial: - data = os.urandom(chunk_size) - serial.write(data) - - read_data = serial.readexactly(chunk_size) - assert read_data == data - - -@pytest.mark.parametrize( - "iterations", - [16, 32, 64], -) -def test_repeated_write_read_cycles_loopback( - loopback_adapter: str, iterations: int -) -> None: - """Test repeated write/read cycles.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - data = bytes(range(256)) - - for _i in range(iterations): - serial.write(data) - result = serial.readexactly(len(data)) - assert result == data - - -def test_buffered_writes_then_read_loopback(loopback_adapter: str) -> None: - """Test multiple writes followed by a single read.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - chunk = bytes(range(256)) - iterations = 4 - - # Write multiple chunks - for _ in range(iterations): - serial.write(chunk) - - # Read all data back - total_size = len(chunk) * iterations - result = serial.readexactly(total_size) - - # Verify all data was received correctly - expected = chunk * iterations - assert result == expected - - -@pytest.mark.parametrize( - "payload_size", - [1024, 2048], # Kernel buffers are typically ~4KB, stay well below that -) -def test_large_payload_loopback(loopback_adapter: str, payload_size: int) -> None: - """Test large payload transmission.""" - with Serial(loopback_adapter, baudrate=921600) as serial: - data = bytes([i % 256 for i in range(payload_size)]) - - serial.write(data) - result = serial.readexactly(len(data)) - - assert result == data - - -def test_rapid_small_writes_loopback(loopback_adapter: str) -> None: - """Test rapid succession of small writes.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - # Send many small writes - iterations = 256 - received = bytearray() - - for i in range(iterations): - data = bytes([i % 256]) - serial.write(data) - result = serial.readexactly(1) - received.extend(result) - - # Verify all bytes were received in order - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected - - -@pytest.mark.parametrize( - ("baudrate", "iterations"), - [ - (9600, 8), - (115200, 64), - (921600, 512), - ], -) -def test_sustained_throughput_loopback( - loopback_adapter: str, baudrate: int, iterations: int -) -> None: - """Test sustained data throughput at various baudrates.""" - with Serial(loopback_adapter, baudrate=baudrate) as serial: - chunk = os.urandom(1024) - - for _ in range(iterations): - serial.write(chunk) - result = serial.readexactly(len(chunk)) - assert result == chunk - - -@pytest.mark.parametrize( - "baudrate", - [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600], -) -def test_valid_baudrates_loopback(loopback_adapter: str, baudrate: int) -> None: - """Test that valid baudrates are accepted.""" - with Serial(loopback_adapter, baudrate=baudrate) as serial: - assert serial.baudrate == baudrate - serial.write(b"test") - - -@pytest.mark.parametrize( - "parity", - [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE], -) -def test_valid_parity_loopback(loopback_adapter: str, parity: Parity) -> None: - """Test that valid parity settings are accepted.""" - with Serial(loopback_adapter, baudrate=115200, parity=parity) as serial: - assert serial.parity == parity - serial.write(b"test") - - -@pytest.mark.parametrize( - ("stopbits", "expected"), - [ - (StopBits.ONE, StopBits.ONE), - (StopBits.ONE_POINT_FIVE, StopBits.ONE_POINT_FIVE), - (StopBits.TWO, StopBits.TWO), - (1, StopBits.ONE), - (1.5, StopBits.ONE_POINT_FIVE), - (2, StopBits.TWO), - ], -) -def test_valid_stopbits_loopback( - loopback_adapter: str, stopbits: StopBits | int | float, expected: StopBits -) -> None: - """Test that valid stopbits settings are accepted.""" - with Serial(loopback_adapter, baudrate=115200, stopbits=stopbits) as serial: - assert serial.stopbits == expected - serial.write(b"test") - - -@pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -def test_valid_byte_size_loopback(loopback_adapter: str, byte_size: int) -> None: - """Test that valid byte sizes are accepted.""" - with Serial(loopback_adapter, baudrate=115200, byte_size=byte_size) as serial: - serial.write(b"test") - - -@pytest.mark.parametrize("xonxoff", [True, False]) -def test_xonxoff_setting_loopback(loopback_adapter: str, xonxoff: bool) -> None: - """Test that xonxoff setting is accepted.""" - with Serial(loopback_adapter, baudrate=115200, xonxoff=xonxoff) as serial: - serial.write(b"test") - - -@pytest.mark.parametrize( - "rtscts", - [True, False], -) -def test_rtscts_setting_loopback(loopback_adapter: str, rtscts: bool) -> None: - """Test that rtscts setting is accepted.""" - with Serial(loopback_adapter, baudrate=115200, rtscts=rtscts) as serial: - serial.write(b"test") - - -def test_exclusive_loopback(loopback_adapter: str) -> None: - """Test that exclusive setting is respected.""" - with Serial(loopback_adapter, baudrate=115200, exclusive=True) as serial: - assert serial.exclusive is True - - # Opening a second one will fail - with pytest.raises(OSError): - with Serial(loopback_adapter, baudrate=115200, exclusive=True): - pass - - -def test_exclusive_disabled_loopback(loopback_adapter: str) -> None: - """Test that exclusive setting is respected.""" - with Serial(loopback_adapter, baudrate=115200, exclusive=False) as serial1: - assert serial1.exclusive is False - - with Serial(loopback_adapter, baudrate=115200, exclusive=False) as serial2: - assert serial2.exclusive is False - serial2.write(b"test") - - assert serial1.readexactly(4) == b"test" - - -def test_context_manager_multiple_times_loopback(loopback_adapter: str) -> None: - """Test that context manager can be used multiple times.""" - serial = Serial(loopback_adapter, baudrate=115200) - - # First context - with serial: - serial.write(b"test1") - result = serial.readexactly(5) - assert result == b"test1" - - # Second context - with serial: - serial.write(b"test2") - result = serial.readexactly(5) - assert result == b"test2" - - -def test_open_close_cycles_loopback(loopback_adapter: str) -> None: - """Test multiple open/close cycles.""" - serial = Serial(loopback_adapter, baudrate=115200) - - # Cycle 1 - serial.open() - serial.write(b"1") - assert serial.readexactly(1) == b"1" - serial.close() - - # Cycle 2 - serial.open() - serial.write(b"2") - assert serial.readexactly(1) == b"2" - serial.close() - - # Cycle 3 - serial.open() - serial.write(b"3") - assert serial.readexactly(1) == b"3" - serial.close() - - -def test_flush_after_write_loopback(loopback_adapter: str) -> None: - """Test flushing after write operation.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - serial.flush() - - data = b"Test flush operation" - serial.write(data) - serial.flush() - - result = serial.readexactly(len(data)) - assert result == data - - -def test_multiple_flush_calls_loopback(loopback_adapter: str) -> None: - """Test multiple consecutive flush calls.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - data = b"" - - for i in range(5): - chunk = f"Test data {i}".encode("ascii") - data += chunk - - serial.write(chunk) - serial.flush() - - result = serial.readexactly(len(data)) - assert result == data - - -def test_set_modem_pins_loopback(loopback_adapter: str) -> None: - """Test setting modem control bits with loopback adapter.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - serial.set_modem_pins(dtr=True, rts=True) - - modem_pins = serial.get_modem_pins() - assert modem_pins.dtr is PinState.HIGH - assert modem_pins.rts is PinState.HIGH - - # Set DTR low, leave RTS unchanged - serial.set_modem_pins(dtr=False) - - modem_pins = serial.get_modem_pins() - assert modem_pins.dtr is PinState.LOW - assert modem_pins.rts is PinState.HIGH - - # Set both low - serial.set_modem_pins(dtr=False, rts=False) - - modem_pins = serial.get_modem_pins() - assert modem_pins.dtr is PinState.LOW - assert modem_pins.rts is PinState.LOW - - -def test_deprecated_dtr_property_loopback(loopback_adapter: str) -> None: - """Test DTR property (deprecated alias) with loopback adapter.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - serial.dtr = True - assert serial.dtr is True - - serial.dtr = False - assert serial.dtr is False - - -def test_deprecated_rts_property_loopback(loopback_adapter: str) -> None: - """Test RTS property (deprecated alias) with loopback adapter.""" - with Serial(loopback_adapter, baudrate=115200) as serial: - serial.rts = True - assert serial.rts is True - - serial.rts = False - assert serial.rts is False diff --git a/tests/test_serial_linux.py b/tests/test_serial_linux.py index 26dd29f..aad7948 100644 --- a/tests/test_serial_linux.py +++ b/tests/test_serial_linux.py @@ -11,6 +11,7 @@ import contextlib import errno import fcntl +import os import threading from typing import Any from unittest.mock import ANY, call, patch @@ -119,6 +120,66 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: with contextlib.suppress(Exception): await connect_task + # Wait for the transport to fully close before the socat pair is torn + # down, preventing fd reuse races with the socat pidfd. + await transport.wait_closed() + # connection_made was never called assert protocol.connection_made_calls == 0 assert transport.is_closing() + + +async def test_async_linux_wait_closed_when_close_task_cancelled() -> None: + """wait_closed should resolve even if close task is cancelled before start.""" + loop = asyncio.get_running_loop() + transport = PosixSerialTransport(loop, asyncio.Protocol()) + + async with async_create_socat_pair() as (left_path, _right_path): + await transport.connect(path=left_path, baudrate=115200) + transport.close() + + close_task = transport._close_task + assert close_task is not None + close_task.cancel() + await asyncio.sleep(0) + + await transport.wait_closed() + + # Clean up if the fd wasn't closed + if transport._fileno is not None: + os.close(transport._fileno) + + +async def test_async_linux_wait_closed_when_connection_lost_raises() -> None: + """wait_closed should resolve even if protocol.connection_lost raises.""" + + class RaisingProtocol(asyncio.Protocol): + def connection_lost(self, exc: Exception | None) -> None: + raise RuntimeError("boom") + + loop = asyncio.get_running_loop() + transport = PosixSerialTransport(loop, RaisingProtocol()) + + async with async_create_socat_pair() as (left_path, _right_path): + await transport.connect(path=left_path, baudrate=115200) + transport.close() + + await transport.wait_closed() + assert transport._fileno is None + + +async def test_async_linux_close_clears_fileno_when_fd_already_closed() -> None: + """Close should clear fileno even if fd was externally closed.""" + loop = asyncio.get_running_loop() + transport = PosixSerialTransport(loop, asyncio.Protocol()) + + async with async_create_socat_pair() as (left_path, _right_path): + await transport.connect(path=left_path, baudrate=115200) + + assert transport._fileno is not None + os.close(transport._fileno) + + transport.close() + await transport.wait_closed() + + assert transport._fileno is None diff --git a/tests/test_serial_socket.py b/tests/test_serial_socket.py index 4e28ff5..c154cf6 100644 --- a/tests/test_serial_socket.py +++ b/tests/test_serial_socket.py @@ -2,8 +2,9 @@ import pytest -from serialx import serial_for_url +from serialx import Serial from serialx.platforms.serial_socket import SocketSerial +from tests.common import measure_time from tests.socket_relay import create_socket_pair @@ -20,7 +21,7 @@ def test_socket_connect_timeout_property() -> None: def test_socket_effective_timeout_mismatched() -> None: """Test that mismatched read/write timeouts use min for socket timeout.""" with create_socket_pair() as (left_url, _right_url): - serial = serial_for_url( + serial = Serial.from_url( left_url, baudrate=115200, read_timeout=2.0, write_timeout=1.0 ) assert isinstance(serial, SocketSerial) @@ -37,3 +38,15 @@ def test_socket_invalid_uri() -> None: with pytest.raises(ValueError, match="expected both host and port"): SocketSerial(path="socket://:1234", baudrate=115200) + + +def test_socket_connect_timeout() -> None: + """Test that connect_timeout is respected by SocketSerial.""" + url = "socket://192.0.2.1:1234" + + with measure_time() as elapsed: + with pytest.raises((OSError, TimeoutError)): + with Serial.from_url(url, baudrate=115200, connect_timeout=0.2): + pass + + assert elapsed() < 1.0 diff --git a/tests/test_sync_transports.py b/tests/test_sync_transports.py index 27b76a9..45fccef 100644 --- a/tests/test_sync_transports.py +++ b/tests/test_sync_transports.py @@ -4,107 +4,111 @@ from collections.abc import Iterator import logging import os +import sys +import time import pytest from serialx import ModemPins, Parity, PinState, Serial, StopBits, serial_for_url from serialx.common import BaseSerial -from tests.common import SOCAT_BINARY, create_socat_pair, measure_time -from tests.socket_relay import create_socket_pair +from tests.common import SerialPair, measure_time LOGGER = logging.getLogger(__name__) -@pytest.fixture(params=["socat", "socket"]) -def sync_transport_pair(request: pytest.FixtureRequest) -> Iterator[tuple[str, str]]: - """Yield a connected pair of transports.""" - backend = request.param - - if backend == "socat": - if not SOCAT_BINARY: - pytest.skip("socat binary is missing") - - with create_socat_pair() as (left, right): - yield (left, right) - elif backend == "socket": - with create_socket_pair() as (left, right): - yield (left, right) - else: - raise ValueError(f"Unknown backend: {backend}") - - @pytest.fixture -def sync_serial_pair( - sync_transport_pair: tuple[str, str], +def serial_opened_pair( + serial_pair: SerialPair, ) -> Iterator[tuple[BaseSerial, BaseSerial]]: """Yield a connected pair of opened Serial objects with default settings.""" - left_path, right_path = sync_transport_pair - with ( - serial_for_url(left_path, baudrate=115200) as left_serial, - serial_for_url(right_path, baudrate=115200) as right_serial, + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, ): - yield left_serial, right_serial + yield left, right + +# --- Data transmission --- -def test_sync_all_bytes(sync_serial_pair: tuple[Serial, Serial]) -> None: + +def test_sync_all_bytes(serial_pair: SerialPair) -> None: """Test that all bytes 0-255 can be transmitted.""" - left, right = sync_serial_pair - data = bytes(range(256)) - left.write(data) - result = right.readexactly(len(data)) - assert result == data + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + data = bytes(range(256)) + left.write(data) + assert right.readexactly(len(data)) == data + + +def test_sync_serial_for_url_factory(serial_pair: SerialPair) -> None: + """Test the top-level serial_for_url factory on all backends.""" + with ( + serial_for_url(serial_pair.left, baudrate=115200) as left, + serial_for_url(serial_pair.right, baudrate=115200) as right, + ): + data = b"serial_for_url" + left.write(data) + assert right.readexactly(len(data)) == data -def test_sync_segmented_binary_data(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_segmented_binary_data(serial_pair: SerialPair) -> None: """Test binary data sent in segments.""" - left, right = sync_serial_pair - segment_size = 16 - data = bytes(range(256)) + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + segment_size = 16 + data = bytes(range(256)) - for i in range(0, 256, segment_size): - segment = data[i : i + segment_size] - left.write(segment) - result = right.readexactly(len(segment)) - assert result == segment + for i in range(0, 256, segment_size): + segment = data[i : i + segment_size] + left.write(segment) + assert right.readexactly(len(segment)) == segment @pytest.mark.parametrize("size", [1, 16, 64, 256, 512, 1024]) -def test_sync_binary_payload_sizes( - sync_serial_pair: tuple[Serial, Serial], size: int -) -> None: +def test_sync_binary_payload_sizes(serial_pair: SerialPair, size: int) -> None: """Test various binary payload sizes.""" - left, right = sync_serial_pair - data = bytes([i % 256 for i in range(size)]) - left.write(data) - result = right.readexactly(len(data)) - assert result == data + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + data = bytes([i % 256 for i in range(size)]) + left.write(data) + assert right.readexactly(len(data)) == data -def test_sync_null_bytes(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_null_bytes(serial_pair: SerialPair) -> None: """Test that null bytes (0x00) can be transmitted.""" - left, right = sync_serial_pair - null_data = b"\x00" * 64 - left.write(null_data) - result = right.readexactly(len(null_data)) - assert result == null_data + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + null_data = b"\x00" * 64 + left.write(null_data) + assert right.readexactly(len(null_data)) == null_data -def test_sync_overlapping_read_write(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_overlapping_read_write(serial_pair: SerialPair) -> None: """Test that read and write can overlap, data is buffered.""" - left, right = sync_serial_pair - data = bytes(range(256)) - read = b"" + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + data = bytes(range(256)) + read = b"" - left.write(data[:100]) - read += right.readexactly(10) - left.write(data[100:150]) - read += right.readexactly(10) - left.write(data[150:]) - read += right.readexactly(10) - read += right.readexactly(256 - 30) + left.write(data[:100]) + read += right.readexactly(10) + left.write(data[100:150]) + read += right.readexactly(10) + left.write(data[150:]) + read += right.readexactly(10) + read += right.readexactly(256 - 30) - assert read == data + assert read == data @pytest.mark.parametrize( @@ -122,114 +126,103 @@ def test_sync_overlapping_read_write(sync_serial_pair: tuple[Serial, Serial]) -> ], ) def test_sync_random_large( - sync_transport_pair: tuple[str, str], baudrate: int, chunk_size: int + serial_pair: SerialPair, baudrate: int, chunk_size: int ) -> None: - """Test random read/write at various speeds (requires custom setup per test).""" - left_path, right_path = sync_transport_pair - + """Test random read/write at various speeds.""" with ( - serial_for_url(left_path, baudrate=baudrate) as left, - serial_for_url(right_path, baudrate=baudrate) as right, + Serial.from_url(serial_pair.left, baudrate=baudrate) as left, + Serial.from_url(serial_pair.right, baudrate=baudrate) as right, ): data = os.urandom(chunk_size) left.write(data) - read_data = right.readexactly(chunk_size) - assert read_data == data + assert right.readexactly(chunk_size) == data @pytest.mark.parametrize("iterations", [16, 32, 64]) def test_sync_repeated_write_read_cycles( - sync_serial_pair: tuple[Serial, Serial], iterations: int + serial_pair: SerialPair, iterations: int ) -> None: """Test repeated write/read cycles.""" - left, right = sync_serial_pair - data = bytes(range(256)) + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + data = bytes(range(256)) - for _ in range(iterations): - left.write(data) - result = right.readexactly(len(data)) - assert result == data + for _ in range(iterations): + left.write(data) + assert right.readexactly(len(data)) == data -def test_sync_buffered_writes_then_read( - sync_serial_pair: tuple[Serial, Serial], -) -> None: +def test_sync_buffered_writes_then_read(serial_pair: SerialPair) -> None: """Test multiple writes followed by a single read.""" - left, right = sync_serial_pair - chunk = bytes(range(256)) - iterations = 4 + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + chunk = bytes(range(256)) + iterations = 4 - for _ in range(iterations): - left.write(chunk) + for _ in range(iterations): + left.write(chunk) - total_size = len(chunk) * iterations - result = right.readexactly(total_size) - expected = chunk * iterations - assert result == expected + assert right.readexactly(len(chunk) * iterations) == chunk * iterations @pytest.mark.parametrize("payload_size", [1024, 2048]) -def test_sync_large_payload( - sync_transport_pair: tuple[str, str], payload_size: int -) -> None: +def test_sync_large_payload(serial_pair: SerialPair, payload_size: int) -> None: """Test large payload transmission.""" - left_path, right_path = sync_transport_pair - with ( - serial_for_url(left_path, baudrate=921600) as left, - serial_for_url(right_path, baudrate=921600) as right, + Serial.from_url(serial_pair.left, baudrate=921600) as left, + Serial.from_url(serial_pair.right, baudrate=921600) as right, ): data = bytes([i % 256 for i in range(payload_size)]) left.write(data) - result = right.readexactly(len(data)) - assert result == data + assert right.readexactly(len(data)) == data -def test_sync_rapid_small_writes(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_rapid_small_writes(serial_pair: SerialPair) -> None: """Test rapid succession of small writes.""" - left, right = sync_serial_pair - iterations = 256 - received = bytearray() + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + iterations = 256 + received = bytearray() - for i in range(iterations): - data = bytes([i % 256]) - left.write(data) - result = right.readexactly(1) - received.extend(result) + for i in range(iterations): + left.write(bytes([i % 256])) + received.extend(right.readexactly(1)) - expected = bytes([i % 256 for i in range(iterations)]) - assert bytes(received) == expected + assert bytes(received) == bytes([i % 256 for i in range(iterations)]) @pytest.mark.parametrize( "baudrate,iterations", [(9600, 8), (115200, 64), (921600, 512)] ) def test_sync_sustained_throughput( - sync_transport_pair: tuple[str, str], baudrate: int, iterations: int + serial_pair: SerialPair, baudrate: int, iterations: int ) -> None: """Test sustained data throughput at various baudrates.""" - left_path, right_path = sync_transport_pair - with ( - serial_for_url(left_path, baudrate=baudrate) as left, - serial_for_url(right_path, baudrate=baudrate) as right, + Serial.from_url(serial_pair.left, baudrate=baudrate) as left, + Serial.from_url(serial_pair.right, baudrate=baudrate) as right, ): chunk = os.urandom(1024) for _ in range(iterations): left.write(chunk) - result = right.readexactly(len(chunk)) - assert result == chunk + assert right.readexactly(len(chunk)) == chunk + + +# --- Configuration --- @pytest.mark.parametrize( "baudrate", [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] ) -def test_sync_valid_baudrates( - sync_transport_pair: tuple[str, str], baudrate: int -) -> None: +def test_sync_valid_baudrates(serial_pair: SerialPair, baudrate: int) -> None: """Test that valid baudrates are accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=baudrate) as serial: + with Serial.from_url(serial_pair.left, baudrate=baudrate) as serial: assert serial.baudrate == baudrate serial.write(b"test") @@ -237,12 +230,9 @@ def test_sync_valid_baudrates( @pytest.mark.parametrize( "parity", [Parity.NONE, Parity.ODD, Parity.EVEN, Parity.MARK, Parity.SPACE] ) -def test_sync_valid_parity( - sync_transport_pair: tuple[str, str], parity: Parity -) -> None: +def test_sync_valid_parity(serial_pair: SerialPair, parity: Parity) -> None: """Test that valid parity settings are accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200, parity=parity) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200, parity=parity) as serial: assert serial.parity == parity serial.write(b"test") @@ -259,278 +249,510 @@ def test_sync_valid_parity( ], ) def test_sync_valid_stopbits( - sync_transport_pair: tuple[str, str], + serial_pair: SerialPair, stopbits: StopBits | int | float, expected: StopBits, ) -> None: """Test that valid stopbits settings are accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200, stopbits=stopbits) as serial: + with Serial.from_url( + serial_pair.left, baudrate=115200, stopbits=stopbits + ) as serial: assert serial.stopbits == expected serial.write(b"test") @pytest.mark.parametrize("byte_size", [5, 6, 7, 8]) -def test_sync_valid_byte_size( - sync_transport_pair: tuple[str, str], byte_size: int -) -> None: +def test_sync_valid_byte_size(serial_pair: SerialPair, byte_size: int) -> None: """Test that valid byte sizes are accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200, byte_size=byte_size) as serial: + with Serial.from_url( + serial_pair.left, baudrate=115200, byte_size=byte_size + ) as serial: serial.write(b"test") @pytest.mark.parametrize("xonxoff", [True, False]) -def test_sync_xonxoff_setting( - sync_transport_pair: tuple[str, str], xonxoff: bool -) -> None: +def test_sync_xonxoff_setting(serial_pair: SerialPair, xonxoff: bool) -> None: """Test that xonxoff setting is accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200, xonxoff=xonxoff) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200, xonxoff=xonxoff) as serial: serial.write(b"test") @pytest.mark.parametrize("rtscts", [True, False]) -def test_sync_rtscts_setting( - sync_transport_pair: tuple[str, str], rtscts: bool -) -> None: +def test_sync_rtscts_setting(serial_pair: SerialPair, rtscts: bool) -> None: """Test that rtscts setting is accepted.""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200, rtscts=rtscts) as serial: - serial.write(b"test") + # Open both sides: on com0com, opening right asserts DTR which raises CTS on left + with Serial.from_url(serial_pair.right, baudrate=115200): + with Serial.from_url(serial_pair.left, baudrate=115200, rtscts=rtscts) as left: + left.write(b"test") -def test_sync_exclusive(sync_transport_pair: tuple[str, str]) -> None: +def test_sync_exclusive(serial_pair: SerialPair) -> None: """Test that exclusive setting is respected.""" - left_path, _ = sync_transport_pair - - with serial_for_url(left_path, baudrate=115200, exclusive=True) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200, exclusive=True) as serial: assert serial.exclusive is True - # Behavior depends on backend - if left_path.startswith("socket://"): + if serial_pair.backend == "socket": # Socket endpoints are not lockable tty devices - with serial_for_url(left_path, baudrate=115200, exclusive=True) as serial2: + with Serial.from_url( + serial_pair.left, baudrate=115200, exclusive=True + ) as serial2: assert serial2.exclusive is True else: - # Socat PTYs should lock with pytest.raises(OSError): - with serial_for_url(left_path, baudrate=115200, exclusive=True): + with Serial.from_url(serial_pair.left, baudrate=115200, exclusive=True): pass -def test_sync_exclusive_disabled(sync_transport_pair: tuple[str, str]) -> None: - """Test that exclusive setting is respected.""" - left_path, _ = sync_transport_pair +def test_sync_exclusive_disabled(serial_pair: SerialPair) -> None: + """Test that non-exclusive mode allows multiple opens.""" + if sys.platform == "win32": + pytest.skip("Windows does not support shared access") - with serial_for_url(left_path, baudrate=115200, exclusive=False) as serial1: + with Serial.from_url(serial_pair.left, baudrate=115200, exclusive=False) as serial1: assert serial1.exclusive is False - with serial_for_url(left_path, baudrate=115200, exclusive=False) as serial2: + + with Serial.from_url( + serial_pair.left, baudrate=115200, exclusive=False + ) as serial2: assert serial2.exclusive is False serial2.write(b"test") -def test_sync_context_manager_multiple_times( - sync_transport_pair: tuple[str, str], -) -> None: - """Test that context manager can be used multiple times.""" - left_path, right_path = sync_transport_pair +# --- Lifecycle --- - serial_left = serial_for_url(left_path, baudrate=115200) - serial_right = serial_for_url(right_path, baudrate=115200) - # First context - with serial_left, serial_right: - serial_left.write(b"test1") - result = serial_right.readexactly(5) - assert result == b"test1" +def test_sync_context_manager_multiple_times(serial_pair: SerialPair) -> None: + """Test that context manager can be used multiple times.""" + left = Serial.from_url(serial_pair.left, baudrate=115200) + right = Serial.from_url(serial_pair.right, baudrate=115200) - # Second context - with serial_left, serial_right: - serial_left.write(b"test2") - result = serial_right.readexactly(5) - assert result == b"test2" + with left, right: + left.write(b"test1") + assert right.readexactly(5) == b"test1" + with left, right: + left.write(b"test2") + assert right.readexactly(5) == b"test2" -def test_sync_open_close_cycles(sync_transport_pair: tuple[str, str]) -> None: - """Test multiple open/close cycles.""" - left_path, right_path = sync_transport_pair - serial_left = serial_for_url(left_path, baudrate=115200) - serial_right = serial_for_url(right_path, baudrate=115200) +def test_sync_open_close_cycles(serial_pair: SerialPair) -> None: + """Test multiple open/close cycles.""" + left = Serial.from_url(serial_pair.left, baudrate=115200) + right = Serial.from_url(serial_pair.right, baudrate=115200) for i in range(1, 4): - serial_left.open() - serial_right.open() + left.open() + right.open() chunk = str(i).encode("ascii") - serial_left.write(chunk) - assert serial_right.readexactly(1) == chunk + left.write(chunk) + assert right.readexactly(1) == chunk - serial_left.close() - serial_right.close() + left.close() + right.close() -def test_sync_flush_after_write(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_flush_after_write(serial_pair: SerialPair) -> None: """Test flushing after write operation.""" - left, right = sync_serial_pair - left.flush() - data = b"Test flush operation" - left.write(data) - left.flush() - result = right.readexactly(len(data)) - assert result == data + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + left.flush() + + data = b"Test flush operation" + left.write(data) + left.flush() + + assert right.readexactly(len(data)) == data -def test_sync_multiple_flush_calls(sync_serial_pair: tuple[Serial, Serial]) -> None: +def test_sync_multiple_flush_calls(serial_pair: SerialPair) -> None: """Test multiple consecutive flush calls.""" - left, right = sync_serial_pair - data = b"" + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + data = b"" - for i in range(5): - chunk = f"Test data {i}".encode("ascii") - data += chunk - left.write(chunk) - left.flush() + for i in range(5): + chunk = f"Test data {i}".encode("ascii") + data += chunk + left.write(chunk) + left.flush() - result = right.readexactly(len(data)) - assert result == data + assert right.readexactly(len(data)) == data -def test_sync_get_modem_pins(sync_transport_pair: tuple[str, str]) -> None: - """Test reading modem control bits.""" - left_path, _ = sync_transport_pair +# --- Modem pins --- - with serial_for_url(left_path, baudrate=115200) as serial: + +def test_sync_get_modem_pins(serial_pair: SerialPair) -> None: + """Test reading modem control bits.""" + with Serial.from_url(serial_pair.left, baudrate=115200) as serial: modem_pins = serial.get_modem_pins() assert isinstance(modem_pins, ModemPins) + for field in ["le", "dtr", "rts", "st", "sr", "cts", "car", "rng", "dsr"]: value = getattr(modem_pins, field) assert value in (PinState.HIGH, PinState.LOW, PinState.UNDEFINED) -def test_sync_set_modem_pins(sync_transport_pair: tuple[str, str]) -> None: - """Test setting modem control bits.""" - left_path, _ = sync_transport_pair +def test_sync_set_modem_pins_api(serial_pair: SerialPair) -> None: + """Test modem pin writes are accepted on all backends.""" + with Serial.from_url(serial_pair.left, baudrate=115200) as serial: + serial.set_modem_pins(dtr=True, rts=True) + pins_high = serial.get_modem_pins() + + serial.set_modem_pins(dtr=False, rts=False) + pins_low = serial.get_modem_pins() + + for pins in (pins_high, pins_low): + assert isinstance(pins, ModemPins) + for field in ["le", "dtr", "rts", "st", "sr", "cts", "car", "rng", "dsr"]: + value = getattr(pins, field) + assert value in (PinState.HIGH, PinState.LOW, PinState.UNDEFINED) + + if ( + pins_high.dtr is not PinState.UNDEFINED + and pins_low.dtr is not PinState.UNDEFINED + ): + assert pins_high.dtr is PinState.HIGH + assert pins_low.dtr is PinState.LOW + + if ( + pins_high.rts is not PinState.UNDEFINED + and pins_low.rts is not PinState.UNDEFINED + ): + assert pins_high.rts is PinState.HIGH + assert pins_low.rts is PinState.LOW + + +@pytest.mark.skipif( + sys.platform == "win32", reason="GetCommModemStatus cannot read back DTR/RTS" +) +@pytest.mark.skip_backends("socket", "socat") +def test_sync_set_modem_pins(serial_pair: SerialPair) -> None: + """Test setting modem control bits and verifying readback.""" - with serial_for_url(left_path, baudrate=115200) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200) as serial: serial.set_modem_pins(dtr=True, rts=True) - assert isinstance(serial.get_modem_pins(), ModemPins) + modem_pins = serial.get_modem_pins() + assert modem_pins.dtr is PinState.HIGH + assert modem_pins.rts is PinState.HIGH serial.set_modem_pins(dtr=False) - assert isinstance(serial.get_modem_pins(), ModemPins) + modem_pins = serial.get_modem_pins() + assert modem_pins.dtr is PinState.LOW + assert modem_pins.rts is PinState.HIGH serial.set_modem_pins(dtr=False, rts=False) - assert isinstance(serial.get_modem_pins(), ModemPins) + modem_pins = serial.get_modem_pins() + assert modem_pins.dtr is PinState.LOW + assert modem_pins.rts is PinState.LOW -def test_sync_deprecated_dtr_property(sync_transport_pair: tuple[str, str]) -> None: +@pytest.mark.skipif( + sys.platform == "win32", reason="GetCommModemStatus cannot read back DTR/RTS" +) +@pytest.mark.skip_backends("socket", "socat") +def test_sync_deprecated_dtr_property(serial_pair: SerialPair) -> None: """Test DTR property (deprecated alias).""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200) as serial: serial.dtr = True + assert serial.dtr is True + serial.dtr = False + assert serial.dtr is False -def test_sync_deprecated_rts_property(sync_transport_pair: tuple[str, str]) -> None: +@pytest.mark.skipif( + sys.platform == "win32", reason="GetCommModemStatus cannot read back DTR/RTS" +) +@pytest.mark.skip_backends("socket", "socat") +def test_sync_deprecated_rts_property(serial_pair: SerialPair) -> None: """Test RTS property (deprecated alias).""" - left_path, _ = sync_transport_pair - with serial_for_url(left_path, baudrate=115200) as serial: + with Serial.from_url(serial_pair.left, baudrate=115200) as serial: serial.rts = True + assert serial.rts is True + serial.rts = False + assert serial.rts is False -def test_sync_read_timeout(sync_transport_pair: tuple[str, str]) -> None: - """Test that reading with a timeout returns 0 bytes after the timeout.""" - left_path, _ = sync_transport_pair +# --- Timeouts --- - with serial_for_url(left_path, baudrate=115200, read_timeout=0.1) as serial: + +def test_sync_read_timeout(serial_pair: SerialPair) -> None: + """Test that reading with a timeout returns 0 bytes after the timeout.""" + with Serial.from_url(serial_pair.left, baudrate=115200, read_timeout=0.1) as serial: assert serial.read_timeout == 0.1 with measure_time() as elapsed: - # Try to read 10 bytes when no data is available result = serial.read(10) - # Should return 0 bytes assert len(result) == 0 - # Should have taken at least 0.1 seconds (allowing for some OS jitter) assert elapsed() >= 0.09 -def test_sync_read_timeout_with_partial_data( - sync_transport_pair: tuple[str, str], -) -> None: +def test_sync_read_timeout_with_partial_data(serial_pair: SerialPair) -> None: """Test that reading with a timeout returns available data immediately.""" - left_path, right_path = sync_transport_pair - with ( - serial_for_url(left_path, baudrate=115200, read_timeout=1.0) as serial_left, - serial_for_url(right_path, baudrate=115200, read_timeout=1.0) as serial_right, + Serial.from_url(serial_pair.left, baudrate=115200, read_timeout=1.0) as left, + Serial.from_url(serial_pair.right, baudrate=115200, read_timeout=1.0) as right, ): - # Write 5 bytes from one side - data = b"hello" - serial_left.write(data) + left.write(b"hello") with measure_time() as elapsed: - # Try to read 5 bytes (matching what we wrote) - result = serial_right.read(5) + result = right.read(5) - # Should return 5 bytes immediately - assert result == data - # Should have taken much less than 1.0 seconds + assert result == b"hello" assert elapsed() < 0.2 -def test_sync_readexactly_partial_timeout( - sync_transport_pair: tuple[str, str], -) -> None: +def test_sync_readexactly_partial_timeout(serial_pair: SerialPair) -> None: """Test that readexactly(10) with only 5 bytes raises IncompleteReadError.""" - left_path, right_path = sync_transport_pair - with ( - serial_for_url(left_path, baudrate=115200, read_timeout=0.5) as serial_left, - serial_for_url(right_path, baudrate=115200, read_timeout=0.5) as serial_right, + Serial.from_url(serial_pair.left, baudrate=115200, read_timeout=0.5) as left, + Serial.from_url(serial_pair.right, baudrate=115200, read_timeout=0.5) as right, ): - serial_left.write(b"hello") + left.write(b"hello") with measure_time() as elapsed: with pytest.raises(IncompleteReadError) as exc_info: - serial_right.readexactly(10) + right.readexactly(10) assert exc_info.value.partial == b"hello" - assert 0.5 <= elapsed() < 1.0 - + assert elapsed() == pytest.approx(0.5, abs=0.1) -def test_socket_connect_timeout() -> None: - """Test that connect_timeout is respected by SocketSerial.""" - # We use a non-routable IP to trigger a timeout (TEST-NET-1) - # 192.0.2.1 is reserved for documentation and shouldn't be reachable - url = "socket://192.0.2.1:1234" - with measure_time() as elapsed: - with pytest.raises((OSError, TimeoutError)): - # connect_timeout is passed to SocketSerial constructor via kwargs - with serial_for_url(url, baudrate=115200, connect_timeout=0.2): - pass - - # Should have timed out after ~0.2s - # Note: On some systems, "no route to host" might return instantly, - # so we primarily check that it didn't hang forever. - assert elapsed() < 1.0 - - -def test_sync_write_timeout(sync_transport_pair: tuple[str, str]) -> None: +@pytest.mark.skip_backends("socket") +def test_sync_write_timeout(serial_pair: SerialPair) -> None: """Test that write timeout works when buffer is full.""" - left_path, _ = sync_transport_pair - - if left_path.startswith("socket://"): - pytest.skip("Write timeout test is not applicable to socket transport") - with serial_for_url(left_path, baudrate=9600, write_timeout=0.1) as serial: + with Serial.from_url(serial_pair.left, baudrate=9600, write_timeout=0.1) as serial: data = b"x" * 1024 with pytest.raises(TimeoutError): for _ in range(1000): serial.write(data) + + +# --- Adapter-pair-specific tests --- +# These tests require physical adapter pairs (com0com, real hardware) +# and verify cross-port behavior that virtual backends can't emulate. + + +@pytest.mark.require_backends("adapter") +def test_dtr_cts(serial_pair: SerialPair) -> None: + """Test that DTR on one side controls CTS on the other.""" + + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + left.set_modem_pins(rts=False) + right.set_modem_pins(rts=False) + + left.set_modem_pins(dtr=True) + time.sleep(0.05) + assert right.get_modem_pins().cts is PinState.HIGH + + right.set_modem_pins(dtr=True) + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + left.set_modem_pins(dtr=False) + time.sleep(0.05) + assert right.get_modem_pins().cts is PinState.LOW + + right.set_modem_pins(dtr=False) + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.LOW + + +@pytest.mark.require_backends("adapter") +def test_deprecated_dtr_cts(serial_pair: SerialPair) -> None: + """Test DTR/CTS cross-port behavior via deprecated property aliases.""" + + with ( + Serial.from_url(serial_pair.left, baudrate=115200) as left, + Serial.from_url(serial_pair.right, baudrate=115200) as right, + ): + left.set_modem_pins(rts=False) + right.set_modem_pins(rts=False) + + left.dtr = True + time.sleep(0.05) + assert right.get_modem_pins().cts is PinState.HIGH + + right.dtr = True + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + left.dtr = False + time.sleep(0.05) + assert right.get_modem_pins().cts is PinState.LOW + + right.dtr = False + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.LOW + + +def test_fast_open_close(serial_pair: SerialPair) -> None: + """Test quickly opening and closing a port.""" + message = b"Fast write and close test" + + with Serial.from_url(serial_pair.left, baudrate=115200) as left: + with Serial.from_url(serial_pair.right, baudrate=115200) as right: + right.write(message) + right.flush() + + assert left.readexactly(len(message)) == message + + +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +def test_deassert_on_open(serial_pair: SerialPair) -> None: + """Test DTR/CTS deassertion on open.""" + + with Serial.from_url(serial_pair.left, baudrate=115200) as left: + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtsdtr_on_open=PinState.HIGH, + rtsdtr_on_close=PinState.HIGH, + ) as right: + right.set_modem_pins(dtr=True) + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + # rtsdtr_on_close=HIGH keeps DTR asserted + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtsdtr_on_open=PinState.LOW, + rtsdtr_on_close=PinState.HIGH, + ) as right: + # rtsdtr_on_open=LOW deasserts DTR + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.LOW + right.set_modem_pins(dtr=True) + + # rtsdtr_on_close=HIGH keeps DTR asserted + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +def test_hang_up_on_close(serial_pair: SerialPair) -> None: + """Test DTR/CTS hang up on close.""" + + with Serial.from_url(serial_pair.left, baudrate=115200) as left: + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.HIGH, + rtsdtr_on_open=PinState.HIGH, + ) as right: + right.set_modem_pins(dtr=True) + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.HIGH, + rtsdtr_on_open=PinState.HIGH, + ) as right: + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtsdtr_on_close=PinState.LOW, + rtsdtr_on_open=PinState.HIGH, + ) as right: + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.LOW + + +@pytest.mark.skipif(sys.platform == "win32", reason="CloseHandle resets modem signals") +@pytest.mark.require_backends("adapter") +@pytest.mark.parametrize( + ("rtscts", "rtsdtr_on_open", "expected_state"), + [ + (False, PinState.HIGH, PinState.HIGH), + (False, PinState.LOW, PinState.LOW), + (True, PinState.HIGH, PinState.HIGH), + (True, PinState.LOW, PinState.LOW), + ], +) +def test_deassert_on_open_with_rtscts( + serial_pair: SerialPair, + rtscts: bool, + rtsdtr_on_open: PinState, + expected_state: PinState, +) -> None: + """Test interaction of rtsdtr_on_open with rtscts.""" + + with Serial.from_url(serial_pair.left, baudrate=115200) as left: + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtscts=False, + rtsdtr_on_open=PinState.HIGH, + rtsdtr_on_close=PinState.HIGH, + ) as right: + right.set_modem_pins(dtr=True) + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + time.sleep(0.05) + assert left.get_modem_pins().cts is PinState.HIGH + + with Serial.from_url( + serial_pair.right, + baudrate=115200, + rtscts=rtscts, + rtsdtr_on_open=rtsdtr_on_open, + ): + time.sleep(0.05) + assert left.get_modem_pins().cts is expected_state + + +@pytest.mark.require_backends("adapter") +def test_write_timeout_cts_held(serial_pair: SerialPair) -> None: + """Test that write timeout fires when CTS is deasserted (flow control hold).""" + + with Serial.from_url(serial_pair.right, baudrate=9600) as right: + right.set_modem_pins(dtr=False) + time.sleep(0.1) + + with Serial.from_url( + serial_pair.left, + baudrate=9600, + rtscts=True, + write_timeout=0.5, + ) as left: + with measure_time() as elapsed: + with pytest.raises(TimeoutError): + left.write(b"x" * 1024) + + assert elapsed() == pytest.approx(0.5, abs=0.2)