Fix SendInput crash; dim lit lamps after reset; harden the link

SendInput declared its parameter as Span<INPUT>, which cannot be marshalled
(MarshalDirectiveException) — the first keyboard/mouse output threw on the
serial read thread and killed the link. Pass a blittable INPUT[] instead;
add a regression test.

Also: defend the runtime so a faulty output sink can never tear down the
serial link (output is best-effort), and dim lit lamps on the first reply
from the board rather than in Start() — lamp commands sent in the first ms
after the DTR reset (on port open) are dropped before the board has booted.

Verified end-to-end against the partial RIO on COM1 (keypad types to the PC,
joystick feeds, comms stay up; lit MFD buttons come up dim).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-28 22:39:40 -05:00
co-authored by Claude Opus 4.8
parent 6fed476713
commit 905b9facc9
4 changed files with 101 additions and 19 deletions
+3 -7
View File
@@ -70,12 +70,8 @@ public sealed class SendInputSink : IInputSink
Send(input);
}
private static void Send(INPUT input)
{
Span<INPUT> inputs = stackalloc INPUT[1];
inputs[0] = input;
SendInput(1, inputs, Marshal.SizeOf<INPUT>());
}
private static void Send(INPUT input) =>
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
// --- Win32 interop --------------------------------------------------------
@@ -130,7 +126,7 @@ public sealed class SendInputSink : IInputSink
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, Span<INPUT> pInputs, int cbSize);
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
private static extern uint MapVirtualKey(uint uCode, uint uMapType);
+33 -2
View File
@@ -20,6 +20,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
private readonly InputRouter _router;
private readonly ILampSink _lamp;
private bool _started;
private bool _lampsInitialized;
public RioRuntime(
RioSerialLink link,
@@ -61,18 +62,23 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// <summary>Raised for each board/lamp status item in a <see cref="RioCommand.CheckReply"/>.</summary>
public event Action<CheckStatus>? CheckReceived;
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
/// <summary>
/// Subscribe to the link. Lit lamps are dimmed on the first reply from the board
/// (<see cref="EnsureLampsInitialized"/>) rather than here: lamp commands sent in
/// the first milliseconds after the DTR reset (on port open) are dropped before
/// the board finishes booting, so the idle-dim state never takes.
/// </summary>
public void Start()
{
if (_started)
return;
_started = true;
_lampsInitialized = false;
_link.PacketReceived += OnPacket;
_link.AnalogReceived += OnAnalog;
_link.VersionReceived += OnVersion;
_link.CheckReceived += OnCheck;
_router.InitializeLamps();
}
/// <summary>Unsubscribe from the link.</summary>
@@ -97,9 +103,21 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// <summary>Invoke a RIO command directly (e.g. from the tray menu).</summary>
public void Trigger(RioCommandCode command) => ((IRioCommandSink)this).Execute(command);
// Once the board replies (alive after the reset), set every lit button to dim.
private void EnsureLampsInitialized()
{
if (_lampsInitialized)
return;
_lampsInitialized = true;
_router.InitializeLamps();
}
private void OnAnalog(AnalogReport report)
{
EnsureLampsInitialized();
AxisOutputs axes = _calibrator.Update(report);
try
{
_joystick.SetAxis(JoyAxis.X, axes.X);
_joystick.SetAxis(JoyAxis.Y, axes.Y);
_joystick.SetAxis(JoyAxis.Z, axes.Z);
@@ -107,9 +125,15 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
_joystick.SetAxis(JoyAxis.Ry, axes.Ry);
_joystick.SetAxis(JoyAxis.Rz, axes.Rz);
}
catch
{
// Output is best-effort: a faulty joystick sink must not kill the link.
}
}
private void OnPacket(RioPacket packet)
{
EnsureLampsInitialized();
ReadOnlySpan<byte> p = packet.Payload.Span;
switch (packet.Command)
{
@@ -130,11 +154,18 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
// Route the event through the input pipeline and notify any live-activity listener.
private void Activity(int address, bool pressed)
{
try
{
if (pressed)
_router.Press(address);
else
_router.Release(address);
}
catch
{
// Output is best-effort: a faulty sink must never kill the serial link.
}
// Light the button on the RIO even when it is unmapped (the router only lamps
// mapped lamp-buttons), so the editor's check-function workflow always responds.
@@ -0,0 +1,21 @@
using RioJoy.Core.Output;
using Xunit;
namespace RioJoy.Core.Tests.Output;
public class SendInputSinkTests
{
[Fact]
public void SendInput_DoesNotThrow_OnMarshalling()
{
var sink = new SendInputSink();
// A zero relative mouse move is a no-op, but it exercises the same SendInput
// INPUT[] marshalling path that previously threw MarshalDirectiveException with
// a Span<INPUT> parameter — which killed the serial read thread when a button
// with output enabled was pressed.
Exception? ex = Record.Exception(() => sink.MouseMove(0, 0));
Assert.Null(ex);
}
}
@@ -72,6 +72,40 @@ public class RioRuntimeTests
await run;
}
[Fact]
public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
var map = new RioInputMap { [0x05] = new RioMapEntry(0x9001) }; // lit joystick button
using var runtime = new RioRuntime(link, map, recorder, recorder);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Nothing is sent on Start: lamp commands would be dropped during the board's
// post-reset boot, so no write happens until the board first replies.
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(150)));
// A reply proves the board is alive → the lit lamp is dimmed.
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
bool dimmed = false;
for (int i = 0; i < 8 && !dimmed; i++)
{
byte[] w = await fake.NextWriteAsync(TimeSpan.FromSeconds(2));
dimmed = w.Length >= 3 && w[0] == (byte)RioCommand.LampRequest && w[1] == 0x05;
}
Assert.True(dimmed, "expected a LampRequest dimming address 0x05 after the first reply");
cts.Cancel();
await run;
}
[Fact]
public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped()
{