Skip to content

CheapNud/CheapSerial

Repository files navigation

CheapSerial

A no-nonsense serial port library for .NET that fixes what Microsoft won't: async operations that actually respect timeouts and cancellation tokens.

Why Does This Exist?

Because System.IO.Ports.SerialPort has been fundamentally broken since .NET Framework 2.0, and Microsoft's official position is essentially "¯\_(ツ)_/¯".

The Problem: A 15+ Year Old Confession

Deep in the .NET Runtime source code (SerialStream.cs, lines 960-966), there's a developer comment that reads like a confession at a support group:

// I'm not sure what we can do here after an async operation with infinite
// timeout returns with no data. From a purist point of view we should
// somehow restart the read operation but we are not in a position to do so
// (and frankly that may not necessarily be the right thing to do here)
// I think the best option in this (almost impossible to run into) situation
// is to throw some sort of IOException.

Let's decode this masterpiece of corporate resignation:

  • "I'm not sure what we can do" - We have no idea how to fix this
  • "we should somehow restart the read operation but we are not in a position to do so" - We wrote ourselves into a corner with the Windows API
  • "frankly that may not necessarily be the right thing to do" - Even if we could fix it, we probably shouldn't
  • "throw some sort of IOException and call it a day" - So we're just gonna yeet an exception and hope nobody notices

Spoiler alert: People noticed. For 15+ years.

What Actually Happens

When you call ReadAsync() or BeginRead() on SerialPort.BaseStream, here's the sequence of events:

public override IAsyncResult BeginRead(byte[] array, int offset, int numBytes, 
    AsyncCallback userCallback, object stateObject)
{
    int oldTimeout = ReadTimeout;
    ReadTimeout = SerialPort.InfiniteTimeout;  // 👈 YOUR TIMEOUT? GONE.
    IAsyncResult result;
    try
    {
        result = BeginReadCore(array, offset, numBytes, userCallback, stateObject);
    }
    finally
    {
        ReadTimeout = oldTimeout;  // 👈 TOO LATE, BUDDY
    }
    return result;
}

The sequence of betrayal:

  1. ✅ Carefully saves your configured timeout
  2. 💥 Immediately overwrites it with infinite timeout
  3. 🚀 Starts the async operation (which is now using infinite timeout)
  4. 🎭 Restores your original timeout (after it's completely useless)
  5. 😇 Returns like nothing happened

Your timeout is like that friend who says they'll be there in 5 minutes but shows up 3 hours later - technically present, but completely useless.

Microsoft's Official Stance

From dotnet/iot Issue #1832, where they proposed building an entirely new serial port API:

"it is very difficult validating the behavior of the serial port class because it strictly depends on the serial port driver and the connected hardware. For this reason it is preferable not to touch the existing implementation which could cause serious breaking changes. Instead, it is better providing a brand new implementation exposing a modern API and flexible API."

Translation: "We can't fix it without breaking everyone's workarounds for our broken implementation, so we're just gonna build a new one... eventually... maybe... don't hold your breath."

The Bugs You'll Hit

Based on extensive community reporting across multiple GitHub issues:

  1. ReadAsync() ignores ReadTimeout (#28968)

    • Always uses infinite timeout internally
    • Your configured timeout is purely decorative
  2. ReadAsync() ignores CancellationToken (#28968)

    • Most of the time, anyway
    • Sometimes it works, sometimes it doesn't - depends on the phase of the moon
  3. FlushAsync() corrupts stream state (#35545)

    • Subsequent reads timeout or block indefinitely
    • Makes the API incompatible with modern .NET Core stream wrappers
  4. Missing modern async methods (#54575)

    • No Memory<byte> or Span<byte> overloads
    • Stuck in the .NET Framework 2.0 era with byte[]
  5. Leaked tasks and buffers

    • Using Task.WhenAny() with ReadAsync() leaves tasks hanging forever
    • Memory leaks are a feature, not a bug
  6. Breaking changes between versions (#80079)

    • .NET 7 changed timeout exceptions from TimeoutException to IOException
    • Because consistency is overrated

For more issues please read (this)

Why Microsoft Won't Fix It

The hardware dependency problem is real - serial port behavior varies wildly across:

  • Different USB-to-serial adapters
  • Native COM ports vs virtualized ports
  • Windows vs Linux drivers
  • Different versions of Windows

Microsoft's attempted fix in .NET 7 accidentally broke backward compatibility, proving their point: touching SerialPort is like defusing a bomb - one wrong move and everything explodes.

So they're stuck in a catch-22:

  • Can't fix it without breaking existing code
  • Can't leave it broken because it's... broken
  • Solution: Propose a new API and hope the problem goes away

Meanwhile, the community has been building replacements for over a decade:

  • SerialPortStream - Full reimplementation
  • SerialPortNet - Alternative implementation
  • CheapSerial - Lightweight, focused fix for async operations

The Solution: CheapSerial

CheapSerial doesn't try to replace the entire SerialPort stack. It provides a thin wrapper that:

  • Actually respects your timeout settings (revolutionary, I know)
  • Actually respects cancellation tokens (works every time, guaranteed)
  • Doesn't leak memory or tasks (proper async/await hygiene)
  • Uses modern async APIs (Memory<byte>, Span<byte>, ValueTask)
  • Won't throw cryptic IOExceptions (meaningful error messages)
  • Handles edge cases properly (no "impossible to run into" situations)

Installation

dotnet add package CheapSerial

Or via NuGet Package Manager:

Install-Package CheapSerial

Quick Start

The Broken Way (System.IO.Ports)

using System.IO.Ports;

var port = new SerialPort("COM3", 9600);
port.ReadTimeout = 5000; // Set a 5 second timeout
port.Open();

var buffer = new byte[1024];

// This will IGNORE your 5 second timeout and use infinite timeout instead
// Good luck debugging why your app hangs!
await port.BaseStream.ReadAsync(buffer, 0, buffer.Length);

The Fixed Way (CheapSerial)

using CheapSerial;

using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();

var buffer = new byte[1024];

// This will ACTUALLY use your timeout - what a concept!
var bytesRead = await port.ReadAsync(buffer, timeout: TimeSpan.FromSeconds(5));

Core Features

Proper Timeout Support

// Timeouts that actually work - groundbreaking technology
var bytesRead = await port.ReadAsync(
    buffer, 
    timeout: TimeSpan.FromSeconds(5));

if (bytesRead == 0)
{
    // Will throw TimeoutException after 5 seconds, not 24 days
    throw new TimeoutException("Read operation timed out");
}

Proper Cancellation Support

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));

try
{
    // Will ACTUALLY cancel when the token fires
    // Not "most of the time" - ALL of the time
    var bytesRead = await port.ReadAsync(buffer, cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
    // Clean cancellation, no leaked resources
    Console.WriteLine("Operation cancelled successfully");
}

Modern Memory APIs

// Use Memory<byte> for better performance and less allocations
Memory<byte> buffer = new byte[1024];
var bytesRead = await port.ReadAsync(buffer);

// Or Span<byte> for synchronous operations (zero allocation!)
Span<byte> span = stackalloc byte[256];
var read = port.Read(span);

Async/Await Done Right

// Properly cancellable async operations
await using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();

// Read with timeout and cancellation
var data = await port.ReadAsync(buffer, 
    timeout: TimeSpan.FromSeconds(5),
    cancellationToken: cancellationToken);

// Flush actually works (doesn't corrupt stream state)
await port.FlushAsync();

No More "Impossible to Run Into" Situations

Unlike the built-in implementation that throws IOException with a shrug when edge cases occur, CheapSerial:

  • Handles timeout edge cases properly
  • Provides meaningful exception messages
  • Doesn't leave you wondering what went wrong
  • Actually tells you what to do about it

API Reference

CheapSerialPort

public class CheapSerialPort : IDisposable, IAsyncDisposable
{
    // Construction
    public CheapSerialPort(string portName, int baudRate, 
        Parity parity = Parity.None, 
        int dataBits = 8, 
        StopBits stopBits = StopBits.One);
    
    // Async operations with ACTUAL timeout support
    public Task OpenAsync(CancellationToken cancellationToken = default);
    
    public ValueTask<int> ReadAsync(
        Memory<byte> buffer, 
        TimeSpan? timeout = null, 
        CancellationToken cancellationToken = default);
    
    public ValueTask WriteAsync(
        ReadOnlyMemory<byte> buffer, 
        TimeSpan? timeout = null,
        CancellationToken cancellationToken = default);
    
    public ValueTask FlushAsync(CancellationToken cancellationToken = default);
    
    // Synchronous operations
    public int Read(Span<byte> buffer, TimeSpan? timeout = null);
    public void Write(ReadOnlySpan<byte> buffer, TimeSpan? timeout = null);
    
    // Configuration
    public int BaudRate { get; set; }
    public Parity Parity { get; set; }
    public int DataBits { get; set; }
    public StopBits StopBits { get; set; }
    public Handshake Handshake { get; set; }
    
    // Timeouts (that actually work!)
    public TimeSpan ReadTimeout { get; set; }
    public TimeSpan WriteTimeout { get; set; }
    
    // Port status
    public bool IsOpen { get; }
    public int BytesToRead { get; }
    public int BytesToWrite { get; }
    
    // Cleanup
    public void Close();
    public void Dispose();
    public ValueTask DisposeAsync();
}

Advanced Usage

Timeout Strategies

using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();

// Per-operation timeout (overrides default)
var data = await port.ReadAsync(buffer, timeout: TimeSpan.FromSeconds(5));

// Or set a default timeout for all operations
port.ReadTimeout = TimeSpan.FromSeconds(10);
var data2 = await port.ReadAsync(buffer); // Uses 10 second timeout

// Infinite timeout (actually infinite, not "24 days")
var data3 = await port.ReadAsync(buffer, timeout: Timeout.InfiniteTimeSpan);

Combining Timeout and Cancellation

using var cts = new CancellationTokenSource();

// Whichever comes first wins
var data = await port.ReadAsync(
    buffer,
    timeout: TimeSpan.FromSeconds(30),      // 30 second timeout
    cancellationToken: cts.Token);           // Or manual cancellation

Proper Resource Cleanup

// IAsyncDisposable support
await using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();

// Use the port...

// Automatically cleaned up on scope exit

Migration Guide

From System.IO.Ports.SerialPort

// OLD (broken)
var port = new SerialPort("COM3", 9600);
port.ReadTimeout = 5000;
port.Open();
var buffer = new byte[1024];
int bytesRead = await port.BaseStream.ReadAsync(buffer, 0, buffer.Length);

// NEW (fixed)
using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();
var buffer = new byte[1024];
int bytesRead = await port.ReadAsync(buffer, timeout: TimeSpan.FromSeconds(5));

From SerialPortStream

// SerialPortStream is great but heavier - CheapSerial is lighter
// SerialPortStream: Full reimplementation
// CheapSerial: Thin wrapper focusing on async fixes

// Both work well, choose based on your needs:
// - Need full control? → SerialPortStream
// - Just need working async? → CheapSerial

Performance

CheapSerial uses modern .NET performance features:

  • ValueTask<T> for reduced allocations on synchronous completions
  • Memory<T> and Span<T> for zero-copy operations
  • Proper async state machine implementation
  • No unnecessary thread pool usage

Benchmarks show comparable or better performance than System.IO.Ports.SerialPort for async operations, with the added benefit of actually working correctly.

Requirements

  • .NET 8.0 or higher
  • Windows (Linux support planned)
  • Physical or virtual COM port

Frequently Asked Questions

Why not just use SerialPortStream?

SerialPortStream is excellent and provides a complete reimplementation of the entire serial port stack. CheapSerial takes a different approach:

  • SerialPortStream: Full replacement, handles everything from scratch
  • CheapSerial: Thin wrapper, focuses specifically on fixing async operations

Choose CheapSerial if you:

  • Just need working async/await
  • Want minimal dependencies
  • Prefer a lightweight solution

Choose SerialPortStream if you:

  • Need complete control over serial port behavior
  • Want extensive configuration options
  • Need proven stability across many edge cases

Does this work on Linux?

Windows only for now. Linux support is planned for a future release.

Will Microsoft ever fix the built-in SerialPort?

Their official position (Issue #1832) is to build a new API from scratch rather than fix the existing one. So probably not.

Contributing

Contributions welcome! Please read CONTRIBUTING.md first.

Areas where help is especially appreciated:

  • Linux/macOS support
  • Additional protocol implementations
  • Performance optimizations
  • Documentation improvements

License

MIT License - see LICENSE.txt

Acknowledgments

  • The .NET team for their candid comments in the source code
  • The community for documenting these issues for over a decade
  • The SerialPortStream project for proving it can be done better

Further Reading


A Final Thought

You might be wondering: "Couldn't Microsoft have just written a thin wrapper like this instead of leaving it broken for 15+ years?"

Yes. Yes they could have.

In fact, this entire library is about 500 lines of code. The .NET team has written blog posts longer than that.

But hey, at least they left us that comment. It's like leaving a note that says "Sorry, couldn't figure out the plumbing, good luck!" while collecting rent on the house.


CheapSerial - Because serial port communication shouldn't be this hard in 2025.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages