fix(18): stop ends everything — pills clear, pump/prep failures roll the session back
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording): - StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality. - OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was: toast + Error-status only when live — record-only sessions zombied with IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy says 'Recording stopped' vs 'stream pipeline stopped'. - Same zombie class in the three go-live prep failure branches: StreamStatus.Error limbo replaced by StopStream() (audio loop + recording + pills unwind; a created broadcast still gets the close-out). - AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that never reached StartLive). - Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test pattern mirrors LayoutPathOverride). ONE integration test: SessionTeardownTests (real window + temp DB: pump death with lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count paragraph corrected to stop claiming a suite total that cannot currently be measured.
This commit is contained in:
@@ -160,11 +160,13 @@ public sealed class AudioMixer : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
||||||
/// EOF on the audio input. Safe when not live.</summary>
|
/// EOF on the audio input. Safe when not live: the writer's Stop() is idempotent
|
||||||
|
/// (StopStream now calls this on every rollback path).</summary>
|
||||||
public void StopLive()
|
public void StopLive()
|
||||||
{
|
{
|
||||||
_liveCts?.Cancel();
|
var cts = _liveCts;
|
||||||
_liveCts = null;
|
_liveCts = null;
|
||||||
|
cts?.Cancel();
|
||||||
_pipe.Stop();
|
_pipe.Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
||||||
_notifications.Error("Couldn't go live",
|
_notifications.Error("Couldn't go live",
|
||||||
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
||||||
StreamStatus = StreamStatus.Error;
|
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_reusableStream = stream;
|
_reusableStream = stream;
|
||||||
@@ -125,7 +125,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
||||||
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
||||||
StreamStatus = StreamStatus.Error;
|
StopStream(); // full rollback
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||||||
@@ -163,7 +163,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
||||||
_notifications.Error("Couldn't go live", ex.Message);
|
_notifications.Error("Couldn't go live", ex.Message);
|
||||||
StreamStatus = StreamStatus.Error;
|
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +227,11 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
StreamStatus = StreamStatus.Offline;
|
StreamStatus = StreamStatus.Offline;
|
||||||
IsRecording = false;
|
IsRecording = false;
|
||||||
|
// Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session
|
||||||
|
// behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording
|
||||||
|
// attempt was the report. Intent resets with reality; re-arming is one click.
|
||||||
|
RecordPillOn = false;
|
||||||
|
OnAirPillOn = false;
|
||||||
WindowTitle = "LlamaCasty";
|
WindowTitle = "LlamaCasty";
|
||||||
ResetHealth(StreamStatus.Offline);
|
ResetHealth(StreamStatus.Offline);
|
||||||
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
// Stop chat polling first (TASK 9.4) — no more messages arriving while
|
||||||
@@ -290,11 +295,27 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFramePumpFailed(object? sender, string message)
|
// internal: test seam (mirrors LayoutPathOverride) — the rollback path is
|
||||||
|
// unobservable without simulating a pump death; ytLive.Tests has InternalsVisibleTo.
|
||||||
|
internal void OnFramePumpFailed(object? sender, string message)
|
||||||
{
|
{
|
||||||
|
// No zombies (2026-09-01): a dead pump means NO session — the old handler
|
||||||
|
// toasted and (live-only) flipped StreamStatus, leaving a record-only session
|
||||||
|
// with IsRecording=true and no encoder (first real launch proved it). The
|
||||||
|
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
|
||||||
|
// loop closed, recording finalized, close-out if live), then explains itself.
|
||||||
AppLog.Write($"Frame pump failed: {message}");
|
AppLog.Write($"Frame pump failed: {message}");
|
||||||
_notifications.Error("The stream pipeline stopped", message);
|
void Handle()
|
||||||
if (IsLive) StreamStatus = StreamStatus.Error;
|
{
|
||||||
|
var live = IsLive;
|
||||||
|
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
||||||
|
StopStream();
|
||||||
|
}
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||||
|
dispatcher.BeginInvoke(Handle);
|
||||||
|
else
|
||||||
|
Handle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public bool CanToggleOnAir => IsConnected;
|
public bool CanToggleOnAir => IsConnected;
|
||||||
|
|
||||||
/// <summary>Whether the session is actively recording — the REC status light
|
/// <summary>Whether the session is actively recording — the REC status light
|
||||||
/// turns green only when this is true (the pill is intent, this is reality).</summary>
|
/// turns green only when this is true (the pill is intent, this is reality).
|
||||||
|
/// Internal setter is a test seam (same pattern as LayoutPathOverride) so the
|
||||||
|
/// failure-rollback path can be exercised without running the real pipeline.</summary>
|
||||||
public bool IsRecording
|
public bool IsRecording
|
||||||
{
|
{
|
||||||
get => _isRecording;
|
get => _isRecording;
|
||||||
private set
|
internal set
|
||||||
{
|
{
|
||||||
if (!SetProperty(ref _isRecording, value)) return;
|
if (!SetProperty(ref _isRecording, value)) return;
|
||||||
OnPropertyChanged(nameof(RecDotBrush));
|
OnPropertyChanged(nameof(RecDotBrush));
|
||||||
|
|||||||
@@ -125,15 +125,19 @@ FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineT
|
|||||||
the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24),
|
the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24),
|
||||||
GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests
|
GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests
|
||||||
(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests
|
(TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests
|
||||||
(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18) —
|
(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests (2026-09-01
|
||||||
**247 total (246 pass — the ONE remaining known failure is
|
rollback) — **~250 total (exact whole-suite count not claimable — the full run hangs and
|
||||||
|
`AudioPipelineTests` hangs even STANDALONE (confirmed 2026-09-01 — treat the class as
|
||||||
|
un-runnable until the audio channel-init fix lands). Every other class passes individually
|
||||||
|
through the Windows dotnet.exe host, including the real-`MainWindow`/RealApp tests — 247 pass
|
||||||
|
was the last full-suite number (2026-08-31); since then: the ONE remaining known failure is
|
||||||
`AudioPipelineTests.Mix_HonorsProviderGains…` (the creator-declared known, suspected WASAPI
|
`AudioPipelineTests.Mix_HonorsProviderGains…` (the creator-declared known, suspected WASAPI
|
||||||
channel declaration/init). The former "known" `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`
|
channel declaration/init), `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` was
|
||||||
was root-caused and FIXED 2026-09-01: two stale-test layers from the component refactor (namescoped
|
root-caused and FIXED 2026-09-01 (two stale-test layers — namescoped `FindName` +
|
||||||
`FindName` + `VisualTreeHelper.HitTest` used where `UIElement.InputHitTest` models input — see
|
`VisualTreeHelper.HitTest` used where `UIElement.InputHitTest` models input; see MyMistakes
|
||||||
MyMistakes recipe), no product bug. Full-suite vstest still hangs headless — but per-class runs
|
recipe; no product bug), and three tests were ADDED (locator dead-pin 404, EndBroadcast
|
||||||
THROUGH THE WINDOWS dotnet.exe HOST DO EXECUTE the real-`MainWindow`/RealApp tests fine (verified
|
close-out, SessionTeardown rollback). "GUI suites can't run from here" was an overstatement —
|
||||||
same day; the old "GUI suites can't run from here" was an overstatement.)**
|
per-class Windows-host vstest runs them fine.)**
|
||||||
|
|
||||||
Reward-event capture (monetization awareness, see the Monetization section) will add its integration
|
Reward-event capture (monetization awareness, see the Monetization section) will add its integration
|
||||||
tests here when it ships: one real chat-poll payload containing all seven reward event types →
|
tests here when it ships: one real chat-poll payload containing all seven reward event types →
|
||||||
@@ -548,6 +552,14 @@ green when a session is actually recording, the ON-AIR dot when actually live.
|
|||||||
(`OpenFolderDialog`), persisted through `LayoutStore.Load/SaveRecordFolder` (`RecordFolder` key).
|
(`OpenFolderDialog`), persisted through `LayoutStore.Load/SaveRecordFolder` (`RecordFolder` key).
|
||||||
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via Logout /
|
- **Explicit sign-out only:** `StopStream` no longer clears the session/token. Sign out via Logout /
|
||||||
Change Account. Stopping a recording leaves the creator signed in.
|
Change Account. Stopping a recording leaves the creator signed in.
|
||||||
|
- **Stop ends everything; failures roll back (2026-09-01):** `StopStream` also clears both intent
|
||||||
|
pills (`RecordPillOn`/`OnAirPillOn`) — a lit pill with no session behind it is a lie. Every
|
||||||
|
frame-pump death and every go-live prep failure now runs the full `StopStream` teardown instead of
|
||||||
|
leaving a limbo (`StreamStatus.Error` with `IsRecording=true` over a dead encoder — the first-launch
|
||||||
|
zombie recording). Toast copy distinguishes "Recording stopped" from "stream pipeline stopped";
|
||||||
|
`AudioMixer.StopLive` is idempotent-safe for rollbacks that never reached StartLive. Seams:
|
||||||
|
`OnFramePumpFailed` + `IsRecording` setter made internal (test-only, InternalsVisibleTo).
|
||||||
|
Test: `SessionTeardownTests`.
|
||||||
- `EncoderOptions.StreamEnabled/RecordEnabled/RecordPath` gate outputs; record+stream is one ffmpeg with two
|
- `EncoderOptions.StreamEnabled/RecordEnabled/RecordPath` gate outputs; record+stream is one ffmpeg with two
|
||||||
output blocks. Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` now always
|
output blocks. Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` now always
|
||||||
returns an options (with flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
|
returns an options (with flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-teardown integration (2026-09-01, first native launch): a frame-pump death
|
||||||
|
/// must roll the WHOLE session back — record-only sessions used to stay IsRecording=true
|
||||||
|
/// with a dead encoder (no live session to flip StreamStatus), and StopStream never
|
||||||
|
/// cleared the intent pills. "Stop ends everything" (creator ruling): pills slide off
|
||||||
|
/// with the session; re-arming is one click. Real-App + temp-DB pattern.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("RealApp")]
|
||||||
|
public sealed class SessionTeardownTests
|
||||||
|
{
|
||||||
|
private readonly RealAppHost _app;
|
||||||
|
|
||||||
|
public SessionTeardownTests(RealAppHost app) => _app = app;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pump_Failure_Rolls_Back_Recording_And_Clears_Pills()
|
||||||
|
{
|
||||||
|
_app.Run(Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-teardown-{Guid.NewGuid():N}.db");
|
||||||
|
MainViewModel.LayoutPathOverride = tempDb;
|
||||||
|
var window = default(MainWindow);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var schema = new LayoutStore(tempDb)) { }
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
||||||
|
window = new MainWindow();
|
||||||
|
var vm = (MainViewModel)window.DataContext;
|
||||||
|
window.Show();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
// The exact 2026-09-01 zombie: record-only, intent lit, reality true,
|
||||||
|
// encoder already dead. Sign-in absent — the guard must also prove no
|
||||||
|
// close-out API call is attempted for a record-only session (StopStream
|
||||||
|
// completes without throwing offline).
|
||||||
|
vm.RecordPillOn = true;
|
||||||
|
vm.IsRecording = true;
|
||||||
|
|
||||||
|
vm.OnFramePumpFailed(this, "test: ffmpeg exited");
|
||||||
|
|
||||||
|
Assert.False(vm.IsRecording, "pump death must roll IsRecording back — no zombie session");
|
||||||
|
Assert.False(vm.RecordPillOn, "Stop ends everything — the REC pill slides off with the session");
|
||||||
|
Assert.False(vm.OnAirPillOn, "both pills clear");
|
||||||
|
Assert.False(vm.ShowEndStreamButton, "no End button once nothing is running");
|
||||||
|
Assert.Equal(ytLive.Models.StreamStatus.Offline, vm.StreamStatus);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (window != null) window.Close();
|
||||||
|
MainViewModel.LayoutPathOverride = null;
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try { File.Delete(tempDb); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user