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
+24
View File
@@ -857,3 +857,27 @@ cleanup. The forest for the trip: the fallback path (animation) MASKED the broke
output still looked like "an alert rendering", exactly the flick I'd have shipped if the test
hadn't forced the null-frame idle state.
### A seam fed from the chat poller must marshal EVERY WPF-object write to the UI thread (AlertOverlayLayer crash, 2026-09-26)
First crash in the app's history (startup.log `11:22:01.301`), and the sims gave zero warning for
months. Test-tab alert buttons run on the UI thread, so `OnMessageReceived` → preview writes were
always UI-threaded there. The moment a REAL message round-tripped through the chat poller:
`System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject` in
`DataBindEngine.ProcessCrossThreadRequests` — WriteableBitmap created on the MTA poller thread,
`alertBox.VideoImageSource` raises INPC (`DisplaySource`) and is WPF-bound, the binding engine
re-binds cross-thread, process dies.
The chat layer already had the rule (`ChatOverlayLayer.OnMessageReceived` wraps its body in
`Application.Current.Dispatcher.Invoke`); the alert layer's copy of the seam didn't. Rule now
recorded for every layer: **the chat poller is an MTA producer — any INPC-raising seam it feeds
must marshal every WPF-object write (WriteableBitmap, DP/INPC-raised bound sources) to the UI
thread; wrap the WHOLE ingest, not just one writer, so enqueue/timer/preview stay coherent on the
UI thread. Guard `Application.Current` null like AlertTickerRenderer.Render does (pure seams).**
Red/green proof pattern: call the seam from a raw `new Thread` (MTA) while the RealApp message
loop runs, return to the loop so the marshalled work can execute (never `Join` on the UI thread),
then assert on the UI thread that `source.VideoImageSource.Dispatcher` is the App's dispatcher.
Red pre-fix (it was the poller's), green post-fix.
Grep-ahead: a `DispatcherTimer` ctor guarded by `if (Application.Current != null)` marks a class
with UI-thread affinity — audit every production ingress point for the marshal when adding one.