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 /// monotonic slot deadline (<c>slot = max(prevSlot + period, now)</c>), so
/// the stream averages the true baud rate without bursting after idle.</para> /// 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 /// <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 /// 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 /// 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]; var one = new byte[1];
long slot = Stopwatch.GetTimestamp(); long slot = Stopwatch.GetTimestamp();
bool txHealthy = true; // log stall/recovery transitions, not every byte
while (_running) while (_running)
{ {
@@ -226,12 +233,33 @@ public sealed class VRioSerialService : IDisposable
try try
{ {
port.Write(one, 0, 1); 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) catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{ {
if (_running) if (!_running)
Logged?.Invoke($"Write failed: {ex.Message}"); return;
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 // If the wait overshot its slot, pace the next byte from the