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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -87,10 +87,24 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>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 <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)
|
||||
{
|
||||
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;
|
||||
Enqueue(message);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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):
|
||||
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.</summary>
|
||||
private sealed class FakeAlertClipDecoder : IAlertClipDecoder
|
||||
|
||||
Reference in New Issue
Block a user