SubtitleEdit/subtitleedit · error · InvalidOperationException
Cannot retry download on non-seekable stream after partial d
Error message
Cannot retry download on non-seekable stream after partial download
What it means
Thrown inside DownloadHelper's retry block when a transient failure occurs, the code wants to retry, but the destination stream cannot seek (CanSeek == false) and already has bytes written (Position > 0). Because retry/resume needs to rewind or set the write position, a non-seekable partial stream would produce a corrupted file, so the helper aborts rather than silently corrupt. The original failure is chained as InnerException.
Source
Thrown at src/ui/Logic/Download/DownloadHelper.cs:180
// If this was the last retry, don't wait
if (attempt >= maxRetries)
{
break;
}
// Exponential backoff with jitter: wait before retrying
var baseDelay = Math.Min(2000 * (int)Math.Pow(2, attempt - 1), 30000);
var jitter = Random.Shared.Next(0, 1000);
var delayMs = baseDelay + jitter;
await Task.Delay(delayMs, CancellationToken.None).ConfigureAwait(false);
// Don't reset stream position - we'll resume from where we left off
// Only reset if we can't seek (which means we can't resume anyway)
if (!destination.CanSeek && destination.Position > 0)
{
throw new InvalidOperationException(
"Cannot retry download on non-seekable stream after partial download",
lastException);
}
}
}
// All retries exhausted
var bytesDownloaded = destination.CanSeek ? destination.Position : 0;
throw new InvalidOperationException(
$"Failed to download file after {maxRetries} attempts. URL: {url}. Downloaded: {bytesDownloaded}/{totalBytes ?? 0} bytes",
lastException);
}
private static async Task<bool> CheckRangeSupport(
HttpClient httpClient,
string url,
CancellationToken cancellationToken)
{View on GitHub (pinned to 17a9f07487)
Solutions
- Always pass a seekable destination (FileStream or MemoryStream) to DownloadFileAsync so retries can rewind/resume.
- If you truly need a non-seekable sink, download to a temp FileStream first, then copy to your sink.
- Disable retry by setting maxRetries: 1 only if you accept that any transient failure is fatal (rarely the right choice).
- Ensure the server supports range requests (CheckRangeSupport) and the stream is seekable for true resume semantics.
Example fix
// before: piping straight into a non-seekable stream
await DownloadHelper.DownloadFileAsync(http, url, networkStream, progress, ct);
// after: buffer to a seekable file, then forward
var tmp = Path.GetTempFileName();
await using (var fs = File.OpenWrite(tmp))
await DownloadHelper.DownloadFileAsync(http, url, fs, progress, ct);
using var src = File.OpenRead(tmp);
await src.CopyToAsync(networkStream, ct); Defensive patterns
Strategy: validation
Validate before calling
// Never hand a non-seekable stream with partial bytes to the downloader
if (!destination.CanSeek)
throw new ArgumentException("Pass a seekable stream to enable retry/resume.", nameof(destination)); Type guard
static bool IsRetrySafeDestination(Stream s) => s.CanSeek && s.CanWrite;
Try / catch
try { await DownloadHelper.DownloadFileAsync(http, url, nonSeekable, progress, ct); }
catch (InvalidOperationException ex) when (ex.Message.Contains("non-seekable stream"))
{ _logger.Error("Destination must be seekable for retries. Buffer to a file first."); throw; } Prevention
- Buffer downloads to a FileStream or MemoryStream, then forward to the real sink.
- Set maxRetries: 1 only if the sink is non-seekable AND you accept no retries.
- Document the seekable-stream contract at the API boundary.
When it happens
Trigger: Passing a non-seekable destination (e.g. a NetworkStream, certain wrapped streams, some crypto pipes) to DownloadFileAsync, where the first attempt downloads some bytes then hits a retryable HttpRequestException / TaskCanceledException / IOException / InvalidOperationException. On retry the guard trips because position cannot be rewound.
Common situations: Streaming a download directly to a pipe or another process's stdin; using a GZipStream/CryptoStream over a non-seekable base without buffering; a MemoryStream wrapped by a non-seekable adapter. Note: even when the server lacks range support, a seekable stream is rewound to startPosition on retry — non-seekable streams cannot be recovered.
Related errors
- Download incomplete: expected {totalBytes} bytes, received {
- Dictionary download failed: {url}
- Unable to download file - Invalid username or password! (Per
- crispasr (chatterbox) did not report healthy within 15 minut
- crispasr (cosyvoice3-tts) did not report healthy within {(al
AI-assisted analysis of SubtitleEdit/subtitleedit@17a9f07487 (2026-08-13).
Data as JSON: /api/errors/0cb6037223f8a9c0.
Report an issue: GitHub.