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:
2026-09-01 21:15:57 -07:00
parent 688682d5b5
commit 5a1a3c566a
5 changed files with 123 additions and 18 deletions
@@ -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/