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