From 2273d9ba48ef611fe8540a0d8670195094a6d981 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 19:09:02 +0700 Subject: [PATCH] feat(download): resume interrupted downloads via Range/If-Range --- go_backend/extension_runtime_file.go | 199 ++++++++++++++---- .../extension_runtime_supplement_test.go | 88 ++++++++ 2 files changed, 242 insertions(+), 45 deletions(-) diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index b4e4177e..40b798e3 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -198,20 +198,31 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { unlock := lockDownloadOutputPath(fullPath) defer unlock() - req, err := http.NewRequest("GET", urlStr, nil) + buildReq := func(rangeFrom int64, validator string) (*http.Request, *stallWatchdog, error) { + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return nil, nil, err + } + req = r.bindDownloadCancelContext(req) + req, wd := bindStallWatchdog(req, downloadStallTimeout) + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", appUserAgent()) + } + if rangeFrom > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", rangeFrom)) + req.Header.Set("If-Range", validator) + } + return req, wd, nil + } + + req, wd, err := buildReq(0, "") if err != nil { return r.jsError("%s", err.Error()) } - req = r.bindDownloadCancelContext(req) - req, wd := bindStallWatchdog(req, downloadStallTimeout) - defer wd.stop() - - for k, v := range headers { - req.Header.Set(k, v) - } - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", appUserAgent()) - } + defer func() { wd.stop() }() resp, err := client.Do(req) if err != nil { @@ -220,9 +231,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } return r.jsError("%s", err.Error()) } - defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() return r.jsError("HTTP error: %d", resp.StatusCode) } @@ -233,6 +244,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { os.Remove(stagedPath) out, err := os.Create(stagedPath) if err != nil { + resp.Body.Close() return r.jsError("failed to create file: %v", err) } promoted := false @@ -254,48 +266,145 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { SetItemBytesTotal(activeItemID, contentLength) } - var progressWriter interface{ Write([]byte) (int, error) } = out - if shouldTrackItemBytes { - progressWriter = NewItemProgressWriter(out, activeItemID) + makeProgressWriter := func() interface{ Write([]byte) (int, error) } { + if shouldTrackItemBytes { + return NewItemProgressWriter(out, activeItemID) + } + return out + } + progressWriter := makeProgressWriter() + + // resumeValidator picks a validator usable with If-Range: a strong ETag or + // Last-Modified. Weak ETags (W/...) are not valid for If-Range. + resumeValidator := func(h http.Header) string { + if etag := h.Get("ETag"); etag != "" && !strings.HasPrefix(etag, "W/") { + return etag + } + return h.Get("Last-Modified") } var written int64 buf := make([]byte, 32*1024) - for { - nr, er := resp.Body.Read(buf) - if nr > 0 { - wd.reset() - nw, ew := progressWriter.Write(buf[0:nr]) - if nw < 0 || nr < nw { - nw = 0 - if ew == nil { - ew = fmt.Errorf("invalid write result") - } - } - written += int64(nw) - if ew != nil { - if ew == ErrDownloadCancelled { - return r.jsError("download cancelled") - } - return r.jsError("failed to write file: %v", ew) - } - if nr != nw { - return r.jsError("short write") - } - if onProgress != nil && contentLength > 0 { - _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) + // copyBody streams resp into progressWriter. fatal is a terminal JS error + // (write failure or user cancel); readErr is a network error eligible for + // a Range resume. + copyBody := func(resp *http.Response) (fatal goja.Value, readErr error) { + defer resp.Body.Close() + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + wd.reset() + nw, ew := progressWriter.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = fmt.Errorf("invalid write result") + } + } + written += int64(nw) + if ew != nil { + if ew == ErrDownloadCancelled { + return r.jsError("download cancelled"), nil + } + return r.jsError("failed to write file: %v", ew), nil + } + if nr != nw { + return r.jsError("short write"), nil + } + + if onProgress != nil && contentLength > 0 { + _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) + } + } + if er != nil { + if er != io.EOF { + return nil, er + } + return nil, nil } } - if er != nil { - if er != io.EOF { - if wd.stalled.Load() { - return r.stallError() - } - return r.jsError("failed to read response: %v", er) - } + } + + // Mid-body network failures resume from the current offset instead of + // failing the whole download. Only attempted when the server gave a + // validator (If-Range guards against splicing two versions of the file) + // and the caller did not set its own Range. A server that ignores Range + // answers 200 and the download restarts from zero — same as today. + validator := resumeValidator(resp.Header) + _, callerSetRange := headers["Range"] + canResume := validator != "" && !callerSetRange + + const maxResumes = 3 + resumes := 0 + for { + fatal, readErr := copyBody(resp) + if fatal != nil { + return fatal + } + if readErr == nil { break } + + stalled := wd.stalled.Load() + if !canResume || resumes >= maxResumes || + (activeItemID != "" && isDownloadCancelled(activeItemID)) { + if stalled { + return r.stallError() + } + return r.jsError("failed to read response: %v", readErr) + } + resumes++ + GoLog("[Extension:%s] Download interrupted at %d bytes (%v), resuming (attempt %d/%d)\n", + r.extensionID, written, readErr, resumes, maxResumes) + + wd.stop() + var resumeReq *http.Request + resumeReq, wd, err = buildReq(written, validator) + if err != nil { + return r.jsError("%s", err.Error()) + } + resp, err = client.Do(resumeReq) + if err != nil { + if wd.stalled.Load() { + return r.stallError() + } + return r.jsError("resume failed at %d bytes: %v", written, err) + } + + switch resp.StatusCode { + case http.StatusPartialContent: + if written > 0 && !strings.HasPrefix(resp.Header.Get("Content-Range"), fmt.Sprintf("bytes %d-", written)) { + cr := resp.Header.Get("Content-Range") + resp.Body.Close() + return r.jsError("resume failed: unexpected Content-Range %q at %d bytes", cr, written) + } + case http.StatusOK: + // Range ignored or file changed under If-Range: restart from zero + // on the same staged file. + if err := out.Truncate(0); err != nil { + resp.Body.Close() + return r.jsError("failed to restart download: %v", err) + } + if _, err := out.Seek(0, io.SeekStart); err != nil { + resp.Body.Close() + return r.jsError("failed to restart download: %v", err) + } + written = 0 + progressWriter = makeProgressWriter() + if resp.ContentLength > 0 { + contentLength = resp.ContentLength + if shouldTrackItemBytes { + SetItemBytesTotal(activeItemID, contentLength) + } + } + validator = resumeValidator(resp.Header) + canResume = validator != "" + default: + code := resp.StatusCode + resp.Body.Close() + return r.jsError("resume failed: HTTP %d at %d bytes", code, written) + } } if shouldTrackItemBytes { diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index f7f0d6b2..33232621 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -267,6 +267,94 @@ func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) { } } +func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) { + const full = "hello-world!" + var attempts int + var resumeRange, resumeIfRange string + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + attempts++ + if attempts == 1 { + h := make(http.Header) + h.Set("ETag", `"v1"`) + return &http.Response{ + StatusCode: 200, + Header: h, + Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}), + ContentLength: int64(len(full)), + Request: req, + }, nil + } + resumeRange = req.Header.Get("Range") + resumeIfRange = req.Header.Get("If-Range") + h := make(http.Header) + h.Set("Content-Range", fmt.Sprintf("bytes 6-%d/%d", len(full)-1, len(full))) + return &http.Response{ + StatusCode: 206, + Header: h, + Body: io.NopCloser(strings.NewReader(full[6:])), + ContentLength: int64(len(full) - 6), + Request: req, + }, nil + }) + + result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("https://cdn.example.com/track.flac"), + runtime.vm.ToValue("out/track.flac"), + }}).Export().(map[string]any) + if result["success"] != true { + t.Fatalf("download result = %#v", result) + } + if attempts != 2 || resumeRange != "bytes=6-" || resumeIfRange != `"v1"` { + t.Fatalf("attempts=%d range=%q if-range=%q", attempts, resumeRange, resumeIfRange) + } + data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac")) + if err != nil || string(data) != full { + t.Fatalf("final file = %q/%v", data, err) + } +} + +func TestFileDownloadResumeRestartsWhenRangeIgnored(t *testing.T) { + const full = "hello-world!" + var attempts int + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + attempts++ + h := make(http.Header) + h.Set("ETag", `"v1"`) + if attempts == 1 { + return &http.Response{ + StatusCode: 200, + Header: h, + Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}), + ContentLength: int64(len(full)), + Request: req, + }, nil + } + // Server ignores the Range header and replays the full body. + return &http.Response{ + StatusCode: 200, + Header: h, + Body: io.NopCloser(strings.NewReader(full)), + ContentLength: int64(len(full)), + Request: req, + }, nil + }) + + result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("https://cdn.example.com/track.flac"), + runtime.vm.ToValue("out/track.flac"), + }}).Export().(map[string]any) + if result["success"] != true { + t.Fatalf("download result = %#v", result) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } + data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac")) + if err != nil || string(data) != full { + t.Fatalf("final file = %q/%v", data, err) + } +} + func TestResetDownloadCancelDropsStaleFlag(t *testing.T) { const itemID = "reset-cancel-item"