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>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using VPlasma.Core.Device;
|
||||
using Xunit;
|
||||
|
||||
namespace VPlasma.Core.Tests;
|
||||
|
||||
public class VPlasmaPipeServiceTests
|
||||
{
|
||||
private static string UniqueName() => $"vplasma-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);
|
||||
}
|
||||
}
|
||||
|
||||
private static NamedPipeClientStream Connect(string name)
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.Connect(200);
|
||||
return pipe;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
if (clock.ElapsedMilliseconds >= 3000)
|
||||
throw;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Framed_text_reaches_the_display_and_lines_are_ignored()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = Connect(name);
|
||||
var hello = new byte[2];
|
||||
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
|
||||
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
|
||||
|
||||
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
|
||||
// the data frame torn in half mid-payload.
|
||||
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
|
||||
client.Flush();
|
||||
Thread.Sleep(20);
|
||||
client.Write(new byte[] { (byte)'I' }, 0, 1);
|
||||
client.Flush();
|
||||
|
||||
WaitFor(() => device.BytesReceived == 2);
|
||||
Assert.Equal(2, device.TextCharsDrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using (var bad = Connect(name))
|
||||
{
|
||||
var hello = new byte[2];
|
||||
bad.Read(hello, 0, 2);
|
||||
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
|
||||
bad.Flush();
|
||||
// Contract: log + drop, no resync — our next read sees the close.
|
||||
var eof = Task.Run(() =>
|
||||
{
|
||||
try { return bad.Read(new byte[1], 0, 1) == 0; }
|
||||
catch (IOException) { return true; }
|
||||
});
|
||||
Assert.True(eof.Wait(2000), "server did not drop the connection");
|
||||
Assert.True(eof.Result);
|
||||
}
|
||||
|
||||
using var good = Connect(name);
|
||||
var again = new byte[2];
|
||||
Assert.Equal(1, good.Read(again, 0, 1));
|
||||
Assert.Equal(1, good.Read(again, 1, 1));
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user