fix(alerts): AlertOverlayLayer marshals the chat-poller seam to the UI thread

Live test session proved the native alert box DID play (the alert ring was the
only source in the mix: micLevel/loopLevel 0.000 while peakMix went
0.375->0.733->0.891 after the sim injections) but the app crashed at
11:22:01.301 the moment a REAL message round-tripped through the poller:

  System.ArgumentException: Must create DependencySource on same Thread as
  the DependencyObject
  at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()

OnMessageReceived ran RefreshAlertPreviews on the MTA poller thread and
stamped the WPF-bound VideoImageSource with a WriteableBitmap created there;
the binding engine's cross-thread re-bind killed the process. ChatOverlayLayer
already marshals this exact seam (Dispatcher.Invoke) - mirror it, guarded for
Application.Current null so the pure test seams still run inline.

Good Dog test:
AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread
calls the seam from a raw MTA Thread while the RealApp loop runs, then asserts
on the UI thread that the preview bitmap's Dispatcher is the App's (red on the
old seam, green on the fix). WPF cross-thread rule recorded in MyMistakes.md.

Full suite 320/320, clean build 0 warnings, scope-check green.
This commit is contained in:
2026-09-26 11:28:36 -07:00
parent a11b15e444
commit 7b940b6a5a
5 changed files with 161 additions and 58 deletions
+51
View File
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Windows;
using Xunit;
using ytLive.Models;
using ytLive.Services;
@@ -128,6 +130,55 @@ public sealed class AlertLayerVideoTests
private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0);
[Fact]
public void OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread()
{
Source? box = null;
AlertOverlayLayer? layer = null;
Exception? bgFailure = null;
var done = new ManualResetEventSlim();
_app.Run(() =>
{
box = new Source { Type = SourceType.AlertBox, Name = "Stream Alerts", Width = 600, Height = 200 };
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
layer = new AlertOverlayLayer(new AlertRenderer());
var poller = new Thread(() =>
{
try
{
// The chat poller fires MessageReceived on its own (MTA) thread;
// the layer must marshal the preview writes to the UI thread —
// recreating the 2026-09-26 live crash ("Must create
// DependencySource on same Thread").
layer.OnMessageReceived(
new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "Funder" }, scenes);
}
catch (Exception ex)
{
bgFailure = ex;
}
finally
{
done.Set();
}
});
poller.Start();
// Return to the message loop so the marshalled Ingest can run.
});
Assert.True(done.Wait(TimeSpan.FromSeconds(10)), "the poller call must not deadlock");
_app.Run(() =>
{
Assert.Null(bgFailure);
Assert.True(layer!.IsPlaying);
Assert.NotNull(box!.VideoImageSource);
Assert.Same(Application.Current.Dispatcher, box.VideoImageSource!.Dispatcher);
});
}
/// <summary>A decoder seam the test drives synchronously (no ffmpeg, no threads):
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.</summary>
private sealed class FakeAlertClipDecoder : IAlertClipDecoder