feat(download): resume interrupted downloads via Range/If-Range

This commit is contained in:
zarzet
2026-07-14 09:09:26 +07:00
parent 034b505d15
commit 2273d9ba48
2 changed files with 242 additions and 45 deletions
+154 -45
View File
@@ -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 {
@@ -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"