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:
2026-11-13 07:47:35 -08:00
parent 70fcf89f9e
commit 1ce6b9c1b8
3 changed files with 97 additions and 1 deletions
+57
View File
@@ -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]
public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives()
{