diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index b1b4c93..39a1671 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -147,8 +147,28 @@ public partial class MainWindow : Window
_topBar.RefreshAvatar();
}
- private void MainWindow_Closing(object? sender, CancelEventArgs e)
+ private bool _shutdownComplete;
+
+ private async void MainWindow_Closing(object? sender, CancelEventArgs e)
{
+ if (_shutdownComplete)
+ {
+ SaveWindowGeometry();
+ _viewModel.Shutdown();
+ return;
+ }
+
+ // A session must END before anything is disposed (2026-11-12: closing mid-recording used to
+ // pull the pump out from under itself). Hold the window open, stop properly, then close again.
+ if (_viewModel.IsLive || _viewModel.IsRecording)
+ {
+ e.Cancel = true;
+ await _viewModel.StopBeforeShutdownAsync();
+ _shutdownComplete = true;
+ Close();
+ return;
+ }
+
SaveWindowGeometry();
_viewModel.Shutdown();
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index f4c86e2..9dc8da6 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -616,6 +616,25 @@ public partial class MainViewModel : ViewModelBase
}
+ ///
+ /// Ends a running session before the app tears itself down, and says whether it is safe to close.
+ ///
+ /// Closing with a recording active used to dispose the frame pump and the audio mixer under a
+ /// live loop: ffmpeg never flushed, the file was never renamed or kept, and the pump was disposed
+ /// while it was still working. The window now holds itself open until the ordinary stop completes
+ /// (bounded — a stuck encoder must not trap the creator in an unclosable app).
+ ///
+ internal async Task StopBeforeShutdownAsync()
+ {
+ if (!IsLive && !IsRecording) return true;
+
+ StopStream();
+ for (var i = 0; i < 120 && (IsLive || IsRecording); i++) // up to ~6 s
+ await Task.Delay(50).ConfigureAwait(true);
+
+ return !IsLive && !IsRecording;
+ }
+
public void Shutdown()
{
StopCurrentLaunchLogFeed();
diff --git a/ytLive.Tests/TopBarWorldTests.cs b/ytLive.Tests/TopBarWorldTests.cs
index f6b19ba..98af355 100644
--- a/ytLive.Tests/TopBarWorldTests.cs
+++ b/ytLive.Tests/TopBarWorldTests.cs
@@ -91,6 +91,63 @@ public class TopBarWorldTests
}
}
+ /// Closing mid-session must end the session first (2026-11-12). The clear case: with
+ /// nothing running, closing is immediate — no hold, no wait.
+ [Fact]
+ public async Task Closing_with_nothing_running_does_not_hold_the_window()
+ {
+ var db = Path.Combine(Path.GetTempPath(), $"llamacasty-shutdown-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = db;
+ try
+ {
+ var vm = new MainViewModel();
+ var started = DateTime.UtcNow;
+ Assert.True(await vm.StopBeforeShutdownAsync());
+ Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(1));
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(db); } catch (IOException) { }
+ }
+ }
+
+ ///
+ /// The symptom the creator hit: pressing Start left the idle chip on screen with a dead command, so
+ /// the recording could not be stopped. Reading the property directly was never the test — the UI must
+ /// be TOLD the stage changed.
+ ///
+ [Fact]
+ public void Starting_or_stopping_a_recording_announces_the_stage_change()
+ {
+ var db = Path.Combine(Path.GetTempPath(), $"llamacasty-recannounce-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = db;
+ try
+ {
+ var vm = new MainViewModel();
+ vm.EnterOutputWorldCommand.Execute(null);
+ vm.SelectLocalMp4Command.Execute(null);
+
+ var raised = new System.Collections.Generic.List();
+ vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? string.Empty);
+
+ vm.IsRecording = true;
+ Assert.Contains(nameof(MainViewModel.ShowEndRecordingAction), raised);
+ Assert.Contains(nameof(MainViewModel.ShowStartRecordingAction), raised);
+ Assert.Contains(nameof(MainViewModel.RecordChipText), raised);
+
+ raised.Clear();
+ vm.IsRecording = false;
+ Assert.Contains(nameof(MainViewModel.ShowStartRecordingAction), raised);
+ Assert.Contains(nameof(MainViewModel.RecordChipText), raised);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(db); } catch (IOException) { }
+ }
+ }
+
[Fact]
public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives()
{