Phase 21 Direction B: bound network memory via Range-segmented forward fetch
Replace the open-ended forward GET with sequential bounded bytes=start-end segments, the next fetched only when the scheduler drains below low-water, so the browser holds ~one segment regardless of file size. Seek converges on the same loop. Strip BP-DIAG.
This commit is contained in:
@@ -20,13 +20,24 @@ public class TrackMediaResponse : IDisposable
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The total file length in bytes, parsed from the 206 response's <c>Content-Range:
|
||||
/// bytes start-end/TOTAL</c> header (Phase 21 Direction B). Null when the server returned
|
||||
/// 200 (no Content-Range) — callers fall back to <see cref="ContentLength"/> as the total.
|
||||
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
|
||||
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
|
||||
/// never trips the decoder's byte-count completion early).
|
||||
/// </summary>
|
||||
public long? TotalLength { get; }
|
||||
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
|
||||
{
|
||||
Stream = stream;
|
||||
ContentLength = contentLength;
|
||||
ContentType = contentType;
|
||||
TotalLength = totalLength;
|
||||
_response = response;
|
||||
}
|
||||
|
||||
@@ -54,6 +65,15 @@ public class TrackMediaClient
|
||||
/// token aborts the in-flight server connection rather than leaving the server
|
||||
/// draining bytes into a dead socket.
|
||||
/// <para>
|
||||
/// <paramref name="byteEnd"/> (Phase 21 Direction B) bounds the request to a single
|
||||
/// segment: when set, the Range header is <c>bytes={byteOffset}-{byteEnd}</c> (inclusive),
|
||||
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
|
||||
/// network-memory bound this phase exists for. When null the request is open-ended
|
||||
/// (<c>bytes={byteOffset}-</c>), the pre-Direction-B behaviour. Either way the response's
|
||||
/// <c>Content-Range</c> total is surfaced via <see cref="TrackMediaResponse.TotalLength"/>
|
||||
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
|
||||
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
|
||||
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
|
||||
@@ -65,12 +85,13 @@ public class TrackMediaClient
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||
string trackId,
|
||||
long byteOffset = 0,
|
||||
long? byteEnd = null,
|
||||
AudioFormat format = AudioFormat.Lossless,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
||||
// Same URL for every fetch — only the Range header differs. byteOffset 0 is
|
||||
// not special-cased: "bytes=0-" requests the whole file from the start.
|
||||
// Lossless omits the format param entirely so the request is byte-identical to
|
||||
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
|
||||
@@ -78,18 +99,19 @@ public class TrackMediaClient
|
||||
? $"api/track/{trackId}"
|
||||
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
||||
// Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
|
||||
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
|
||||
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
|
||||
// which parses the full RFC 7233 range grammar and slices accordingly.
|
||||
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
|
||||
|
||||
// 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.
|
||||
// browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
|
||||
// each request is already bounded to one segment, so the body is small regardless — but
|
||||
// streaming still lets us read it incrementally and is harmless on the SSR server-to-server
|
||||
// path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
|
||||
// seek/refill paths since both share this method.
|
||||
request.SetBrowserResponseStreamingEnabled(true);
|
||||
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||
@@ -100,11 +122,15 @@ public class TrackMediaClient
|
||||
// Default to WAV when the server omits the header — the only format shipping
|
||||
// today — so the JS factory always receives a usable media type.
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
|
||||
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
|
||||
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
|
||||
var totalLength = response.Content.Headers.ContentRange?.Length;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// TrackMediaResponse takes ownership of both stream and response;
|
||||
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(
|
||||
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user