Merge branch 'seek-load-race' into dev

This commit is contained in:
daniel-c-harvey
2026-06-10 14:31:48 -04:00
@@ -92,13 +92,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// track while it's still loading, not only after playback starts. // track while it's still loading, not only after playback starts.
CurrentTrack = track; CurrentTrack = track;
// Create new cancellation token for this streaming operation // Create new cancellation token for this streaming operation. Capture it in a local
_streamingCancellation = new CancellationTokenSource(); // so the catch/finally can compare identity against _streamingCancellation: a seek
// replaces _streamingCancellation with its own seekCts before this load's continuation
// resumes on the single-threaded WASM dispatcher, and we must not clobber the seek's state.
var loadCts = new CancellationTokenSource();
_streamingCancellation = loadCts;
// Fetch the waveform profile alongside the audio. Fire-and-forget against the same // Fetch the waveform profile alongside the audio. Fire-and-forget against the same
// streaming token so a track switch abandons it; it only updates display state and must // streaming token so a track switch abandons it; it only updates display state and must
// never gate or fail the audio load (a missing profile yields the flat-seekbar fallback). // never gate or fail the audio load (a missing profile yields the flat-seekbar fallback).
_ = LoadWaveformProfileAsync(track.EntryKey, _streamingCancellation.Token); _ = LoadWaveformProfileAsync(track.EntryKey, loadCts.Token);
try try
{ {
@@ -119,7 +123,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
var mediaResult = await _trackMediaClient.GetTrackMedia( var mediaResult = await _trackMediaClient.GetTrackMedia(
track.EntryKey, track.EntryKey,
byteOffset: 0, byteOffset: 0,
cancellationToken: _streamingCancellation.Token); cancellationToken: loadCts.Token);
if (!mediaResult.Success) if (!mediaResult.Success)
{ {
var technicalError = mediaResult.GetMessage(); var technicalError = mediaResult.GetMessage();
@@ -150,16 +154,25 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token); _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
await _activeStreamingTask; await _activeStreamingTask;
} }
catch (OperationCanceledException) catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
{ {
// Cancellation is expected, reset state // Cancellation is expected when this load was superseded (track switch or seek).
// The when filter ensures HttpClient timeout OCEs — where loadCts was NOT
// cancelled — fall through to the error handler below instead of being swallowed.
_logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey); _logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);
// Only reset streaming state if this load is still the active operation. A seek
// in flight has already replaced _streamingCancellation with its own seekCts and
// owns IsLoaded/IsStreamingMode; clobbering them here corrupts the seek mid-flight.
if (ReferenceEquals(_streamingCancellation, loadCts))
{
IsLoaded = false; IsLoaded = false;
IsStreamingMode = false; IsStreamingMode = false;
} }
}
catch (Exception ex) catch (Exception ex)
{ {
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey); StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
@@ -171,9 +184,14 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
finally finally
{ {
IsLoading = false; IsLoading = false;
// Only notify if this load is still the active operation. A superseding seek
// owns state notifications; firing here mid-seek would push a stale snapshot.
if (ReferenceEquals(_streamingCancellation, loadCts))
{
await NotifyStateChanged(); await NotifyStateChanged();
} }
} }
}
/// <summary> /// <summary>
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the /// Fetches and decodes the track's waveform loudness profile, then notifies state so the
@@ -429,11 +447,21 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// OperationCanceledException asynchronously; if we kick off a new loop // OperationCanceledException asynchronously; if we kick off a new loop
// immediately, both can race against the single-instance JS StreamDecoder // immediately, both can race against the single-instance JS StreamDecoder
// and corrupt decode state. Draining here is the load-bearing guarantee. // and corrupt decode state. Draining here is the load-bearing guarantee.
_streamingCancellation?.Cancel(); //
await DrainActiveStreamingTaskAsync(); // Invariant: any caller that supersedes a load WITHOUT wanting the load's
_streamingCancellation?.Dispose(); // state reset must assign its own CTS to _streamingCancellation *before*
// its first await. LoadTrackStreaming's OCE continuation fires during the
// drain await on the shared _activeStreamingTask; it resets IsLoaded/
// IsStreamingMode only when _streamingCancellation still equals its loadCts.
// Assigning seekCts synchronously here makes that identity check fail, so
// the seek's state survives. (ResetToIdle deliberately does NOT do this —
// it wants the reset, and nulls _streamingCancellation only after the drain.)
var oldCts = _streamingCancellation;
var seekCts = new CancellationTokenSource(); var seekCts = new CancellationTokenSource();
_streamingCancellation = seekCts; _streamingCancellation = seekCts;
oldCts?.Cancel();
await DrainActiveStreamingTaskAsync();
oldCts?.Dispose();
try try
{ {