diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs index cd715e1..fa65636 100644 --- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; +using Microsoft.AspNetCore.Components.WebAssembly.Http; using Microsoft.Extensions.DependencyInjection; using NetBlocks.Models; @@ -79,6 +80,18 @@ public class TrackMediaClient using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Range = new RangeHeaderValue(byteOffset, null); + // Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix). + // In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the + // browser buffers the ENTIRE body before the response stream yields a byte, so the 21.2 + // read-loop pause (StreamingAudioPlayerService) backpressures nothing — the whole payload is + // already in memory. Enabling streaming makes ReadAsync pull from a browser ReadableStream + // whose backpressure reaches the underlying fetch, so pausing reads genuinely throttles the + // network. This is a request-option flag, not a runtime call: on the SSR server-to-server path + // the SocketsHttpHandler simply ignores the unknown option, so it is safe unguarded. Applies to + // BOTH the initial stream (byteOffset 0) and the seek/refill Range requests (21.3) — both share + // this method, so both depend on the same backpressure. + request.SetBrowserResponseStreamingEnabled(true); + // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/DeepDrftTests/TrackMediaStreamingEnabledTests.cs b/DeepDrftTests/TrackMediaStreamingEnabledTests.cs new file mode 100644 index 0000000..dc0501a --- /dev/null +++ b/DeepDrftTests/TrackMediaStreamingEnabledTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Clients; + +namespace DeepDrftTests; + +/// +/// Pins the Phase 21.4 transport fix: every audio media fetch must carry the browser response-streaming +/// option so the body streams incrementally in WASM. Without it the browser buffers the whole payload +/// before the response stream yields a byte, and the 21.2 read-loop pause backpressures nothing. +/// +/// The flag is set by SetBrowserResponseStreamingEnabled(true), which records it in +/// under the framework key "WebAssemblyEnableStreamingResponse". +/// A stub handler reads that option back during SendAsync — the same network-boundary fake the Opus +/// format-selection tests use. True network backpressure is browser-only and cannot be unit-profiled; this +/// asserts the request is *configured* for streaming, which is the part the harness can observe. Daniel's +/// 21.4 manual re-run confirms the actual bounded-memory behaviour. +/// +/// Both the initial full-stream request (byteOffset 0) and the seek/refill Range request (byteOffset > 0, +/// Phase 21.3) flow through , so both are asserted here. +/// +[TestFixture] +public class TrackMediaStreamingEnabledTests +{ + // The framework key SetBrowserResponseStreamingEnabled writes into HttpRequestMessage.Options. + private static readonly HttpRequestOptionsKey StreamingOptionKey = new("WebAssemblyEnableStreamingResponse"); + + // Captures the streaming option off each outgoing request, then returns a minimal 200 with a body so + // GetTrackMedia reaches its pass path (ReadAsStreamAsync over ByteArrayContent). + private sealed class CapturingHandler : HttpMessageHandler + { + public bool? StreamingEnabled { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + StreamingEnabled = request.Options.TryGetValue(StreamingOptionKey, out var enabled) ? enabled : null; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("audio-bytes"u8.ToArray()), + }; + return Task.FromResult(response); + } + } + + private sealed class SingleClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public SingleClientFactory(HttpMessageHandler handler) => _handler = handler; + + public HttpClient CreateClient(string name) => + new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") }; + } + + [Test] + public async Task GetTrackMedia_InitialStream_EnablesBrowserResponseStreaming() + { + var handler = new CapturingHandler(); + var client = new TrackMediaClient(new SingleClientFactory(handler)); + + var result = await client.GetTrackMedia("track-1", byteOffset: 0, format: AudioFormat.Lossless); + + Assert.That(result.Success, Is.True, "the fetch should succeed against the stub"); + Assert.That(handler.StreamingEnabled, Is.True, + "the initial media stream must enable browser response streaming or the read-loop pause backpressures nothing"); + } + + [Test] + public async Task GetTrackMedia_SeekOffsetStream_EnablesBrowserResponseStreaming() + { + var handler = new CapturingHandler(); + var client = new TrackMediaClient(new SingleClientFactory(handler)); + + var result = await client.GetTrackMedia("track-1", byteOffset: 1_048_576, format: AudioFormat.Opus); + + Assert.That(result.Success, Is.True, "the offset fetch should succeed against the stub"); + Assert.That(handler.StreamingEnabled, Is.True, + "the seek/refill Range request must also enable streaming — 21.3 refill depends on the same backpressure"); + } +}