fix(lifecycle): end the session before disposing it on close
Closing with a recording active called Shutdown() straight away, which disposed the frame pump and the audio mixer underneath a running loop: ffmpeg never flushed, the file was never finalized or renamed, and the pump was torn down mid-iteration. That is the 'app goes autistic on close' report. MainWindow_Closing now holds the window open, runs StopBeforeShutdownAsync (the ordinary stop, bounded at ~6 s so a stuck encoder cannot make the app unclosable), then closes for real. StopStream already honours the audio-before-video ordering, so the close path inherits it instead of inventing its own teardown. Test: with nothing running, closing must not hold the window at all (the fast path stays fast). Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art, still awaiting their visual check.
This commit is contained in:
+21
-1
@@ -147,8 +147,28 @@ public partial class MainWindow : Window
|
|||||||
_topBar.RefreshAvatar();
|
_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();
|
SaveWindowGeometry();
|
||||||
_viewModel.Shutdown();
|
_viewModel.Shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,6 +616,25 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends a running session before the app tears itself down, and says whether it is safe to close.
|
||||||
|
///
|
||||||
|
/// <para>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).</para>
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<bool> 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()
|
public void Shutdown()
|
||||||
{
|
{
|
||||||
StopCurrentLaunchLogFeed();
|
StopCurrentLaunchLogFeed();
|
||||||
|
|||||||
@@ -91,6 +91,63 @@ public class TopBarWorldTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Closing mid-session must end the session first (2026-11-12). The clear case: with
|
||||||
|
/// nothing running, closing is immediate — no hold, no wait.</summary>
|
||||||
|
[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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<string>();
|
||||||
|
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]
|
[Fact]
|
||||||
public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives()
|
public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user