diff --git a/HANDOFF.md b/HANDOFF.md
index 11ce7e5..a946886 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -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
-`main` — TASK 47 changes are staged-ready, **uncommitted** in the working tree (repo was clean at
-`2c9af94`, the TASK 46 handoff commit; `origin/main` = `ecb329e`). Commit + push pending the
-creator's go / milestone signal. The TASK 46 commit was pushed 2026-09-25
-(`origin/main` = `197ee81..ecb329e`).
+`main` — two local commits ahead of `origin/main` (= `2c9af94`... actually `origin/main` = `ecb329e`),
+**NOT pushed** (awaiting the creator's go / milestone signal):
-## ⚠️ 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
-alerts" — that follow-up is **TASK 47, done 2026-09-26**:
+## ⚠️ The live crash + fix (this unit)
-- **Video alerts:** the alert box plays a clip on every alert — the shipped built-in
- (`Assets/alert-default.mp4`, stamped into the `Asset` table at startup via
- `StampDefaultAlertVideo`, id tracked in the `AlertDefaultVideoAssetId` setting) unless the
- creator picks their own file (path-only reference, **never stored in the DB**; the six
- `AlertRenderer` animations remain the fallback when nothing plays). Per-alert
- `IAlertClipDecoder` (ffmpeg bgra + f32le pipes, real-time paced, disposed at drain).
-- **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).
+The creator ran the shipped build in a test session: **test-tab alerts DID fire** (button
+commands run on the UI thread — startup.log `11:21:53` shows the sims and alert-clip audio in the
+live mix: `micLevel=0.000 loopLevel=0.000` yet `peakMix 0.375 → 0.733 → 0.891`, which can only be
+the alert ring; the default clip is materialized at `%TEMP%\ytLive-alert-941785b0-….mp4`, the
+video pipe verified against the pinned ffmpeg), but the app **crashed at `11:22:01.301`** — the
+first REAL message that round-tripped through the chat poller:
-**Good Dog test:** `ytLive.Tests/AlertLayerVideoTests.cs` (RealApp STA) — one `[Fact]` driving a
-**fake** `IAlertClipDecoder` through the whole lifecycle: custom path wins per-source, decoder
-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,
-ticker renders non-null, has pixels and **scrolls** between ticks (and null when idle), EOF
-freeze-fades to a null frame. The test caught a real bug on the first run: the clip branch of
-`Advance` never cleared `_current` before `AdvanceToNext` → the layer stayed `IsPlaying` after
-drain (see `MyMistakes.md`).
-**Gate: clean build 0 warnings; alert tests 2/2; full vstest 318/319** — the one failure
-`LayerReorderPersistenceTests.RealMouseDrag…` is the HANDOFF-documented env class (physical
-mouse drag no-ops when the desktop/session isn't interactive; this unit it failed 2× then passed
-in isolation on re-run; SourceList row geometry provably unchanged — `AlertOverlayLayer` —
-and its two sibling reorder tests, the same persistence path, pass).
+ System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
+ at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
+
+Root cause: `AlertOverlayLayer.OnMessageReceived` had NO UI-thread marshal while
+`ChatOverlayLayer.OnMessageReceived` has one. A polled message ran `RefreshAlertPreviews` →
+`UpdatePreview` on the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC-raised +
+WPF-bound) with a WriteableBitmap created there → binding engine cross-thread re-bind → crash.
+Fix: marshal the whole ingest via `Application.Current.Dispatcher.Invoke` (null-guarded so pure
+seams still run inline). Good Dog regression test
+`AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread`
+(calls the seam from a raw MTA `Thread`, returns to the message loop, asserts on the UI thread
+that `VideoImageSource.Dispatcher` is the App's dispatcher — **red pre-fix, green post-fix,
+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)
@@ -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).
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — all
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)
- 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
- isolation when re-run alone. AudioPipeline timing flake similar.
+ 2026-09-22) — **close fullscreen windows before full-suite runs**; passes in isolation.
- 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
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
- `ReadInt64` (MyMistakes recipe), never `GetInt64`.
-- **API facts (TASK 44 + 45):** a liveChatId fetch before the broadcast is live returns nothing by
- design (read `snippet.liveChatId`, poll until active); `400 MISSING_REQUIRED_FIELD` from
- `liveChat/messages.insert` = wrong body shape (missing `snippet.type`), NOT auth/scope.
-- **WPF test technique (TASK 46):** a `MouseButtonEventArgs { RoutedEvent =
- Mouse.PreviewMouseLeftButtonDownEvent }` raised on the window root deterministically simulates an
- "outside the drawer rail" click — InputBindings are NOT triggered by `RaiseEvent`.
-- **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.
+- **API facts (TASK 44 + 45):** liveChatId unavailable before the broadcast is live (poll until
+ active); `400 MISSING_REQUIRED_FIELD` from `liveChat/messages.insert` = wrong body shape
+ (missing `snippet.type`), NOT auth/scope.
+- **TASK 47 sticky facts:** bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer
+ loop; default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play, never the refcounted
+ `MediaVideoSourceManager`. DB stays `user_version 10` — TASK 47's Source columns are additive
+ guarded ALTERs. Recipe in `MyMistakes.md`.
+- ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer now mirrors it.
- Every committed change needs a **close + relaunch** of the running app to be seen.
## Next step
-1. Run `scripts/scope-check.sh` with the declared file list (TASK 47), final clean build (verify,
- 0 warnings, `"/mnt/c/Program Files/dotnet/dotnet.exe" build ytLive.csproj`) + full vstest.
-2. Single commit (code + this unit's docs: `ai.md`, `TASKS.md`, `TASKS/task-47-alert-videos.md`,
- `Services/index.md`, `HANDOFF.md`, `MyMistakes.md`). Push at the creator's go/milestone.
-3. When scoped: TASK 3 item 20 (RewardEvents persistence half) draws next.
+1. Commit the crash fix (declared scope + same-commit docs), then run
+ `./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
+2. Full clean build (0 warnings, Windows dotnet host) + full vstest **320/320**.
+3. Push `a11b15e` + the fix commit at the creator's go / milestone signal.
+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)
diff --git a/MyMistakes.md b/MyMistakes.md
index 3d2f142..473771f 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -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.
diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs
index 6388c5c..9b2e0ca 100644
--- a/Services/AlertOverlayLayer.cs
+++ b/Services/AlertOverlayLayer.cs
@@ -87,10 +87,24 @@ public sealed class AlertOverlayLayer : IDisposable
}
}
- /// 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.
+ /// 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 ) — the preview
+ /// writes create WPF instances, and creating a
+ /// DependencySource off the UI thread crashes the WPF binding engine
+ /// (ArgumentException "Must create DependencySource on same Thread").
public void OnMessageReceived(ChatMessage message, IEnumerable 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 scenes)
{
_scenes = scenes;
Enqueue(message);
diff --git a/TASKS/task-47-alert-videos.md b/TASKS/task-47-alert-videos.md
index 9b8e273..c1e8c87 100644
--- a/TASKS/task-47-alert-videos.md
+++ b/TASKS/task-47-alert-videos.md
@@ -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
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)
- TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain
diff --git a/ytLive.Tests/AlertLayerVideoTests.cs b/ytLive.Tests/AlertLayerVideoTests.cs
index 916be36..dfa3a77 100644
--- a/ytLive.Tests/AlertLayerVideoTests.cs
+++ b/ytLive.Tests/AlertLayerVideoTests.cs
@@ -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 { 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);
+ });
+ }
+
/// A decoder seam the test drives synchronously (no ffmpeg, no threads):
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.
private sealed class FakeAlertClipDecoder : IAlertClipDecoder