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.
This commit is contained in:
+10
-4
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<bool>(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<bool>(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<MainViewModel> body) => WithVm((vm, _) => body(vm));
|
||||
|
||||
private void WithVm(Action<MainViewModel, string> body)
|
||||
|
||||
Reference in New Issue
Block a user