Files
ersatztv/ErsatzTV.Infrastructure/Streaming/HttpRemoteStreamProber.cs
T
timothyandClaude Opus 4.8 dc5ceb5a14
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(473): bound the drain, correct the interface contract, quiet graceful cancels
Second review pass returned BLOCKED on two findings introduced by the
first fix commit. Both were right.

BLOCKER 1 — the drain added for "return the connection to the pool" was
unbounded. `response.Content.ReadAsByteArrayAsync()` buffers the WHOLE
body, and it ran for every non-404 response. A server that ignores
`Range: bytes=0-0` answers 200 with the entire file, so this would
download at line rate into a byte[] on the streaming hot path for up to
the 2s timeout -- strictly worse than the aborted socket it replaced, and
it defeated the ResponseHeadersRead the probe deliberately uses. Now the
single byte is read only on 206 (where the server honoured the range and
the body really is one byte); any other status aborts the socket, which
is much the cheaper evil. Two tests pin both directions; verified
non-vacuous (restoring the unbounded drain fails the 200-with-body test).

BLOCKER 2 — IRemoteStreamProber's doc-comment still described pre-fix
behaviour. I had told the reviewer it was updated; it was not -- only the
implementation's <remarks> had been. It claimed `false` on any 404 (now
only a redirected one) and that every other outcome returns `true` (caller
cancellation throws). Both clauses corrected, and the throwing contract is
now documented with <exception>.

Also fixed the reviewer's own follow-on finding: the cancellation rethrow
it asked for reached HlsSessionWorker's catch-all, which logs a
channel-level ERROR with a stack trace. The graceful
TaskCanceledException/OperationCanceledException handler at :662 wraps only
the inner ffmpeg block, not the mediator sends, so every client disconnect
on a remote-streaming channel would have produced a spurious ERROR -- in
exactly the logs a #350 cold-start investigation reads. Added a
cancellation filter on the outer try that logs Information instead.

Nit: stale SeedAll doc-comment now mentions the emby case.

Deferred, per reviewer's explicit agreement: Plex-branch handler coverage
(follow-up), and HEAD-with-GET-fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:47:50 +02:00

110 lines
5.1 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Streaming;
/// <summary>
/// Probes a media-server remote-stream URL over HTTP.
/// </summary>
/// <remarks>
/// Deliberately fail-open: the only outcome that reports the media as gone is a 404 that came
/// from the media server itself (i.e. arrived after our <c>/media/{provider}/...</c> endpoint
/// redirected). A timeout, a transport failure, any other status, or a 404 raised by ErsatzTV's
/// own endpoint all report available, so a probe that cannot answer never turns a tune that
/// would have worked into an error card. (ersatztv#473)
/// </remarks>
public class HttpRemoteStreamProber(
IHttpClientFactory httpClientFactory,
ILogger<HttpRemoteStreamProber> logger) : IRemoteStreamProber
{
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2);
public async Task<bool> IsAvailable(string url, CancellationToken cancellationToken)
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ProbeTimeout);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// ask for a single byte; media servers vary in their HEAD support, and this exercises the
// same redirect chain ffmpeg will follow
request.Headers.Range = new RangeHeaderValue(0, 0);
using HttpClient client = httpClientFactory.CreateClient();
using HttpResponseMessage response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
timeoutCts.Token);
if (response.StatusCode is HttpStatusCode.NotFound)
{
// only the MEDIA SERVER's 404 is evidence that the item is gone. our own
// /media/{provider}/... endpoint also returns 404 when the media source is
// unconfigured or momentarily missing (InternalController maps a failed
// connection-parameter lookup to NotFound), and treating that as "gone" would fail
// CLOSED for every item on that source. A media-server 404 always arrives after a
// redirect, so an un-redirected 404 came from us and must fail open.
if (WasRedirected(response, url))
{
logger.LogWarning("Media server reported 404 for remote stream {Url}", url);
return false;
}
logger.LogDebug(
"Probe of {Url} returned 404 without redirecting to a media server; assuming the "
+ "item is available rather than failing closed on our own endpoint",
url);
return true;
}
// return the connection to the pool instead of aborting it by disposing an unread
// stream - but ONLY where the server honoured the range, i.e. the body really is one
// byte. A server that ignores `Range` answers 200 with the WHOLE FILE, and draining that
// would download at line rate into memory on the streaming hot path, defeating the
// ResponseHeadersRead above. There, abort the socket - much the cheaper evil.
if (response.StatusCode is HttpStatusCode.PartialContent)
{
var singleByte = new byte[1];
Stream body = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
await body.ReadAsync(singleByte, timeoutCts.Token);
}
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// the CALLER cancelled (shutdown / client disconnect). that is a genuine signal, not a
// probe failure, so it must propagate rather than be swallowed as fail-open.
throw;
}
catch (Exception ex)
{
// fail open - a probe failure is not evidence that the media is gone
logger.LogDebug(ex, "Unable to probe remote stream {Url}; assuming it is available", url);
return true;
}
}
private static bool WasRedirected(HttpResponseMessage response, string probeUrl)
{
Uri finalUri = response.RequestMessage?.RequestUri;
if (finalUri is null || !Uri.TryCreate(probeUrl, UriKind.Absolute, out Uri requestedUri))
{
// can't tell where the 404 came from; fail open rather than guess
return false;
}
// compare parsed Uris rather than strings. Uri.Equals compares normalized components, so it
// can't mistake an escaping/casing difference for a redirect and fail CLOSED - the exact
// failure this check exists to prevent. (A string compare on Uri.ToString() happens to agree
// for our machine-generated URLs, since ToString unescapes; this is defense in depth, not a
// fix for an observed bug.)
return !Uri.Equals(finalUri, requestedUri);
}
}