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:
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||
/// 0x01 <lines:u8> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user