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
+17 -3
View File
@@ -87,10 +87,24 @@ public sealed class AlertOverlayLayer : IDisposable
}
}
/// <summary>Production entry from the chat feed: enqueue one event. The queue
/// lives on the UI thread (the poller marshals here), so the ticker's own
/// thread expectations hold for preview writes.</summary>
/// <summary>Production entry from the chat feed: enqueue one event. Messages
/// arrive on the chat poller thread, so this marshals to the UI thread (same
/// rule as <see cref="ChatOverlayLayer.OnMessageReceived"/>) — the preview
/// writes create WPF <see cref="WriteableBitmap"/> instances, and creating a
/// DependencySource off the UI thread crashes the WPF binding engine
/// (ArgumentException "Must create DependencySource on same Thread").</summary>
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
{
var app = Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
app.Dispatcher.Invoke(() => Ingest(message, scenes));
return;
}
Ingest(message, scenes);
}
private void Ingest(ChatMessage message, IEnumerable<Scene> scenes)
{
_scenes = scenes;
Enqueue(message);