{"record":{"id":"0cb6037223f8a9c0","repo":"SubtitleEdit/subtitleedit","slug":"cannot-retry-download-on-non-seekable-stream-after","errorCode":null,"errorMessage":"Cannot retry download on non-seekable stream after partial download","messagePattern":"Cannot retry download on non-seekable stream after partial download","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/ui/Logic/Download/DownloadHelper.cs","lineNumber":180,"sourceCode":"\n                // If this was the last retry, don't wait\n                if (attempt >= maxRetries)\n                {\n                    break;\n                }\n\n                // Exponential backoff with jitter: wait before retrying\n                var baseDelay = Math.Min(2000 * (int)Math.Pow(2, attempt - 1), 30000);\n                var jitter = Random.Shared.Next(0, 1000);\n                var delayMs = baseDelay + jitter;\n\n                await Task.Delay(delayMs, CancellationToken.None).ConfigureAwait(false);\n\n                // Don't reset stream position - we'll resume from where we left off\n                // Only reset if we can't seek (which means we can't resume anyway)\n                if (!destination.CanSeek && destination.Position > 0)\n                {\n                    throw new InvalidOperationException(\n                        \"Cannot retry download on non-seekable stream after partial download\",\n                        lastException);\n                }\n            }\n        }\n\n        // All retries exhausted\n        var bytesDownloaded = destination.CanSeek ? destination.Position : 0;\n        throw new InvalidOperationException(\n            $\"Failed to download file after {maxRetries} attempts. URL: {url}. Downloaded: {bytesDownloaded}/{totalBytes ?? 0} bytes\",\n            lastException);\n    }\n\n    private static async Task<bool> CheckRangeSupport(\n        HttpClient httpClient,\n        string url,\n        CancellationToken cancellationToken)\n    {","sourceCodeStart":162,"sourceCodeEnd":198,"githubUrl":"https://github.com/SubtitleEdit/subtitleedit/blob/17a9f0748781032255db3526b7215d2fb891e3af/src/ui/Logic/Download/DownloadHelper.cs#L162-L198","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: piping straight into a non-seekable stream\nawait DownloadHelper.DownloadFileAsync(http, url, networkStream, progress, ct);\n\n// after: buffer to a seekable file, then forward\nvar tmp = Path.GetTempFileName();\nawait using (var fs = File.OpenWrite(tmp))\n    await DownloadHelper.DownloadFileAsync(http, url, fs, progress, ct);\nusing var src = File.OpenRead(tmp);\nawait src.CopyToAsync(networkStream, ct);","handlingStrategy":"validation","validationCode":"// Never hand a non-seekable stream with partial bytes to the downloader\nif (!destination.CanSeek)\n    throw new ArgumentException(\"Pass a seekable stream to enable retry/resume.\", nameof(destination));","typeGuard":"static bool IsRetrySafeDestination(Stream s) => s.CanSeek && s.CanWrite;","tryCatchPattern":"try { await DownloadHelper.DownloadFileAsync(http, url, nonSeekable, progress, ct); }\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"non-seekable stream\"))\n{ _logger.Error(\"Destination must be seekable for retries. Buffer to a file first.\"); throw; }","preventionTips":["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."],"tags":["download","non-seekable-stream","retry","stream-corruption"],"backgroundTag":null,"analyzedSha":"17a9f0748781032255db3526b7215d2fb891e3af","analyzedAt":"2026-08-13T18:11:43.374Z","schemaVersion":2},"datasetVersion":"2026-08-13T19:17:28.613Z"}