From c5c8bb2bf571356ca54497cd5c6b23f5ac6c62d0 Mon Sep 17 00:00:00 2001 From: Levi Date: Mon, 31 Aug 2026 23:05:01 +0800 Subject: [PATCH] fix(http): reset discarded prefetch progress on resume (#1462) --- .github/workflows/test.yml | 25 ++++++++- internal/protocol/http/fetcher.go | 4 ++ internal/protocol/http/fetcher_test.go | 55 +++++++++++++++++++ internal/webview/goprovider/provider.go | 6 +- internal/webview/integrationtest/contract.go | 6 +- .../rpcprovider/provider_integration_test.go | 6 +- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b4fedbe..583c98fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,7 +91,7 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Test webview if: runner.os == 'Windows' - run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/ + run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/ env: CGO_ENABLED: 1 - name: Test webview @@ -107,7 +107,7 @@ jobs: cd ui/flutter flutter pub get - flutter run -d macos >"$LOG_PATH" 2>&1 & + flutter run --machine -d macos >"$LOG_PATH" 2>&1 & FLUTTER_PID=$! cd ../.. @@ -116,7 +116,26 @@ jobs: } trap cleanup EXIT - if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then + FLUTTER_STARTED=false + for _ in $(seq 1 600); do + if grep -Eq '"event"[[:space:]]*:[[:space:]]*"app\.started"' "$LOG_PATH"; then + FLUTTER_STARTED=true + break + fi + if ! kill -0 "$FLUTTER_PID" >/dev/null 2>&1; then + echo "Flutter exited before reporting app.started" + cat "$LOG_PATH" + exit 1 + fi + sleep 1 + done + if [[ "$FLUTTER_STARTED" != true ]]; then + echo "Timed out waiting for Flutter to report app.started" + cat "$LOG_PATH" + exit 1 + fi + + if ! go test -tags webview -v -count=1 -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then cat "$LOG_PATH" exit 1 fi diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index f504e725..375f4cb9 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -584,6 +584,10 @@ func (f *Fetcher) cleanupPrefetchFile() { os.Remove(f.prefetchFilePath) f.prefetchFilePath = "" } + // The byte count is only reusable while the backing prefetch file exists. + // Pause may discard that file before the first Start, so retaining the + // count would make the next Start skip bytes that were never copied. + f.prefetchSize.Store(0) } func (f *Fetcher) Start() error { diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index be84a8fa..ef8723d0 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -1,7 +1,9 @@ package http import ( + "bytes" "context" + "crypto/md5" "encoding/json" "errors" "fmt" @@ -377,6 +379,59 @@ func TestFetcher_DownloadContinue(t *testing.T) { downloadContinue(listener, 16, t) } +func TestFetcher_PauseBeforeStartDiscardsPrefetchProgress(t *testing.T) { + payload := make([]byte, 32*1024*1024) + for i := range payload { + payload[i] = byte(i % 251) + } + prefetchServed := make(chan struct{}, 1) + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + if r.Header.Get(base.HttpHeaderRange) == "" { + w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(payload))) + w.WriteHeader(gohttp.StatusOK) + if _, err := w.Write(payload); err == nil { + prefetchServed <- struct{}{} + } + return + } + gohttp.ServeContent(w, r, test.BuildName, time.Unix(0, 0), bytes.NewReader(payload)) + })) + defer server.Close() + + fetcher := buildFetcher() + if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}, &base.Options{ + Path: t.TempDir(), + Name: test.DownloadName, + Extra: &http.OptsExtra{ + Connections: 2, + }, + }); err != nil { + t.Fatal(err) + } + select { + case <-prefetchServed: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for resolve prefetch response") + } + + if err := fetcher.Pause(); err != nil { + t.Fatal(err) + } + if err := fetcher.Start(); err != nil { + t.Fatal(err) + } + if err := fetcher.Wait(); err != nil { + t.Fatal(err) + } + + want := fmt.Sprintf("%x", md5.Sum(payload)) + got := test.FileMd5(fetcher.Meta().SingleFilepath()) + if want != got { + t.Fatalf("download after pause before start got MD5 %s, want %s", got, want) + } +} + func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) { listener := test.StartTestNoRangeSlowServer(time.Millisecond) defer listener.Close() diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go index 660f6394..a81c20df 100644 --- a/internal/webview/goprovider/provider.go +++ b/internal/webview/goprovider/provider.go @@ -81,6 +81,10 @@ type nativeCookieJSON struct { var providerSeq atomic.Uint64 +// Keep native page creation aligned with the provider availability budget used +// by the integration contract. Cold WebView2 startup can exceed ten seconds. +const startupTimeout = time.Minute + func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper { seq := providerSeq.Add(1) return &pageWrapper{ @@ -142,7 +146,7 @@ func (p *pageWrapper) start() error { case err := <-p.ready: p.tracef("open %dms", time.Since(startedAt).Milliseconds()) return err - case <-time.After(10 * time.Second): + case <-time.After(startupTimeout): p.tracef("open timeout %dms", time.Since(startedAt).Milliseconds()) return fmt.Errorf("webview startup timeout") } diff --git a/internal/webview/integrationtest/contract.go b/internal/webview/integrationtest/contract.go index 15dd5ca7..1a508e35 100644 --- a/internal/webview/integrationtest/contract.go +++ b/internal/webview/integrationtest/contract.go @@ -14,6 +14,10 @@ import ( const TestUserAgent = "GopeedWebViewIntegration/1.0" +// Providers may need to initialize an external host or a native browser runtime +// before the first page can be created. +const defaultAvailabilityTimeout = time.Minute + type ContractOptions struct { AvailabilityTimeout time.Duration CookieDomainMode CookieDomainMode @@ -32,7 +36,7 @@ func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts Con timeout := opts.AvailabilityTimeout if timeout <= 0 { - timeout = 10 * time.Second + timeout = defaultAvailabilityTimeout } waitForProviderAvailable(t, provider, timeout) diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go index f4671986..6ade9458 100644 --- a/internal/webview/rpcprovider/provider_integration_test.go +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/GopeedLab/gopeed/internal/webview/integrationtest" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" @@ -26,9 +25,8 @@ func TestProviderContract(t *testing.T) { Token: *webViewRPCToken, }) integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{ - AvailabilityTimeout: 5 * time.Minute, - CookieDomainMode: integrationtest.CookieDomainModeRequired, - CookieTestURL: "https://example.com/", + CookieDomainMode: integrationtest.CookieDomainModeRequired, + CookieTestURL: "https://example.com/", }) }