Skip to content

SerialPort on Unix: after a device error (e.g. USB unplug), Write with infinite WriteTimeout hangs forever and Close/Dispose spins forever #135369

Description

@fastcat

Description

On Linux, if the serial device fails while I/O is pending (for example, a USB-serial adapter is unplugged), SerialStream's I/O loop exits. It goes through the error.HasValue or POLLNVAL/POLLERR branch, fails the pending requests, and breaks. Neither path clears _ioLoop, so it still points to the finished task.

From then on, that SerialPort instance is permanently wedged:

  1. Write hangs forever. WriteAsync queues a request and calls EnsureIOLoopRunning. That method only starts a loop when _ioLoop == null, so nothing ever processes the request. With the default WriteTimeout (InfiniteTimeout), Write blocks in GetAwaiter().GetResult() forever.
  2. Close/Dispose hang forever. SerialPort.Dispose calls SerialStream.Flush() before Close(). Flush spins until the write queue is empty, which never happens. This occurs even if the writer used a finite WriteTimeout. Cancelling marks the request completed, but only the dead I/O loop removes completed requests from the queue (RemoveCompletedTasks). The cleanup in SerialStream.Dispose (FinishPendingIORequests, releasing the handle) comes after the Flush call and is never reached. The device fd also stays open.

The only way to recover is to restart the process.

Reproduction Steps

  1. Attach a USB-serial adapter on Linux. Nothing needs to be connected to its other end.
  2. Run the following, then unplug the adapter while it is writing:
using System.IO.Ports;

var port = new SerialPort(args[0], 115200); // WriteTimeout left at default (infinite)
port.Open();

var writer = new Thread(() =>
{
    while (true)
    {
        try { port.Write(new byte[] { 0x55 }, 0, 1); }
        catch (Exception e) { Console.WriteLine($"write error: {e.Message}"); }
        Thread.Sleep(50);
    }
}) { IsBackground = true };
writer.Start();

Console.WriteLine("Unplug the adapter, then press Enter");
Console.ReadLine();
Console.WriteLine("Closing...");
port.Close(); // never returns
Console.WriteLine("Closed");

Expected behavior

After the device fails, each Write fails with an exception. Close/Dispose complete and release the handle.

Actual behavior

One write fails with ObjectDisposedException: The port is closed. (or an IOException, if poll itself failed). After that, the next Write blocks forever and Close() never returns.

A live process stuck in this state showed:

  • _ioLoop was non-null and its task state was RanToCompletion.
  • _writeQueue.Count == 1.
  • No thread was running IOLoop.
  • One thread was blocked in SerialStream.Write → TaskAwaiter.GetResult.
  • A second thread was in SerialPort.Dispose → SerialStream.Flush, in the SpinWait loop, waking about 1000 times/s.
  • The fd still pointed at /dev/ttyUSB0 (deleted).

Regression?

Not that we know of. The same exit paths exist on main.

Known Workarounds

None within the process. A finite WriteTimeout frees the writer, but Close/Dispose still hang. Calling code can avoid closing a port that has hit this state, at the cost of leaking the fd, or isolate serial I/O in a child process that can be killed.

Configuration

  • Linux x64 (Ubuntu), USB-serial adapter (/dev/ttyUSB0)
  • .NET 9.0.17 runtime, NativeAOT, System.IO.Ports 10.0.0
  • Linux-specific: the bug is in SerialStream.Unix.cs

Other information

Possible fix: on the two error exits in IOLoop, set _ioLoop = null under _ioLoopLock (as the idle-timeout exit already does). Alternatively, EnsureIOLoopRunning could restart the loop when _ioLoop.IsCompleted. Either way, a later write would start a new loop, which would see the error again and fail the request instead of stranding it.

Related issues:

  • System.IO.Ports.SerialPort: Under Linux, closing the port while writing cause a deadlock. #100690, "Under Linux, closing the port while writing cause a deadlock". This has the same end state (a write queued with no I/O loop to service it) but a different trigger: the race between Dispose and EnsureIOLoopRunning. Here no concurrent Close is needed; a device error alone strands the loop. That issue's suggested workaround (set a WriteTimeout) doesn't help with the Close/Flush hang described here.
  • SerialPort turns stale if device is unplugged on linux #121627, "SerialPort turns stale if device is unplugged on linux". Same scenario (Linux unplug). It reports reads and writes silently going nowhere and IsOpen staying true. This issue may be the root cause of some of that behavior, or a more severe form of it.

Related PR: #134854 ("SerialPort.Unix: use UnixHandleAsyncContext", draft) replaces the poll loop with epoll for .NET 12+ Unix targets. Reading the diff, it looks like it would fix this:

  • There is no write queue or I/O loop.
  • An epoll error event wakes pending writes, which then fail with EIO.
  • Flush becomes a zero-byte write that completes after earlier writes fail.

However, older targets keep the existing loop (moved to SerialStream.UnixPollLoop.cs), and this bug is still in it. A targeted fix there would still be needed for consumers on older target frameworks, and may be easier to backport.

Activity

  1. dotnet-policy-service commented on Oct 7, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-io-ports
    See info in area-owners.md if you want to be subscribed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions