diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs index 3884488..e63f8c0 100644 --- a/Services/Audio/AudioMixer.cs +++ b/Services/Audio/AudioMixer.cs @@ -160,11 +160,13 @@ public sealed class AudioMixer : IDisposable } /// Ends the live mix loop and closes the audio pipe — ffmpeg sees - /// EOF on the audio input. Safe when not live. + /// EOF on the audio input. Safe when not live: the writer's Stop() is idempotent + /// (StopStream now calls this on every rollback path). public void StopLive() { - _liveCts?.Cancel(); + var cts = _liveCts; _liveCts = null; + cts?.Cancel(); _pipe.Stop(); } diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 9de1e9b..f03207b 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -112,7 +112,7 @@ public partial class MainViewModel : ViewModelBase AppLog.Write("Reusable stream unavailable; check the OAuth session"); _notifications.Error("Couldn't go live", "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; } _reusableStream = stream; @@ -125,7 +125,7 @@ public partial class MainViewModel : ViewModelBase { 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."); - StreamStatus = StreamStatus.Error; + StopStream(); // full rollback return; } AppLog.Write($"Broadcast created: {_currentBroadcastId}"); @@ -163,7 +163,7 @@ public partial class MainViewModel : ViewModelBase { AppLog.Write($"Go-live preparation failed: {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; } @@ -227,6 +227,11 @@ public partial class MainViewModel : ViewModelBase StreamStatus = StreamStatus.Offline; 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"; ResetHealth(StreamStatus.Offline); // 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}"); - _notifications.Error("The stream pipeline stopped", message); - if (IsLive) StreamStatus = StreamStatus.Error; + void Handle() + { + 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/ diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 1f19e8d..ce78171 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -107,11 +107,13 @@ public partial class MainViewModel : ViewModelBase public bool CanToggleOnAir => IsConnected; /// Whether the session is actively recording — the REC status light - /// turns green only when this is true (the pill is intent, this is reality). + /// 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. public bool IsRecording { get => _isRecording; - private set + internal set { if (!SetProperty(ref _isRecording, value)) return; OnPropertyChanged(nameof(RecDotBrush)); diff --git a/ai.md b/ai.md index 95d479f..5e7ee4b 100644 --- a/ai.md +++ b/ai.md @@ -125,15 +125,19 @@ FfmpegEncoderTests, FramePumpTests, the TASK 8/11/12 audio chain (AudioPipelineT the Socials fediverse-heal roundtrip, AboutHubTests, NotificationAreaIntegrationTests (TASK 24), GlobalHotkeyTests + HotkeyConfigTests (TASK 20), WebcamMenuGateTests (TASK 26), ChatLayerGateTests (TASK 27), BroadcastPullOutTests (TASK 29), DefaultRecordFolder fallback (TASK 30), WebView2ManagerTests -(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18) — -**247 total (246 pass — the ONE remaining known failure is +(TASK 17), RecordingFileTests + OnAirSignTests (TASK 18), SessionTeardownTests (2026-09-01 +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 -channel declaration/init). The former "known" `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` -was root-caused and FIXED 2026-09-01: two stale-test layers from the component refactor (namescoped -`FindName` + `VisualTreeHelper.HitTest` used where `UIElement.InputHitTest` models input — see -MyMistakes recipe), no product bug. Full-suite vstest still hangs headless — but per-class runs -THROUGH THE WINDOWS dotnet.exe HOST DO EXECUTE the real-`MainWindow`/RealApp tests fine (verified -same day; the old "GUI suites can't run from here" was an overstatement.)** +channel declaration/init), `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…` was +root-caused and FIXED 2026-09-01 (two stale-test layers — namescoped `FindName` + +`VisualTreeHelper.HitTest` used where `UIElement.InputHitTest` models input; see MyMistakes +recipe; no product bug), and three tests were ADDED (locator dead-pin 404, EndBroadcast +close-out, SessionTeardown rollback). "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 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). - **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. +- **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 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. diff --git a/ytLive.Tests/SessionTeardownTests.cs b/ytLive.Tests/SessionTeardownTests.cs new file mode 100644 index 0000000..670608e --- /dev/null +++ b/ytLive.Tests/SessionTeardownTests.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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. +/// +[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 */ } + } + } +}