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:
@@ -64,6 +64,84 @@ public class SegmentedStreamLoopTests
|
||||
}
|
||||
}
|
||||
|
||||
// Serves the first segment normally, then truncates subsequent segment bodies to a short slice
|
||||
// (Content-Range reports the correct total, but the HTTP body ends early — simulating a
|
||||
// connection drop mid-segment while cursor < totalLength).
|
||||
private sealed class TruncatingAfterFirstSegmentServer : HttpMessageHandler
|
||||
{
|
||||
private readonly long _total;
|
||||
private readonly long _truncatedBodyBytes; // bytes to actually deliver for non-first segments
|
||||
private int _audioRequestCount;
|
||||
|
||||
public TruncatingAfterFirstSegmentServer(long total, long truncatedBodyBytes)
|
||||
{
|
||||
_total = total;
|
||||
_truncatedBodyBytes = truncatedBodyBytes;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
|
||||
var rangeItem = request.Headers.Range!.Ranges.First();
|
||||
var from = rangeItem.From ?? 0;
|
||||
var to = rangeItem.To ?? (_total - 1);
|
||||
if (to > _total - 1) to = _total - 1;
|
||||
|
||||
var requestIndex = Interlocked.Increment(ref _audioRequestCount);
|
||||
// First segment (requestIndex == 1): serve fully. Subsequent segments: truncate.
|
||||
var fullSliceLength = to - from + 1;
|
||||
var bodyLength = requestIndex == 1 ? fullSliceLength : Math.Min(_truncatedBodyBytes, fullSliceLength);
|
||||
var body = new byte[bodyLength];
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
|
||||
{
|
||||
Content = new ByteArrayContent(body),
|
||||
};
|
||||
// Content-Range always reports the true full total — the truncation is in the body, not the header.
|
||||
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
// Serves the first segment normally, then returns HTTP 500 for all subsequent requests —
|
||||
// simulating a mid-stream fetch failure after playback is underway.
|
||||
private sealed class FailingAfterFirstSegmentServer : HttpMessageHandler
|
||||
{
|
||||
private readonly long _total;
|
||||
private int _audioRequestCount;
|
||||
|
||||
public FailingAfterFirstSegmentServer(long total) => _total = total;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
|
||||
var requestIndex = Interlocked.Increment(ref _audioRequestCount);
|
||||
if (requestIndex > 1)
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
|
||||
var rangeItem = request.Headers.Range!.Ranges.First();
|
||||
var from = rangeItem.From ?? 0;
|
||||
var to = rangeItem.To ?? (_total - 1);
|
||||
if (to > _total - 1) to = _total - 1;
|
||||
|
||||
var body = new byte[to - from + 1];
|
||||
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
|
||||
{
|
||||
Content = new ByteArrayContent(body),
|
||||
};
|
||||
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
@@ -97,6 +175,7 @@ public class SegmentedStreamLoopTests
|
||||
public int ReinitCallCount { get; private set; }
|
||||
public int MarkCompleteCallCount { get; private set; }
|
||||
public int IsProductionPausedCallCount { get; private set; }
|
||||
public int RecoverCallCount { get; private set; }
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
{
|
||||
@@ -131,6 +210,9 @@ public class SegmentedStreamLoopTests
|
||||
case "DeepDrftAudio.markStreamComplete":
|
||||
MarkCompleteCallCount++;
|
||||
return (ValueTask<TValue>)(object)ValueTask.FromResult(new StreamingResult { Success = true });
|
||||
case "DeepDrftAudio.recoverFromFailedRefill":
|
||||
RecoverCallCount++;
|
||||
return Result<TValue>(true);
|
||||
default:
|
||||
// createPlayer / setOnProgressCallback / setOnEndCallback / setVolume /
|
||||
// ensureAudioContextReady / initializeStreaming / startStreamingPlayback /
|
||||
@@ -151,7 +233,7 @@ public class SegmentedStreamLoopTests
|
||||
|
||||
private static TrackDto Track() => new() { EntryKey = "mix-1", TrackName = "Long Mix" };
|
||||
|
||||
private static StreamingAudioPlayerService BuildPlayer(SegmentServer server, FakeJsRuntime js)
|
||||
private static StreamingAudioPlayerService BuildPlayer(HttpMessageHandler server, FakeJsRuntime js)
|
||||
{
|
||||
var interop = new AudioInteropService(js);
|
||||
var media = new TrackMediaClient(new SingleClientFactory(server));
|
||||
@@ -248,6 +330,64 @@ public class SegmentedStreamLoopTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MidStream_TruncatedSegment_RoutesToRecovery_NotSilentEof()
|
||||
{
|
||||
// 10 MB file → 3 segments. The second segment's body is short (1 MB instead of 4 MB) while
|
||||
// cursor < totalLength — simulates a connection drop mid-segment. The loop must NOT treat this
|
||||
// as EOF (must not call MarkStreamComplete) and must route to recovery (scheduler halted) so
|
||||
// the buffered tail cannot drain into a silent false end.
|
||||
var total = 10L * 1024 * 1024;
|
||||
var truncatedBodyBytes = 1L * 1024 * 1024; // 1 MB short body for segment 2
|
||||
var server = new TruncatingAfterFirstSegmentServer(total, truncatedBodyBytes);
|
||||
var js = new FakeJsRuntime();
|
||||
var player = BuildPlayer(server, js);
|
||||
|
||||
await player.SelectTrackStreaming(Track());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(js.MarkCompleteCallCount, Is.Zero,
|
||||
"a truncated non-final segment must NOT be reported as a clean EOF — MarkStreamComplete must not fire");
|
||||
Assert.That(js.RecoverCallCount, Is.EqualTo(1),
|
||||
"a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery");
|
||||
Assert.That(player.IsLoaded, Is.True,
|
||||
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
|
||||
Assert.That(player.IsPaused, Is.True,
|
||||
"recovery settles into a paused state, not playing");
|
||||
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,
|
||||
"recovery surfaces an error message to the UI");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MidStream_SegmentFetchFailure_RoutesToRecovery_NotSilentFalseEnd()
|
||||
{
|
||||
// 10 MB file → 3 segments. The second segment fetch fails (HTTP 500), simulating a network
|
||||
// error after playback is already underway. The loop must halt the JS scheduler via recovery
|
||||
// rather than letting the buffered first-segment tail drain into a silent false end (AC6).
|
||||
var total = 10L * 1024 * 1024;
|
||||
var server = new FailingAfterFirstSegmentServer(total);
|
||||
var js = new FakeJsRuntime();
|
||||
var player = BuildPlayer(server, js);
|
||||
|
||||
await player.SelectTrackStreaming(Track());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(js.MarkCompleteCallCount, Is.Zero,
|
||||
"a mid-stream fetch failure must not report clean EOF — MarkStreamComplete must not fire");
|
||||
Assert.That(js.RecoverCallCount, Is.EqualTo(1),
|
||||
"a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain");
|
||||
Assert.That(player.IsLoaded, Is.True,
|
||||
"recovery leaves the track loaded so the listener can retry — not torn down to unloaded");
|
||||
Assert.That(player.IsPaused, Is.True,
|
||||
"recovery settles into a paused state, not playing");
|
||||
Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,
|
||||
"recovery surfaces an error message to the UI");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SeekBeyondBuffer_ReinitsOnceThenSegmentsForwardFromOffset()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user