Fix truncated-segment and mid-stream failure paths in segmented loop
cursor>=totalLength is the sole forward-EOF test; a short non-final body is a truncation error, not EOF. Mid-stream forward-load failures now invoke RecoverFromFailedRefill so the scheduler halts instead of a silent false end. Two regression tests pin both paths.
This commit is contained in:
@@ -284,10 +284,26 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
var userError = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
|
||||
// Mid-stream failure (playback was already underway): halt the JS scheduler into a clean
|
||||
// paused-but-loaded state exactly as the seek path does via RecoverFromFailedRefill, rather
|
||||
// than resetting to unloaded and letting the scheduler's buffered tail drain into a silent
|
||||
// false end (AC6). Apply only when this load is still the active operation — a superseding
|
||||
// seek owns state and has already replaced _streamingCancellation with its own CTS.
|
||||
if (_streamingPlaybackStarted && ReferenceEquals(_streamingCancellation, loadCts))
|
||||
{
|
||||
await RecoverFromFailedRefill(CurrentTime, userError);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First-segment failure (nothing buffered / playing yet) or superseded load: the
|
||||
// normal unload-to-error path is correct — no buffered tail to halt.
|
||||
ErrorMessage = userError;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -496,6 +512,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
CanStartStreaming = chunkResult.CanStartStreaming;
|
||||
HeaderParsed = chunkResult.HeaderParsed;
|
||||
BufferedChunks = chunkResult.BufferCount;
|
||||
// chunkResult.ProductionPaused is informational only on this path — back-pressure
|
||||
// granularity is one segment (the inter-segment gate below), not per-chunk.
|
||||
|
||||
// Set duration from header when available (only set once)
|
||||
if (chunkResult.Duration.HasValue && Duration == null)
|
||||
@@ -561,15 +579,28 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
segment.Dispose();
|
||||
segment = null!;
|
||||
|
||||
// EOF: cursor reached the total, OR the server returned a short final slice (fewer
|
||||
// bytes than the segment we requested). Either way there is nothing left to fetch.
|
||||
// EOF: cursor reached the total file length. This is the sole forward-EOF condition.
|
||||
// A short segment body (segmentBytesRead < SegmentSizeBytes) is NOT an EOF signal —
|
||||
// the inner read loop fully drains the HTTP body, so a short body means the server
|
||||
// sent fewer bytes than the bounded range we requested. While cursor < totalLength that
|
||||
// can only be a connection drop / truncated stream, NOT the file tail — route it to
|
||||
// the same clean-failure recovery as a fetch error rather than silently completing.
|
||||
var reachedTotal = totalLength > 0 && cursor >= totalLength;
|
||||
var shortSegment = segmentBytesRead < SegmentSizeBytes;
|
||||
if (reachedTotal || shortSegment)
|
||||
if (reachedTotal)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Guard: if the body was short but we haven't reached totalLength, the stream was
|
||||
// truncated mid-segment (connection drop / premature close). Surface as an error so
|
||||
// the scheduler is halted rather than left to drain its buffered tail into a false end.
|
||||
if (segmentBytesRead < SegmentSizeBytes)
|
||||
{
|
||||
throw new Exception(
|
||||
$"Stream truncated at byte {cursor} of {totalLength}: received {segmentBytesRead} bytes " +
|
||||
$"but expected up to {SegmentSizeBytes} and have not reached EOF");
|
||||
}
|
||||
|
||||
// Inter-segment back-pressure gate (Phase 21.2 fill signal, now gating SEGMENT FETCH
|
||||
// instead of pacing ReadAsync on an open stream). Do not fetch the next segment while
|
||||
// the scheduler is over high-water; wait until it drains below low-water. Because the
|
||||
@@ -849,9 +880,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
|
||||
// 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
|
||||
// the seek or pick another track; resetting to unloaded would evict the track identity.
|
||||
ErrorMessage = userFacingError;
|
||||
IsPlaying = false;
|
||||
IsPaused = true;
|
||||
IsLoaded = true;
|
||||
CurrentTime = seekPosition;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
await NotifyStateChanged();
|
||||
|
||||
Reference in New Issue
Block a user