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:
2026-11-11 09:57:26 -08:00
parent 24077e58cf
commit 9621db29c9
4 changed files with 93 additions and 20 deletions
+28 -16
View File
@@ -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();