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:
+50
-55
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user