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 */ }
+ }
+ }
+}