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
+50 -55
View File
@@ -1,46 +1,41 @@
# HANDOFF — 2026-09-26 (TASK 47 shipped, NOT yet pushed: alert box video) # HANDOFF — 2026-09-26 (TASK 47 shipping crash fixed; 2 local commits, NOT pushed)
## Branch / Commit State ## Branch / Commit State
`main` — TASK 47 changes are staged-ready, **uncommitted** in the working tree (repo was clean at `main` — two local commits ahead of `origin/main` (= `2c9af94`... actually `origin/main` = `ecb329e`),
`2c9af94`, the TASK 46 handoff commit; `origin/main` = `ecb329e`). Commit + push pending the **NOT pushed** (awaiting the creator's go / milestone signal):
creator's go / milestone signal. The TASK 46 commit was pushed 2026-09-25
(`origin/main` = `197ee81..ecb329e`).
## ⚠️ The unit that just shipped (creator directive) 1. `a11b15e` — **TASK 47** alert box video (built-in/custom clip + read-time fade + message
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 318/319 with the
known env flake → final full run 319/319 green).
2. *(uncommitted next)* — **the 2026-09-26 live-crash fix** (see below), scope = 3 files + docs:
`Services/AlertOverlayLayer.cs`, `ytLive.Tests/AlertLayerVideoTests.cs`, plus docs
(`TASKS/task-47-alert-videos.md` addendum, `MyMistakes.md` lesson, this file). Full suite
**320/320** green, alert tests 3/3, `scope-check.sh` passed.
TASK 43 closed with "make the alerts animate like a video" / "I want to play my own video for ## ⚠️ The live crash + fix (this unit)
alerts" — that follow-up is **TASK 47, done 2026-09-26**:
- **Video alerts:** the alert box plays a clip on every alert — the shipped built-in The creator ran the shipped build in a test session: **test-tab alerts DID fire** (button
(`Assets/alert-default.mp4`, stamped into the `Asset` table at startup via commands run on the UI thread — startup.log `11:21:53` shows the sims and alert-clip audio in the
`StampDefaultAlertVideo`, id tracked in the `AlertDefaultVideoAssetId` setting) unless the live mix: `micLevel=0.000 loopLevel=0.000` yet `peakMix 0.375 → 0.733 → 0.891`, which can only be
creator picks their own file (path-only reference, **never stored in the DB**; the six the alert ring; the default clip is materialized at `%TEMP%\ytLive-alert-941785b0-….mp4`, the
`AlertRenderer` animations remain the fallback when nothing plays). Per-alert video pipe verified against the pinned ffmpeg), but the app **crashed at `11:22:01.301`** — the
`IAlertClipDecoder` (ffmpeg bgra + f32le pipes, real-time paced, disposed at drain). first REAL message that round-tripped through the chat poller:
- **Read-time fade** (`FadeDurationSeconds = 0.30` in `AlertOverlayLayer`): fade-in alpha on a
straight-alpha frame copy; EOF → freeze-frame → fade-out → drain back to idle/transparent.
- **Message ticker top-of-frame** ("Funder — Super Chat · $10.00"): `AlertTickerRenderer`
(marquee 140px/s), **never baked** — threaded via a `FramePump._alertTicker` seam into
`SceneCompositor.Render/CompositeLayers` and mixed into `BuildFullRenderSignature`.
- **Alert audio at unity, NO duck** (creator ruling): `AudioMixer.EnqueueAlertAudio` + an 8s
stereo-48k ring drained pre-limiter; per-alert `Volume` slider instead.
- New Stream Alerts config section in LeftPanel (built-in pill, path + Browse…, Reset, ticker
pill, volume).
**Good Dog test:** `ytLive.Tests/AlertLayerVideoTests.cs` (RealApp STA) — one `[Fact]` driving a System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
**fake** `IAlertClipDecoder` through the whole lifecycle: custom path wins per-source, decoder at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
spawns/disposes (Start=1, Dispose=1 at drain), alpha envelope 0→127→255, pre-fade audio dropped
while ramp audio forwards at volume×fade (0.8×0.5→[0.2,−0.1]) and full-fade at volume only, Root cause: `AlertOverlayLayer.OnMessageReceived` had NO UI-thread marshal while
ticker renders non-null, has pixels and **scrolls** between ticks (and null when idle), EOF `ChatOverlayLayer.OnMessageReceived` has one. A polled message ran `RefreshAlertPreviews` →
freeze-fades to a null frame. The test caught a real bug on the first run: the clip branch of `UpdatePreview` on the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC-raised +
`Advance` never cleared `_current` before `AdvanceToNext` → the layer stayed `IsPlaying` after WPF-bound) with a WriteableBitmap created there → binding engine cross-thread re-bind → crash.
drain (see `MyMistakes.md`). Fix: marshal the whole ingest via `Application.Current.Dispatcher.Invoke` (null-guarded so pure
**Gate: clean build 0 warnings; alert tests 2/2; full vstest 318/319** — the one failure seams still run inline). Good Dog regression test
`LayerReorderPersistenceTests.RealMouseDrag…` is the HANDOFF-documented env class (physical `AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread`
mouse drag no-ops when the desktop/session isn't interactive; this unit it failed 2× then passed (calls the seam from a raw MTA `Thread`, returns to the message loop, asserts on the UI thread
in isolation on re-run; SourceList row geometry provably unchanged — `AlertOverlayLayer` — that `VideoImageSource.Dispatcher` is the App's dispatcher — **red pre-fix, green post-fix,
and its two sibling reorder tests, the same persistence path, pass). verified both ways**). Rule written into `MyMistakes.md`: every INPC-raising seam fed by the chat
poller must marshal WPF-object writes to the UI thread.
## Follow-ups queued (NOT done in this unit) ## Follow-ups queued (NOT done in this unit)
@@ -48,36 +43,36 @@ and its two sibling reorder tests, the same persistence path, pass).
`superChatEvents.list` (30-day) backfill + session-report rollup (still in-memory only). `superChatEvents.list` (30-day) backfill + session-report rollup (still in-memory only).
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — all - TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — all
queued unchanged. queued unchanged.
- If the creator still reports "nothing in the box" after this fix (their Live scene's "Stream
Alerts" AlertBox is at 612,45 680×200; there is also a WebSource "Web Resource-0" bottom-right —
confirm which element they're watching), investigate the video-render path live — but note all
evidence (audio in the mix, materialized clip, verified pipe) says the native box played.
## Around the task (carried facts) ## Around the task (carried facts)
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen - RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — **close fullscreen windows before full-suite runs**; the single test passes in 2026-09-22) — **close fullscreen windows before full-suite runs**; passes in isolation.
isolation when re-run alone. AudioPipeline timing flake similar.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests. - Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar - `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit. must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first - **API facts (TASK 44 + 45):** liveChatId unavailable before the broadcast is live (poll until
`ReadInt64` (MyMistakes recipe), never `GetInt64`. active); `400 MISSING_REQUIRED_FIELD` from `liveChat/messages.insert` = wrong body shape
- **API facts (TASK 44 + 45):** a liveChatId fetch before the broadcast is live returns nothing by (missing `snippet.type`), NOT auth/scope.
design (read `snippet.liveChatId`, poll until active); `400 MISSING_REQUIRED_FIELD` from - **TASK 47 sticky facts:** bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer
`liveChat/messages.insert` = wrong body shape (missing `snippet.type`), NOT auth/scope. loop; default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play, never the refcounted
- **WPF test technique (TASK 46):** a `MouseButtonEventArgs { RoutedEvent = `MediaVideoSourceManager`. DB stays `user_version 10` — TASK 47's Source columns are additive
Mouse.PreviewMouseLeftButtonDownEvent }` raised on the window root deterministically simulates an guarded ALTERs. Recipe in `MyMistakes.md`.
"outside the drawer rail" click — InputBindings are NOT triggered by `RaiseEvent`. - ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer now mirrors it.
- **TASK 47 sticky facts:** bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer loop
(PCM bytes → float chunks straddle pipe reads); default clip lives at `%TEMP%\ytLive-alert-{id}.mp4`
when materialized; decoder per-play, never the refcounted `MediaVideoSourceManager`. Recipe in
`MyMistakes.md`. DB stays `user_version 10` — TASK 47's Source columns are additive guarded ALTERs.
- Every committed change needs a **close + relaunch** of the running app to be seen. - Every committed change needs a **close + relaunch** of the running app to be seen.
## Next step ## Next step
1. Run `scripts/scope-check.sh` with the declared file list (TASK 47), final clean build (verify, 1. Commit the crash fix (declared scope + same-commit docs), then run
0 warnings, `"/mnt/c/Program Files/dotnet/dotnet.exe" build ytLive.csproj`) + full vstest. `./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
2. Single commit (code + this unit's docs: `ai.md`, `TASKS.md`, `TASKS/task-47-alert-videos.md`, 2. Full clean build (0 warnings, Windows dotnet host) + full vstest **320/320**.
`Services/index.md`, `HANDOFF.md`, `MyMistakes.md`). Push at the creator's go/milestone. 3. Push `a11b15e` + the fix commit at the creator's go / milestone signal.
3. When scoped: TASK 3 item 20 (RewardEvents persistence half) draws next. 4. Creator to relaunch and re-run the test-tab alerts (one of each) + post a real chat message —
the crash is fixed; then confirm which visual element they call the "web-alert box".
## Critical working rules (unchanged, still binding) ## Critical working rules (unchanged, still binding)
+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 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. 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.
+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 /// <summary>Production entry from the chat feed: enqueue one event. Messages
/// lives on the UI thread (the poller marshals here), so the ticker's own /// arrive on the chat poller thread, so this marshals to the UI thread (same
/// thread expectations hold for preview writes.</summary> /// 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) 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; _scenes = scenes;
Enqueue(message); Enqueue(message);
+19
View File
@@ -129,6 +129,25 @@ class (physical-mouse drag no-ops when the desktop/session isn't interactive; it
in isolation on the third re-run of this unit; the two sibling reorder tests — identical in isolation on the third re-run of this unit; the two sibling reorder tests — identical
persistence path — pass). `scripts/scope-check.sh` green. persistence path — pass). `scripts/scope-check.sh` green.
## Post-ship crash fix (2026-09-26)
First live run found a crash the sims never could: test-tab alerts work (button commands run on
the UI thread — startup.log shows the alert clip's audio in the live mix, `peakMix 0.375 → 0.891`
with mic and loop at 0.000), but the first **real** message through the chat poller killed the app
at `11:22:01.301`:
System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
`AlertOverlayLayer.OnMessageReceived` lacked the UI-thread marshal `ChatOverlayLayer.OnMessageReceived`
has: a polled message ran `RefreshAlertPreviews` → `UpdatePreview` on the MTA poller thread and
stamped `alertBox.VideoImageSource` (INPC-raised and WPF-bound) with a `WriteableBitmap` created
there — the binding engine's cross-thread re-bind crashed. Fix: marshal the whole ingest
(`Dispatcher.Invoke`, `Application.Current`-null guard) so enqueue, ticker, and preview writes stay
on the UI thread in lockstep. Good Dog test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red on the old seam: the
WriteableBitmap's `Dispatcher` was the poller's) — committed with the fix.
## Open follow-ups (NOT this unit) ## Open follow-ups (NOT this unit)
- TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain - TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain
+51
View File
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Windows;
using Xunit; using Xunit;
using ytLive.Models; using ytLive.Models;
using ytLive.Services; using ytLive.Services;
@@ -128,6 +130,55 @@ public sealed class AlertLayerVideoTests
private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0); 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): /// <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> /// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.</summary>
private sealed class FakeAlertClipDecoder : IAlertClipDecoder private sealed class FakeAlertClipDecoder : IAlertClipDecoder