Skip to content

Serial fd leak in SerialConnection.connect() when connection times out #95

Description

@SpokaneMesh

Bug

SerialConnection.connect() in serial_cx.py leaks file descriptors when a connection attempt times out. Each failed attempt leaves the serial port fd and two internal pipe fds open. In a reconnect loop (e.g. RemoteTerm's 5-second retry), this exhausts the process fd limit (typically 1024) over time, causing both serial reconnection and HTTP request handling to fail with OSError: [Errno 24] Too many open files.

Root cause

# serial_cx.py — current code
async def connect(self, timeout: float = 10.0):
    self._connected_event.clear()

    loop = asyncio.get_running_loop()
    await serial_asyncio.create_serial_connection(   # opens serial fd + 2 pipe fds
        loop,
        lambda: self.MCSerialClientProtocol(self),
        self.port,
        baudrate=self.baudrate,
    )

    await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
    # ^ if this raises TimeoutError, nothing above gets closed

When _connected_event is not set within timeout seconds (radio connected at USB level but not responding to the protocol handshake), asyncio.wait_for raises TimeoutError. Because self.transport is only set inside the connection_made protocol callback — which may not have fired yet — disconnect() is a no-op (self.transport is None), and the fds opened by create_serial_connection are never released.

Proposed fix

Capture the transport directly from create_serial_connection and close it on any exception:

async def connect(self, timeout: float = 10.0):
    self._connected_event.clear()

    loop = asyncio.get_running_loop()
    transport, _ = await serial_asyncio.create_serial_connection(
        loop,
        lambda: self.MCSerialClientProtocol(self),
        self.port,
        baudrate=self.baudrate,
    )
    try:
        await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
    except Exception:
        transport.close()
        raise

    logger.info("Serial Connection started")
    return self.port

How to reproduce

  1. Connect a MeshCore radio via USB
  2. Start a process that connects via MeshCore.create_serial() and retries on failure
  3. Unplug the radio (or use a device that enumerates but doesn't respond to the handshake)
  4. Watch lsof -p <pid> | grep ttyACM | wc -l climb by ~3 every retry cycle
  5. After ~340 retries the process hits 1024 open fds and all I/O fails

Environment

  • meshcore 2.3.7
  • Python 3.13
  • Linux / serial over USB (ACM device)
  • Discovered via RemoteTerm for MeshCore running as a long-lived systemd service

Happy to submit a PR with the fix if that's helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions