Files
VRIO/tests/VRio.Core.Tests/PipeServiceTests.cs
T
CydandClaude Fable 5 e507f1740c Named-pipe transport for the DOSBox-X fork's namedpipe backend
Both devices now serve \.\pipe\vrio and \.\pipe\vplasma for the whole
app lifetime alongside the COM rows — the com0com-free path. Framing per
the pinned contract (0x00 len data / 0x01 DTR+RTS lines, one lines frame
on connect, unknown type = log + drop): PipeFraming/PipeFrameDecoder and
VRioPipeService (TX paced at the wire rate, peer DTR edges feed
HostHandshake) in VRio.Core, listener-only VPlasmaPipeService twin in
VPlasma.Core. Busy pipe names retry, so vRIO's built-in glass and the
standalone vPLASMA coexist. README documents the transport and the fork
conf lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:03:02 -05:00

328 lines
11 KiB
C#

using System.Diagnostics;
using System.IO.Pipes;
using VRio.Core.Device;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class PipeFrameDecoderTests
{
[Fact]
public void Data_frame_delivers_payload()
{
var decoder = new PipeFrameDecoder();
byte[]? got = null;
decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
}
[Fact]
public void Frames_survive_any_split_across_reads()
{
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 — 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 Multiple_frames_in_one_read_all_deliver()
{
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;
byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
Assert.True(decoder.Feed(chunk, chunk.Length));
Assert.Equal(new byte[] { 0x42, 0x43 }, data);
Assert.Equal(new byte[] { 0x00 }, lines);
}
[Fact]
public void Unknown_frame_type_poisons_the_decoder()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
Assert.Contains("0x7F", decoder.Violation);
// Poisoned: even a healthy frame is rejected until Reset.
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
decoder.Reset();
Assert.Null(decoder.Violation);
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
}
[Fact]
public void Zero_length_data_frame_is_a_violation()
{
var decoder = new PipeFrameDecoder();
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
Assert.Contains("zero-length", decoder.Violation);
}
}
public class VRioPipeServiceTests
{
/// <summary>
/// A contract-speaking client: connects to the service's pipe, deframes
/// everything it receives (timestamping data bytes for the pacing test),
/// and sends raw frame bytes.
/// </summary>
private sealed class TestClient : IDisposable
{
private readonly NamedPipeClientStream _pipe;
private readonly Thread _reader;
private readonly object _sync = new();
private readonly List<byte> _data = new();
private readonly List<long> _dataTicks = new();
private readonly List<byte> _lines = new();
private volatile bool _closed;
public TestClient(string pipeName)
{
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
PipeOptions.Asynchronous);
// The server's listener may still be spinning up; retry briefly.
var connectClock = Stopwatch.StartNew();
while (true)
{
try
{
_pipe.Connect(200);
break;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
if (connectClock.ElapsedMilliseconds >= 3000)
throw;
Thread.Sleep(25);
}
}
var decoder = new PipeFrameDecoder();
decoder.Data += (buf, count) =>
{
long now = Stopwatch.GetTimestamp();
lock (_sync)
for (int i = 0; i < count; i++)
{
_data.Add(buf[i]);
_dataTicks.Add(now);
}
};
decoder.Lines += b => { lock (_sync) _lines.Add(b); };
_reader = new Thread(() =>
{
var buffer = new byte[512];
while (true)
{
int n;
try { n = _pipe.Read(buffer, 0, buffer.Length); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
if (n == 0 || !decoder.Feed(buffer, n))
break;
}
_closed = true;
})
{ IsBackground = true };
_reader.Start();
}
public bool Closed => _closed;
public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
public void SendRaw(params byte[] bytes)
{
_pipe.Write(bytes, 0, bytes.Length);
_pipe.Flush();
}
public void SendData(byte[] payload)
{
var frame = new byte[2 + payload.Length];
frame[0] = PipeFraming.DataType;
frame[1] = (byte)payload.Length;
payload.CopyTo(frame, 2);
SendRaw(frame);
}
public void Dispose()
{
_pipe.Dispose();
_reader.Join(1000);
}
}
private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
{
var clock = Stopwatch.StartNew();
while (!condition())
{
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
Thread.Sleep(10);
}
}
[Fact]
public void Client_gets_board_present_lines_on_connect()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
}
[Fact]
public void Peer_dtr_edges_surface_as_host_handshake()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var edges = new List<bool>();
service.HostHandshake += high => { lock (edges) edges.Add(high); };
service.Start();
using var client = new TestClient(name);
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
WaitFor(() => { lock (edges) return edges.Count == 2; });
lock (edges)
Assert.Equal(new[] { true, false }, edges);
}
[Fact]
public void Version_request_round_trips_ack_then_reply()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0); // fully connected
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
byte[] expected = new[] { (byte)RioControl.Ack }
.Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
WaitFor(() => client.Data.Length >= expected.Length);
Assert.Equal(expected, client.Data);
}
[Fact]
public void Reply_bytes_are_paced_at_the_wire_rate()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
// ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
// 9600 baud. An unpaced writer would land them in well under 1 ms.
WaitFor(() => client.Data.Length >= 5);
long[] ticks = client.DataTicks;
double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
}
[Fact]
public void Unknown_frame_type_drops_the_client_and_server_recovers()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
service.Start();
using (var bad = new TestClient(name))
{
WaitFor(() => bad.Lines.Length > 0);
bad.SendRaw(0x7F); // not a frame type
WaitFor(() => bad.Closed); // contract: log + drop, no resync
}
// The listener must come back for the next client.
using var good = new TestClient(name);
WaitFor(() => good.Lines.Length > 0);
Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
}
[Fact]
public void Client_disconnect_drops_dtr_low()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var edges = new List<bool>();
service.HostHandshake += high => { lock (edges) edges.Add(high); };
service.Start();
using (var client = new TestClient(name))
{
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
WaitFor(() => { lock (edges) return edges.Count == 1; });
} // client leaves with DTR still high
WaitFor(() => { lock (edges) return edges.Count == 2; });
lock (edges)
Assert.Equal(new[] { true, false }, edges);
}
[Fact]
public void Data_split_mid_frame_still_reaches_the_device()
{
string name = UniqueName();
var device = new VRioDevice();
using var service = new VRioPipeService(device, name);
var lamps = new List<(int Address, byte State)>();
device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
service.Start();
using var client = new TestClient(name);
WaitFor(() => client.Lines.Length > 0);
// LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
client.SendRaw(frame.Take(3).ToArray());
Thread.Sleep(20);
client.SendRaw(frame.Skip(3).ToArray());
WaitFor(() => { lock (lamps) return lamps.Count == 1; });
lock (lamps)
Assert.Equal((0x00, (byte)0x01), lamps[0]);
}
}