A no-nonsense serial port library for .NET that fixes what Microsoft won't: async operations that actually respect timeouts and cancellation tokens.
Because System.IO.Ports.SerialPort has been fundamentally broken since .NET Framework 2.0, and Microsoft's official position is essentially "¯\_(ツ)_/¯".
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.
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:
- ✅ Carefully saves your configured timeout
- 💥 Immediately overwrites it with infinite timeout
- 🚀 Starts the async operation (which is now using infinite timeout)
- 🎭 Restores your original timeout (after it's completely useless)
- 😇 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.
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."
Based on extensive community reporting across multiple GitHub issues:
-
ReadAsync()ignoresReadTimeout(#28968)- Always uses infinite timeout internally
- Your configured timeout is purely decorative
-
ReadAsync()ignoresCancellationToken(#28968)- Most of the time, anyway
- Sometimes it works, sometimes it doesn't - depends on the phase of the moon
-
FlushAsync()corrupts stream state (#35545)- Subsequent reads timeout or block indefinitely
- Makes the API incompatible with modern .NET Core stream wrappers
-
Missing modern async methods (#54575)
- No
Memory<byte>orSpan<byte>overloads - Stuck in the .NET Framework 2.0 era with
byte[]
- No
-
Leaked tasks and buffers
- Using
Task.WhenAny()withReadAsync()leaves tasks hanging forever - Memory leaks are a feature, not a bug
- Using
-
Breaking changes between versions (#80079)
- .NET 7 changed timeout exceptions from
TimeoutExceptiontoIOException - Because consistency is overrated
- .NET 7 changed timeout exceptions from
For more issues please read (this)
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
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)
dotnet add package CheapSerialOr via NuGet Package Manager:
Install-Package CheapSerial
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);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));// 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");
}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");
}// 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);// 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();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
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();
}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);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// IAsyncDisposable support
await using var port = new CheapSerialPort("COM3", 9600);
await port.OpenAsync();
// Use the port...
// Automatically cleaned up on scope exit// 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));// 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? → CheapSerialCheapSerial uses modern .NET performance features:
ValueTask<T>for reduced allocations on synchronous completionsMemory<T>andSpan<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.
- .NET 8.0 or higher
- Windows (Linux support planned)
- Physical or virtual COM port
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
Windows only for now. Linux support is planned for a future release.
Their official position (Issue #1832) is to build a new API from scratch rather than fix the existing one. So probably not.
Contributions welcome! Please read CONTRIBUTING.md first.
Areas where help is especially appreciated:
- Linux/macOS support
- Additional protocol implementations
- Performance optimizations
- Documentation improvements
MIT License - see LICENSE.txt
- 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
- Microsoft's GitHub Issue #1832 - Proposal for new SerialPort API
- Issue #28968 - ReadAsync ignoring timeouts
- Issue #35545 - FlushAsync corruption
- Issue #54575 - Missing modern async methods
- SerialStream.cs source - See the confession yourself
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.