Writer thread survives write faults instead of dying mid-run

A single failed port write killed the paced writer thread permanently
while the port still reported open, so every later byte (ACKs, the
CheckRequest handshake) queued forever: wire tap showed 2 of 3 bytes of
a stray ButtonPressed at t=54ms, then 1167 unanswered CheckRequests.
The trigger was com0com flow control with the game side not yet open --
the third byte blocked past WriteTimeout, threw, and the catch returned.

A real UART cannot wedge; it shifts bits into the line whether or not
anyone listens. On a write fault, drop the stalled byte plus the stale
backlog, log the stall/recovery transition once, and keep the writer
alive -- TX resumes as soon as the host drains its end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 13:41:16 -05:00
co-authored by Claude Fable 5
parent 1eded793af
commit dc9a294101
+31 -3
View File
@@ -18,6 +18,12 @@ namespace VRio.Core.Device;
/// monotonic slot deadline (<c>slot = max(prevSlot + period, now)</c>), so
/// the stream averages the true baud rate without bursting after idle.</para>
///
/// <para>A write fault never kills the writer — a real UART streams into an
/// unterminated line rather than blocking. If the virtual wire's far side
/// stops draining (peer end closed, write timeout), the stalled byte and the
/// queued backlog are dropped and transmission resumes with the next fresh
/// packet once the host reads again.</para>
///
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
/// handshake); through a null modem that arrives here as a DSR blip, which is
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
@@ -201,6 +207,7 @@ public sealed class VRioSerialService : IDisposable
{
var one = new byte[1];
long slot = Stopwatch.GetTimestamp();
bool txHealthy = true; // log stall/recovery transitions, not every byte
while (_running)
{
@@ -226,12 +233,33 @@ public sealed class VRioSerialService : IDisposable
try
{
port.Write(one, 0, 1);
if (!txHealthy)
{
txHealthy = true;
Logged?.Invoke("TX recovered — host is draining the wire again");
}
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{
if (_running)
Logged?.Invoke($"Write failed: {ex.Message}");
return;
if (!_running)
return;
// A real UART cannot wedge: it shifts bits onto the line whether
// or not anyone is listening. A failed write means the virtual
// wire's far side stopped draining (peer end closed), so the
// queued backlog is already stale — drop it and keep serving
// fresh traffic; writes land again once the host reads its end.
int dropped;
lock (_txGate)
{
dropped = _txQueue.Count;
_txQueue.Clear();
}
if (txHealthy)
{
txHealthy = false;
Logged?.Invoke($"TX stalled ({ex.Message.TrimEnd('.')}) — dropped {dropped + 1} stale byte(s), writer stays alive");
}
continue;
}
// If the wait overshot its slot, pace the next byte from the