serial: pipe:vrio named-pipe transport, no com0com needed

NamedPipeTransport connects as a client to vRIO's \\.\pipe\vrio (the
DOSBox-X fork's role) and speaks the shared typed-frame contract
(PipeFraming: 0x00 data / 0x01 modem lines, null-modem crossed). The COM
path's DTR reset pulse is replayed in-band on connect. Peer disconnects
and framing violations surface as the 0-byte transport-closed read the
link already understands.

RioTransportFactory routes endpoint strings — pipe:name (vRIO's own
picker syntax) to the pipe transport, everything else to
SerialPortTransport — and is wired into RioCoordinator and all three
RioSerialMonitor modes, so profiles (RioComPort/DefaultRioComPort) and
the bench tools take pipe endpoints anywhere a COM name went.

Gotcha baked into the design: named pipes here have 0-byte buffers, so a
write blocks until the peer reads it, and vRIO also writes its lines
frame before reading — the on-connect pulse frames are therefore queued
as overlapped writes (pipe writes drain in issue order, preserving the
edge positions) instead of blocking the constructor into a mutual
write-first deadlock.

Verified end-to-end against the real VRioDevice + VRioPipeService over
\\.\pipe\vrio: version 4.2 + check replies, 137 analog polls, lamp
commands ACKed, zero framing errors. 322 tests green, both flavors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-30 10:14:13 -05:00
co-authored by Claude Fable 5
parent 3512c89dca
commit 9cca7c77bd
11 changed files with 670 additions and 9 deletions
+13
View File
@@ -52,3 +52,16 @@ auto-switch), and the HID report packer that matches the driver's wire format.
Remaining work is **on-cabinet** (real RIO serial/axis/plasma/auto-switch
verification) plus packaging (Phase 6) and the profile editor + overlay
generator (Phase 7). See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
## Testing without hardware: vRIO over a named pipe
The [vRIO](https://gitea.mysticmachines.com/VWE/VRIO) device emulator can stand
in for the real board with no com0com pair: anywhere a COM port name is
configured — a profile's `RioComPort`, the app-wide `DefaultRioComPort`, or the
`RioSerialMonitor` `[port]` argument — the endpoint `pipe:vrio` connects to
vRIO's `\\.\pipe\vrio` instead (vRIO must have its pipe endpoint open). Serial
bytes and modem lines (including the DTR reset pulse on open) travel as typed
frames over the pipe; the contract lives in
[`src/RioJoy.Core/Serial/PipeFraming.cs`](src/RioJoy.Core/Serial/PipeFraming.cs)
on this side and vRIO's `PipeFraming.cs` / the DOSBox-X fork's
`serialnamedpipe.h` on the others.
+5 -1
View File
@@ -7,7 +7,11 @@ namespace RioJoy.Core.Profiles;
/// </summary>
public sealed class AppConfig
{
/// <summary>Default RIO COM port when a profile doesn't specify one.</summary>
/// <summary>
/// Default RIO endpoint when a profile doesn't specify one: a COM port
/// name ("COM1"), or "pipe:vrio" to reach the vRIO emulator over its
/// named pipe (no com0com pair; see RioTransportFactory).
/// </summary>
public string DefaultRioComPort { get; set; } = "COM1";
/// <summary>
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class RioProfile
/// <summary>Display name (unique within a library).</summary>
public string Name { get; set; } = "Unnamed";
/// <summary>RIO serial port (e.g. "COM3"); null = use the app default.</summary>
/// <summary>RIO endpoint ("COM3", or "pipe:vrio" for the emulator); null = use the app default.</summary>
public string? RioComPort { get; set; }
/// <summary>Plasma/VFD serial port; null = use the app default or none.</summary>
@@ -0,0 +1,174 @@
using System.IO.Pipes;
namespace RioJoy.Core.Serial;
/// <summary>
/// <see cref="IRioTransport"/> over a local named pipe, for driving the vRIO
/// device emulator without com0com. vRIO serves <c>\\.\pipe\vrio</c> for the
/// whole app lifetime (the device is always present); we connect as the
/// client — the same role the DOSBox-X fork's <c>namedpipe</c> serial backend
/// plays. Framing per <see cref="PipeFraming"/>: writes wrap in data frames,
/// reads unwrap them, and modem lines travel in-band as lines frames.
///
/// <para>On connect this replays <see cref="SerialPortTransport"/>'s board
/// reset over the pipe: a lines frame asserting DTR, the
/// <see cref="SerialPortTransport.DtrPulse"/> hold, then a lines frame
/// releasing it (RTS stays low throughout, matching the COM path's
/// RtsEnable default). The in-band frames keep the pulse's exact position
/// in the byte stream, which is why the contract multiplexes control onto
/// the data pipe instead of using a second one.</para>
///
/// <para>The peer's lines frames (vRIO asserts DTR+RTS on connect — "board
/// present") are decoded and tracked but nothing consumes them: the COM path
/// runs Handshake.None and never reads DSR/CTS either. A peer disconnect or
/// framing violation surfaces as a 0-byte read — the transport-closed signal
/// <see cref="RioSerialLink"/> already understands, same as a yanked
/// adapter.</para>
///
/// <para>Writes are not paced: host→RIO traffic is tiny stop-and-wait
/// commands, and the com0com path never emulated baud timing on this
/// direction either. (vRIO paces its own RIO→host TX, where the analog
/// stream would otherwise burst.)</para>
/// </summary>
public sealed class NamedPipeTransport : IRioTransport
{
/// <summary>How long to wait for the pipe server before giving up.</summary>
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(2);
private readonly NamedPipeClientStream _pipe;
private readonly string _pipeName;
private readonly PipeFrameDecoder _decoder = new();
// Decoded data bytes not yet handed to a reader (a pipe read may carry
// more payload than the caller's buffer holds). Only the receive loop
// touches these — IRioTransport has a single reader by contract.
private readonly Queue<byte> _decoded = new();
private readonly byte[] _raw = new byte[512];
private volatile bool _closed;
private byte _peerLines;
/// <param name="pipeName">Pipe name without the <c>\\.\pipe\</c> prefix, e.g. "vrio".</param>
/// <param name="connectTimeout">Server wait; default <see cref="DefaultConnectTimeout"/>.</param>
public NamedPipeTransport(string pipeName, TimeSpan? connectTimeout = null)
{
if (string.IsNullOrWhiteSpace(pipeName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(pipeName));
_pipeName = pipeName;
_decoder.Data += (buffer, count) =>
{
for (int i = 0; i < count; i++)
_decoded.Enqueue(buffer[i]);
};
_decoder.Lines += lines => _peerLines = lines;
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
_pipe.Connect((int)(connectTimeout ?? DefaultConnectTimeout).TotalMilliseconds);
}
catch (TimeoutException)
{
_pipe.Dispose();
throw new TimeoutException($@"No pipe server at \\.\pipe\{pipeName} — is vRIO running?");
}
catch
{
_pipe.Dispose();
throw;
}
// DTR reset pulse, in-band: assert, hold, release (port of the COM
// path's SETDTR/CLRDTR — see SerialPortTransport and PROTOCOL.md §1).
//
// Queued as overlapped writes, never awaited here: the pipe's buffers
// default to 0 bytes, so a synchronous write blocks until the peer
// reads it — and vRIO's own on-connect lines frame is written before
// it starts reading, a write-first/write-first deadlock if we blocked
// too. Pipe writes complete in issue order, so the assert→release
// edge keeps its exact position in the byte stream; both frames
// drain once the two readers are up. (When vRIO is already reading —
// the steady case — the 50 ms hold arrives in real time too; during
// the startup rendezvous the device may see a shorter pulse, which
// still carries both edges.)
try
{
byte[] assert = PipeFraming.EncodeLines(PipeFraming.LineDtr);
Observe(_pipe.WriteAsync(assert, 0, assert.Length, CancellationToken.None));
Thread.Sleep(SerialPortTransport.DtrPulse);
byte[] release = PipeFraming.EncodeLines(0);
Observe(_pipe.WriteAsync(release, 0, release.Length, CancellationToken.None));
}
catch
{
_pipe.Dispose();
throw;
}
}
// Swallow a queued write's eventual fault (e.g. the peer vanished before
// draining it) — on net40 an unobserved task exception kills the process.
private static void Observe(Task task) =>
task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.ExecuteSynchronously);
public string Description => $@"\\.\pipe\{_pipeName}";
/// <summary>The peer's last lines frame (bit0 its DTR → our DSR, bit1 its RTS → our CTS).</summary>
public byte PeerLines => _peerLines;
/// <summary>Why the peer's stream was rejected, or null (diagnostic; see <see cref="PipeFrameDecoder.Violation"/>).</summary>
public string? Violation => _decoder.Violation;
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
{
while (_decoded.Count == 0)
{
if (_closed)
return 0;
int n;
try
{
n = await _pipe.ReadAsync(_raw, 0, _raw.Length, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (_closed && ex is IOException or ObjectDisposedException)
{
return 0; // Dispose broke the pending read — normal teardown
}
if (n == 0)
{
_closed = true; // server went away — unplugged cable
return 0;
}
if (!_decoder.Feed(_raw, n))
{
_closed = true; // framing violation: not line noise on a pipe, drop the link
return 0;
}
}
int count = Math.Min(buffer.Length, _decoded.Count);
for (int i = 0; i < count; i++)
buffer[i] = _decoded.Dequeue();
return count;
}
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
// Empty input frames to an empty array; the 0-byte write is a no-op.
byte[] framed = PipeFraming.EncodeData(data);
return _pipe.WriteAsync(framed, 0, framed.Length, cancellationToken);
}
public void Dispose()
{
// Closing the handle aborts a pending overlapped read (the receive
// loop's read ignores cancellation, same as the COM path — teardown
// relies on this). Pipes have no FlushFileBuffers hang to guard
// against, so no grace-period dance like SerialPortTransport's.
_closed = true;
try { _pipe.Dispose(); }
catch (IOException) { }
}
}
+160
View File
@@ -0,0 +1,160 @@
namespace RioJoy.Core.Serial;
/// <summary>
/// The typed-frame contract for serial-over-named-pipe, shared with vRIO
/// (<c>VRio.Core/Device/PipeFraming.cs</c>) and the DOSBox-X fork's
/// <c>namedpipe</c> serial backend (<c>serialnamedpipe.h</c> is the contract's
/// source of truth on that side). A pipe is a plain byte stream, so serial
/// data and modem control lines are multiplexed as typed frames:
///
/// <code>
/// 0x00 &lt;len:u8&gt; &lt;len bytes&gt; serial data, len ≥ 1 (batching allowed)
/// 0x01 &lt;lines:u8&gt; the sender's OWN output lines (bit0 DTR,
/// bit1 RTS); the receiver applies the
/// null-modem cross: peer DTR → local DSR,
/// peer RTS → local CTS
/// </code>
///
/// Each side sends one lines frame immediately on connect; until it arrives
/// the peer's lines are assumed low, and a disconnect drops them low again.
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
/// bytes — so the receiver drops the connection instead of trying to resync.
/// </summary>
public static class PipeFraming
{
public const byte DataType = 0x00;
public const byte LinesType = 0x01;
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
public const byte LineDtr = 0x01;
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
public const byte LineRts = 0x02;
/// <summary>Largest payload one data frame can carry (u8 length).</summary>
public const int MaxDataPayload = byte.MaxValue;
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
/// <summary>
/// Wrap <paramref name="data"/> in data frames, chunking payloads longer
/// than <see cref="MaxDataPayload"/>. Empty input yields an empty array
/// (the contract forbids zero-length data frames).
/// </summary>
public static byte[] EncodeData(byte[] data)
{
if (data is null) throw new ArgumentNullException(nameof(data));
if (data.Length == 0)
return new byte[0]; // net40 has no Array.Empty
int chunks = (data.Length + MaxDataPayload - 1) / MaxDataPayload;
var framed = new byte[data.Length + chunks * 2];
int src = 0, dst = 0;
while (src < data.Length)
{
int len = Math.Min(MaxDataPayload, data.Length - src);
framed[dst++] = DataType;
framed[dst++] = (byte)len;
Array.Copy(data, src, framed, dst, len);
src += len;
dst += len;
}
return framed;
}
}
/// <summary>
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
/// get one <see cref="Data"/> event per complete data frame and one
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
/// at any byte boundary. A malformed stream (unknown type, zero-length data
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
/// <see cref="Violation"/> says why — drop the connection and
/// <see cref="Reset"/> before the next one.
/// </summary>
public sealed class PipeFrameDecoder
{
private enum State { Type, Length, Payload, Lines }
private readonly byte[] _payload = new byte[byte.MaxValue];
private State _state;
private int _fill, _length;
/// <summary>
/// A complete data frame's payload as (buffer, count). The buffer is
/// reused across frames — consume it synchronously.
/// </summary>
public event Action<byte[], int>? Data;
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
public event Action<byte>? Lines;
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
public string? Violation { get; private set; }
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
public void Reset()
{
_state = State.Type;
_fill = _length = 0;
Violation = null;
}
/// <summary>
/// Consume <paramref name="count"/> received bytes from
/// <paramref name="buffer"/>. Returns false when the stream violates the
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
/// keeps returning false until <see cref="Reset"/>.
/// </summary>
public bool Feed(byte[] buffer, int count)
{
if (Violation is not null)
return false;
for (int i = 0; i < count; i++)
{
byte b = buffer[i];
switch (_state)
{
case State.Type when b == PipeFraming.DataType:
_state = State.Length;
break;
case State.Type when b == PipeFraming.LinesType:
_state = State.Lines;
break;
case State.Type:
Violation = $"unknown frame type 0x{b:X2}";
return false;
case State.Length when b == 0:
Violation = "zero-length data frame";
return false;
case State.Length:
_length = b;
_fill = 0;
_state = State.Payload;
break;
case State.Payload:
_payload[_fill++] = b;
if (_fill == _length)
{
_state = State.Type;
Data?.Invoke(_payload, _length);
}
break;
case State.Lines:
_state = State.Type;
Lines?.Invoke(b);
break;
}
}
return true;
}
}
@@ -0,0 +1,33 @@
namespace RioJoy.Core.Serial;
/// <summary>
/// Opens the right <see cref="IRioTransport"/> for an endpoint string. A COM
/// port name ("COM3") opens a <see cref="SerialPortTransport"/>; a
/// <c>pipe:name</c> endpoint ("pipe:vrio") opens a
/// <see cref="NamedPipeTransport"/> to <c>\\.\pipe\name</c> — the same
/// endpoint syntax vRIO's own connection picker uses, so a profile can point
/// at the emulator with <c>RioComPort = "pipe:vrio"</c> and no com0com pair.
/// </summary>
public static class RioTransportFactory
{
/// <summary>Endpoint prefix selecting the named-pipe transport.</summary>
public const string PipeScheme = "pipe:";
/// <summary>True when <paramref name="endpoint"/> names a pipe rather than a COM port.</summary>
public static bool IsPipe(string endpoint) =>
endpoint is not null
&& endpoint.StartsWith(PipeScheme, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open <paramref name="endpoint"/>. <paramref name="baudRate"/> applies
/// to COM ports only — a pipe has no wire rate (the peer paces itself).
/// </summary>
public static IRioTransport Open(string endpoint, int baudRate = SerialPortTransport.BaudRate)
{
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(endpoint));
return IsPipe(endpoint)
? new NamedPipeTransport(endpoint.Substring(PipeScheme.Length))
: (IRioTransport)new SerialPortTransport(endpoint, baudRate);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ public sealed class RioCoordinator : IDisposable
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory
?? (port => new SerialPortTransport(port, _config().RioBaudRate));
?? (port => RioTransportFactory.Open(port, _config().RioBaudRate));
}
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
@@ -0,0 +1,275 @@
using System.IO.Pipes;
using RioJoy.Core.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Serial;
public class PipeFramingTests
{
[Fact]
public void EncodeData_WrapsInOneFrame()
{
byte[] framed = PipeFraming.EncodeData(new byte[] { 0x81, 0x01, 0xFC });
Assert.Equal(new byte[] { 0x00, 0x03, 0x81, 0x01, 0xFC }, framed);
}
[Fact]
public void EncodeData_ChunksPayloadsOver255()
{
var data = new byte[300];
for (int i = 0; i < data.Length; i++) data[i] = (byte)i;
byte[] framed = PipeFraming.EncodeData(data);
// 255-byte frame + 45-byte frame, payloads contiguous.
Assert.Equal(300 + 4, framed.Length);
Assert.Equal(PipeFraming.DataType, framed[0]);
Assert.Equal(255, framed[1]);
Assert.Equal(PipeFraming.DataType, framed[2 + 255]);
Assert.Equal(45, framed[2 + 255 + 1]);
Assert.Equal(data.Take(255), framed.Skip(2).Take(255));
Assert.Equal(data.Skip(255), framed.Skip(2 + 255 + 2));
}
[Fact]
public void EncodeData_EmptyYieldsNoFrames()
{
Assert.Empty(PipeFraming.EncodeData(new byte[0]));
}
[Fact]
public void Decoder_SurvivesAnySplitAcrossReads()
{
var decoder = new PipeFrameDecoder();
var data = new List<byte>();
var lines = new List<byte>();
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
decoder.Lines += lines.Add;
// A lines frame, a 2-byte data frame, a 1-byte data frame — fed one byte at a time.
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
foreach (byte b in stream)
Assert.True(decoder.Feed(new[] { b }, 1));
Assert.Equal(new byte[] { 0x03 }, lines);
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
}
[Fact]
public void Decoder_UnknownFrameType_Poisons()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0xFF }, 1));
Assert.Contains("0xFF", decoder.Violation);
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); // stays poisoned
decoder.Reset();
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
Assert.Null(decoder.Violation);
}
[Fact]
public void Decoder_ZeroLengthDataFrame_Poisons()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
Assert.Contains("zero-length", decoder.Violation);
}
}
public class RioTransportFactoryTests
{
[Theory]
[InlineData("pipe:vrio", true)]
[InlineData("PIPE:vrio", true)]
[InlineData("COM3", false)]
[InlineData("com1", false)]
public void IsPipe_RecognizesTheScheme(string endpoint, bool expected)
{
Assert.Equal(expected, RioTransportFactory.IsPipe(endpoint));
}
}
/// <summary>
/// Integration tests against a real in-process <see cref="NamedPipeServerStream"/>
/// standing in for vRIO (which serves \\.\pipe\vrio the same way).
/// </summary>
public class NamedPipeTransportTests : IDisposable
{
private readonly string _pipeName = $"riojoy-test-{Guid.NewGuid():N}";
private readonly NamedPipeServerStream _server;
public NamedPipeTransportTests()
{
_server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
}
public void Dispose()
{
try { _server.Dispose(); }
catch (IOException) { }
}
/// <summary>Accept the client and construct the transport concurrently (the ctor blocks through the DTR pulse).</summary>
private async Task<NamedPipeTransport> ConnectAsync()
{
Task accept = _server.WaitForConnectionAsync();
Task<NamedPipeTransport> client = Task.Run(() => new NamedPipeTransport(_pipeName));
await accept.WithTimeout();
return await client.WithTimeout();
}
private async Task<byte[]> ServerReadAsync(int count)
{
var buffer = new byte[count];
int fill = 0;
while (fill < count)
{
int n = await _server.ReadAsync(buffer, fill, count - fill).WithTimeout();
Assert.True(n > 0, "server: pipe closed before the expected bytes arrived");
fill += n;
}
return buffer;
}
[Fact]
public async Task Connect_SendsTheDtrResetPulse()
{
using NamedPipeTransport transport = await ConnectAsync();
// Assert (DTR high), hold, release — the in-band SETDTR/CLRDTR port.
Assert.Equal(new byte[] { 0x01, PipeFraming.LineDtr }, await ServerReadAsync(2));
Assert.Equal(new byte[] { 0x01, 0x00 }, await ServerReadAsync(2));
}
[Fact]
public async Task ReadAsync_UnwrapsDataFrames_AndSwallowsLinesFrames()
{
using NamedPipeTransport transport = await ConnectAsync();
// vRIO's on-connect lines frame (board present), then a data frame —
// split at an awkward boundary to exercise the incremental decoder.
// Issued unawaited: the pipe's 0-byte buffers make a write complete
// only when the peer reads it (see the transport's ctor comment).
Task w1 = _server.WriteAsync(new byte[] { 0x01, 0x03, 0x00, 0x03, 0x81 }, 0, 5);
Task w2 = _server.WriteAsync(new byte[] { 0x01, 0xFC }, 0, 2);
var buffer = new byte[16];
var got = new List<byte>();
while (got.Count < 3)
{
int n = await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout();
Assert.True(n > 0);
got.AddRange(buffer.Take(n));
}
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, got);
Assert.Equal((byte)(PipeFraming.LineDtr | PipeFraming.LineRts), transport.PeerLines);
await w1.WithTimeout();
await w2.WithTimeout();
}
[Fact]
public async Task ReadAsync_SmallBuffer_DrainsAcrossCalls()
{
using NamedPipeTransport transport = await ConnectAsync();
Task write = _server.WriteAsync(new byte[] { 0x00, 0x04, 0x10, 0x20, 0x30, 0x40 }, 0, 6);
var buffer = new byte[3];
Assert.Equal(3, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Equal(new byte[] { 0x10, 0x20, 0x30 }, buffer);
Assert.Equal(1, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Equal(0x40, buffer[0]);
await write.WithTimeout();
}
[Fact]
public async Task WriteAsync_WrapsInADataFrame()
{
using NamedPipeTransport transport = await ConnectAsync();
await ServerReadAsync(4); // discard the DTR pulse frames
// Unawaited until the server drains it (0-byte pipe buffers).
Task write = transport.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
Assert.Equal(new byte[] { 0x00, 0x02, 0x81, 0x01 }, await ServerReadAsync(4));
await write.WithTimeout();
}
[Fact]
public async Task ServerGone_ReadReturnsZero()
{
using NamedPipeTransport transport = await ConnectAsync();
_server.Dispose();
var buffer = new byte[16];
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
// And it keeps saying closed rather than reading a dead pipe.
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
}
[Fact]
public async Task ProtocolViolation_ReadReturnsZero()
{
using NamedPipeTransport transport = await ConnectAsync();
Task write = _server.WriteAsync(new byte[] { 0xFF }, 0, 1);
var buffer = new byte[16];
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
Assert.Contains("0xFF", transport.Violation);
await write.WithTimeout();
}
[Fact]
public async Task Dispose_UnblocksAPendingRead()
{
NamedPipeTransport transport = await ConnectAsync();
Task<int> pending = transport.ReadAsync(new byte[16], CancellationToken.None);
Assert.False(pending.IsCompleted);
transport.Dispose();
Assert.Equal(0, await pending.WithTimeout());
}
[Fact]
public void NoServer_ConstructorTimesOutWithAClearMessage()
{
var ex = Assert.Throws<TimeoutException>(() =>
new NamedPipeTransport($"riojoy-nobody-{Guid.NewGuid():N}", TimeSpan.FromMilliseconds(200)));
Assert.Contains("vRIO", ex.Message);
}
[Fact]
public async Task Factory_OpensPipeEndpoints()
{
Task accept = _server.WaitForConnectionAsync();
Task<IRioTransport> client = Task.Run(() => RioTransportFactory.Open($"pipe:{_pipeName}"));
await accept.WithTimeout();
using IRioTransport transport = await client.WithTimeout();
Assert.Equal($@"\\.\pipe\{_pipeName}", transport.Description);
}
}
internal static class TaskTimeoutExtensions
{
/// <summary>Await with a test-failure deadline, so a hung pipe fails fast instead of stalling the run.</summary>
public static async Task<T> WithTimeout<T>(this Task<T> task, int seconds = 5)
{
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
return await task;
}
public static async Task WithTimeout(this Task task, int seconds = 5)
{
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
await task;
}
}
+3 -3
View File
@@ -70,7 +70,7 @@ internal static class E0Test
int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
bool ackSent = false;
SerialPortTransport? transport = null;
IRioTransport? transport = null;
Task? reader = null;
void PhaseReset(AckMode mode, int expectReply, int expectLen)
@@ -93,7 +93,7 @@ internal static class E0Test
foreach (int tryBaud in bauds)
{
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] probing {port} @ {tryBaud} (DTR reset, ~2s boot wait)...");
try { transport = new SerialPortTransport(port, tryBaud); }
try { transport = RioTransportFactory.Open(port, tryBaud); }
catch (Exception ex)
{
Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
@@ -101,7 +101,7 @@ internal static class E0Test
}
lock (gate) { buffer.Clear(); cursor = 0; }
SerialPortTransport t = transport;
IRioTransport t = transport;
reader = Task.Run(async () =>
{
var tmp = new byte[256];
+1 -1
View File
@@ -110,7 +110,7 @@ internal static class MashTest
IRioTransport transport;
try
{
transport = selftest ? new SelftestTransport() : new SerialPortTransport(port, baud);
transport = selftest ? new SelftestTransport() : RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{
+4 -2
View File
@@ -9,6 +9,8 @@ using RioJoy.Core.Serial;
// It flashes all lamps once to prove the PC -> RIO output path, then echoes a lamp
// on each button press so a physical press lights up.
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds] [--baud rate]
// [port] is a COM name or a pipe endpoint: pipe:vrio connects to the vRIO
// emulator's \\.\pipe\vrio (no com0com pair; vRIO must have its pipe open).
// Firmware wedge-patch validation (RIO_TAP mash test, see MashTest.cs):
// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
// [--label baseline|patched] [--no-lamps] [--wedge seconds] [--baud rate]
@@ -45,10 +47,10 @@ void Log(string msg)
Console.WriteLine($"== RIO serial monitor :: {port} @ {baud} 8N1 for {seconds}s ==");
SerialPortTransport transport;
IRioTransport transport;
try
{
transport = new SerialPortTransport(port, baud);
transport = RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{