Restore IsStreamingMode on recovery; guard superseded-load else-branch

RecoverFromFailedRefill now sets IsStreamingMode=true so the in-place
seek-retry route isn't wedged. The generic-catch unload path is gated on
the loadCts identity, so a superseded load no longer clobbers a newer
operation's state.
This commit is contained in:
daniel-c-harvey
2026-06-24 15:37:38 -04:00
parent e7762e35e8
commit cc9d20184d
2 changed files with 20 additions and 6 deletions
@@ -295,15 +295,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{ {
await RecoverFromFailedRefill(CurrentTime, userError); await RecoverFromFailedRefill(CurrentTime, userError);
} }
else else if (ReferenceEquals(_streamingCancellation, loadCts))
{ {
// First-segment failure (nothing buffered / playing yet) or superseded load: the // First-segment failure (nothing buffered / playing yet), still the active operation:
// normal unload-to-error path is correct — no buffered tail to halt. // the normal unload-to-error path is correct — nothing is in the scheduler to halt.
ErrorMessage = userError; ErrorMessage = userError;
LoadProgress = 0; LoadProgress = 0;
IsLoaded = false; IsLoaded = false;
IsStreamingMode = false; IsStreamingMode = false;
} }
else
{
// Superseded load: a newer seek (or track switch) has already claimed _streamingCancellation
// and owns all shared state. Writing IsLoaded/IsStreamingMode here would corrupt the live
// operation — mirror the OCE catch's identity guard and do nothing to shared state.
_logger.LogDebug("Generic throw on superseded load for track {TrackId} — newer operation owns state, skipping unload", track.EntryKey);
}
} }
finally finally
{ {
@@ -879,13 +886,16 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
_logger.LogWarning("Refill-failure recovery interop did not succeed: {Error}", recovered.Error); _logger.LogWarning("Refill-failure recovery interop did not succeed: {Error}", recovered.Error);
} }
// Settle C# into the matching recoverable state: not playing, paused at the target, still loaded. // Settle C# into the matching recoverable state: not playing, paused at the target, still loaded
// IsLoaded = true is load-bearing — the "paused-but-loaded" contract lets the listener retry // and still in streaming mode. IsLoaded = true and IsStreamingMode = true are both load-bearing —
// the seek or pick another track; resetting to unloaded would evict the track identity. // the "paused-but-loaded" contract lets the listener retry the seek (Seek early-returns when
// !IsLoaded || !IsStreamingMode), resume via TogglePlayPause, or pick another track. Resetting
// either to false would wedge at least one of the three retry routes (AC6 / Phase 21.3).
ErrorMessage = userFacingError; ErrorMessage = userFacingError;
IsPlaying = false; IsPlaying = false;
IsPaused = true; IsPaused = true;
IsLoaded = true; IsLoaded = true;
IsStreamingMode = true;
CurrentTime = seekPosition; CurrentTime = seekPosition;
IsSeekingBeyondBuffer = false; IsSeekingBeyondBuffer = false;
await NotifyStateChanged(); await NotifyStateChanged();
@@ -353,6 +353,8 @@ public class SegmentedStreamLoopTests
"a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery"); "a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery");
Assert.That(player.IsLoaded, Is.True, Assert.That(player.IsLoaded, Is.True,
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); "recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
Assert.That(player.IsStreamingMode, Is.True,
"recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)");
Assert.That(player.IsPaused, Is.True, Assert.That(player.IsPaused, Is.True,
"recovery settles into a paused state, not playing"); "recovery settles into a paused state, not playing");
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty, Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,
@@ -381,6 +383,8 @@ public class SegmentedStreamLoopTests
"a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain"); "a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain");
Assert.That(player.IsLoaded, Is.True, Assert.That(player.IsLoaded, Is.True,
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); "recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
Assert.That(player.IsStreamingMode, Is.True,
"recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)");
Assert.That(player.IsPaused, Is.True, Assert.That(player.IsPaused, Is.True,
"recovery settles into a paused state, not playing"); "recovery settles into a paused state, not playing");
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty, Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,