From 9621db29c9bd71e4b06627771ad38116d1bd13ca Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 11 Nov 2026 09:57:26 -0800 Subject: [PATCH] fix(stream): keep recovery notifications and stop on the dispatcher The FFmpeg ProcessFailed event arrives off-thread. Start recovery on the UI dispatcher, publish retry status there, and route StopStream back to the dispatcher before changing bound state or alert collections. This prevents a recovery exception from becoming an unhandled WPF CollectionView crash. AttentionAlertStackTests covers worker-thread recovery reporting and stop. 616 tests green, clean build with 0 warnings. --- HANDOFF.md | 14 ++++-- ViewModels/MainViewModel.Recovery.cs | 44 ++++++++++------- .../MainViewModel.Streaming.Operations.cs | 7 +++ ytLive.Tests/AttentionAlertStackTests.cs | 48 +++++++++++++++++++ 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 832c7f9..40f9816 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,18 +1,24 @@ -# HANDOFF — 2026-11-11 (TASK 37 complete; TASK 69 queued) +# HANDOFF — 2026-11-11 (recovery fixes; creator UI order 1→2→3) ## State -- `main`. App code is pushed through `b265d42`; queue-only docs commits are local. +- `main`; `b265d42` is the latest pushed commit. Step-down audio fix `24077e5` and recovery-dispatcher + fix are local and verified; these fixes are not pushed yet. - **Done:** TASK 35 scene-linked audio, TASK 55 disk hygiene, TASK 65 replay clips, TASK 37 defaults/current split. - TASK 37: SQLite keeps an immutable default snapshot beside build-stamped current rows; App Settings can restore the staged scene or all scenes, offline only. Webcam identity survives both restore paths. - TASK 14·15 mic input gain was closed by creator test (−2.6 dB, works great); slices 16–18 remain. -- Verification: **615 tests green, clean build with 0 warnings** (`FAST=1 ./scripts/verify.sh`). +- Recovery incident: private test hit step-down on 85% loss; encoder restart reused a broken audio pipe. + Step-down now closes/recreates audio in order; recovery notifications/stops marshal to the dispatcher. +- Verification: **616 tests green, clean build with 0 warnings** (`FAST=1 ./scripts/verify.sh`). Creator test + was stable (~5.1 Mbps, 31 fps, 0.2% drop); its log showed no step-down/reconnect event. - Steam remains blocked on Valve identity/tax review; no app IDs or store page. ## Next action -- Queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**. +- First: creator-directed **1) global alert placement → 2) sticky click-to-dismiss alerts → 3) core-metric + change cues**, one test/commit each. Then resume **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → + 68 → 69 → 70 → 36**. - Ask creator before TASK 68 about first-run ON-AIR default; before gold pass, settle the Release single-instance mutex behavior. - TASK 34 scheduling and TASK 50 recording publish are retired; YouTube owns those surfaces. diff --git a/ViewModels/MainViewModel.Recovery.cs b/ViewModels/MainViewModel.Recovery.cs index 0f80b86..a224266 100644 --- a/ViewModels/MainViewModel.Recovery.cs +++ b/ViewModels/MainViewModel.Recovery.cs @@ -152,6 +152,13 @@ public partial class MainViewModel private void OnEncoderFailed(object? sender, string message) { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.BeginInvoke(new Action(() => OnEncoderFailed(sender, message))); + return; + } + if (!IsLive && !IsRecording) return; if (_recoveryCts != null) return; // one recovery at a time if (!IsLive) return; // a recording-only session has nothing to relive @@ -203,22 +210,7 @@ public partial class MainViewModel _ => BroadcastLiveness.Live, }; }, - Report = text => - { - var progress = text.StartsWith("Stream dropped", StringComparison.Ordinal) - ? ParseAttempt(text) - : 0; - if (progress > 0) ReconnectAttempt = progress; - - var to = AppLog.SnapshotCurrentLaunch() is { Count: > 0 } entries - ? entries[^1].Sequence - : logFrom; - PushAttentionAlert( - "stream-recovery", - text, - logFrom, - Math.Max(logFrom, to)); - }, + Report = text => ReportRecoveryStatus(text, logFrom), Log = line => AppLog.Write(line, AppLogMessageType.Error, AppLogErrorClass.Class2), }); @@ -267,6 +259,26 @@ public partial class MainViewModel } } + internal void ReportRecoveryStatus(string text, long logFrom) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + dispatcher.Invoke(() => ReportRecoveryStatus(text, logFrom)); + return; + } + + var progress = text.StartsWith("Stream dropped", StringComparison.Ordinal) + ? ParseAttempt(text) + : 0; + if (progress > 0) ReconnectAttempt = progress; + + var to = AppLog.SnapshotCurrentLaunch() is { Count: > 0 } entries + ? entries[^1].Sequence + : logFrom; + PushAttentionAlert("stream-recovery", text, logFrom, Math.Max(logFrom, to)); + } + private static int ParseAttempt(string text) { // "Stream dropped — reconnecting (attempt 2 of 4)" diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index e717f78..f36878f 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -406,6 +406,13 @@ public partial class MainViewModel : ViewModelBase private async void StopStream() { + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + { + _ = dispatcher.BeginInvoke(new Action(StopStream)); + return; + } + CancelStreamRecovery(); // a stop wins over a relaunch in flight StopStepDownWatch(); StopClipRing(); diff --git a/ytLive.Tests/AttentionAlertStackTests.cs b/ytLive.Tests/AttentionAlertStackTests.cs index 4ac8a85..a79a751 100644 --- a/ytLive.Tests/AttentionAlertStackTests.cs +++ b/ytLive.Tests/AttentionAlertStackTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Models; @@ -126,6 +127,53 @@ public sealed class AttentionAlertStackTests }); } + [Fact] + public async Task RecoveryReportAndStop_FromWorkerThread_MarshalAlertChangesToDispatcher() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainViewModel? vm = null; + try + { + await _app.RunAsync(async () => + { + vm = new MainViewModel(); + var dispatcher = System.Windows.Application.Current!.Dispatcher; + var changedOnUi = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + vm.AttentionAlerts.CollectionChanged += (_, _) => + changedOnUi.TrySetResult(dispatcher.CheckAccess()); + + await Task.Run(() => vm.ReportRecoveryStatus( + "Stream dropped — reconnecting (attempt 1 of 4)", 10)); + Assert.True(await changedOnUi.Task.WaitAsync(TimeSpan.FromSeconds(5))); + + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + vm.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(vm.StreamStatus) && vm.IsOffline) + stopped.TrySetResult(dispatcher.CheckAccess()); + }; + vm.StreamStatus = ytLive.Models.StreamStatus.Streaming; + + await Task.Run(() => vm.EndStreamCommand.Execute(null)); + Assert.True(await stopped.Task.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.DoesNotContain(vm.AttentionAlerts, alert => alert.Id == "stream-recovery"); + }); + } + finally + { + if (vm != null) + await _app.RunAsync(() => + { + vm.Shutdown(); + return Task.CompletedTask; + }); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + private void WithVm(Action body) => WithVm((vm, _) => body(vm)); private void WithVm(Action body)