From fdbf322ca8c67f5bf41745732e3cca1de4374215 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 10 Oct 2026 01:10:07 +1100 Subject: [PATCH] Treat a clean EOF with unknown total as the whole asset --- internal/provider/syncretry.go | 7 +++++-- internal/provider/syncretry_test.go | 31 +++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/internal/provider/syncretry.go b/internal/provider/syncretry.go index cec262e..1a2eb47 100644 --- a/internal/provider/syncretry.go +++ b/internal/provider/syncretry.go @@ -62,7 +62,7 @@ func AssetStatusError(url string, resp *http.Response) error { // ReadPrefix reads up to n bytes of a 200 or 206 asset response. full reports // that the body is the whole object (per Content-Length or the Content-Range -// total); a body ending before n bytes that is not the whole object is a +// total, or a clean EOF before n when the total is unknown); a body ending before n bytes that is not the whole object is a // transient short read. func ReadPrefix(resp *http.Response, n int64) (body []byte, full bool, err error) { body, err = io.ReadAll(io.LimitReader(resp.Body, n)) @@ -70,15 +70,18 @@ func ReadPrefix(resp *http.Response, n int64) (body []byte, full bool, err error return nil, false, err } total := resp.ContentLength + unknown := resp.StatusCode == http.StatusOK && total == -1 if resp.StatusCode == http.StatusPartialContent { total = -1 if _, t, ok := strings.Cut(resp.Header.Get("Content-Range"), "/"); ok { if v, perr := strconv.ParseInt(t, 10, 64); perr == nil { total = v } + unknown = t == "*" } } - full = total == int64(len(body)) + // net/http errors on a premature close, so a clean EOF before n with an unknown total is the whole object. + full = total == int64(len(body)) || (unknown && int64(len(body)) < n) if !full && int64(len(body)) < n { return nil, false, fmt.Errorf("short read: got %d of %d bytes", len(body), n) } diff --git a/internal/provider/syncretry_test.go b/internal/provider/syncretry_test.go index 4feab63..906b9e3 100644 --- a/internal/provider/syncretry_test.go +++ b/internal/provider/syncretry_test.go @@ -5,8 +5,8 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "strconv" - "strings" "testing" "time" ) @@ -88,18 +88,37 @@ func TestReadPrefix(t *testing.T) { wantErr bool }{ {name: "200 whole object", status: 200, cl: 5, body: "hello", n: 32, wantFull: true}, - {name: "200 larger than n", status: 200, cl: 100, body: "hello", n: 4}, - {name: "200 unknown length short", status: 200, cl: -1, body: "hello", n: 32, wantErr: true}, + {name: "200 larger than n", status: 200, cl: 5, body: "hello", n: 4}, + {name: "200 chunked shorter than n", status: 200, cl: -1, body: "hello", n: 32, wantFull: true}, + {name: "200 chunked larger than n", status: 200, cl: -1, body: "hello", n: 4}, {name: "206 whole object", status: 206, crange: "bytes 0-4/5", body: "hello", n: 32, wantFull: true}, {name: "206 prefix", status: 206, crange: "bytes 0-3/100", body: "hell", n: 4}, {name: "206 short read", status: 206, crange: "bytes 0-31/100", body: "hello", n: 32, wantErr: true}, {name: "206 no content-range short", status: 206, body: "hello", n: 32, wantErr: true}, + {name: "206 unknown total shorter than n", status: 206, cl: -1, crange: "bytes 0-4/*", body: "hello", n: 32, wantFull: true}, + {name: "206 unknown total prefix", status: 206, cl: -1, crange: "bytes 0-3/*", body: "hell", n: 4}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - resp := &http.Response{StatusCode: tc.status, ContentLength: tc.cl, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tc.body))} - if tc.crange != "" { - resp.Header.Set("Content-Range", tc.crange) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tc.cl > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(tc.cl, 10)) + } + if tc.crange != "" { + w.Header().Set("Content-Range", tc.crange) + } + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + w.(http.Flusher).Flush() + })) + defer srv.Close() + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if tc.cl == -1 && resp.ContentLength != -1 { + t.Fatalf("response not chunked: ContentLength=%d", resp.ContentLength) } body, full, err := ReadPrefix(resp, tc.n) if (err != nil) != tc.wantErr {