diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 6f54377d..786e7b32 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -872,12 +872,16 @@ func (f *Fetcher) doStart() error { // For non-range resources, the response will be used directly var prefetchedBytes int64 if f.meta.Res.Range { - resolveComplete := f.knownSize() > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.knownSize() + // Read the size once and judge completeness by it alone: Resolve may have + // written every byte before it sets prefetchDone, and a snapshot that + // covers the whole file would otherwise finish without copying it. + prefetched := f.prefetchSize.Load() + resolveComplete := f.knownSize() > 0 && prefetched >= f.knownSize() if f.resolveFallback.Load() && !resolveComplete { // Keep Resolve downloading until the first Range response has been // validated. The Range request starts at a stable snapshot; a valid // takeover discards any overlap downloaded by Resolve in the meantime. - prefetchedBytes = f.prefetchSize.Load() + prefetchedBytes = prefetched } else { // Resolve already completed, or no live Resolve fallback remains. prefetchedBytes = f.stopPrefetchAndCopyData() diff --git a/internal/protocol/http/prefetch_start_test.go b/internal/protocol/http/prefetch_start_test.go new file mode 100644 index 00000000..383e2cf4 --- /dev/null +++ b/internal/protocol/http/prefetch_start_test.go @@ -0,0 +1,52 @@ +package http + +import ( + "bytes" + gohttp "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +// Start can run after the Resolve prefetch wrote every byte but before it set +// prefetchDone. The prefetched body must still be copied to the target. +func TestFetcher_StartBeforePrefetchDoneCopiesFullBody(t *testing.T) { + payload := []byte("prefetched before Start") + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + gohttp.ServeContent(w, r, "f.txt", time.Time{}, bytes.NewReader(payload)) + })) + defer server.Close() + + f := buildFetcher() + f.Meta().Opts = &base.Options{Path: t.TempDir(), Name: "f.txt", Extra: &http.OptsExtra{Connections: 1}} + if err := f.Resolve(&base.Request{URL: server.URL + "/f.txt"}); err != nil { + t.Fatal(err) + } + for !f.prefetchDone.Load() { + time.Sleep(time.Millisecond) + } + // Reopen the window between the last write and prefetchDone. + f.prefetchDone.Store(false) + go func() { + time.Sleep(50 * time.Millisecond) + f.prefetchDone.Store(true) + }() + + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(f.meta.SingleFilepath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("content = %q, want %q", got, payload) + } +}