vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Answer byte ranges on raw downloads
26c9be338872126250f581f47c16ae5619f2cd2c · Keenan Nemetz · 10d ago · parent 1dfa8cbd
Answer byte ranges on raw downloads `/r/<name>/raw/<ref>/<path>` streamed the whole blob and ignored `Range`, so a client resuming a download or asking for a few KiB received the whole file. The artifact route already answers `206`; this does the same for repository content. A blob is streamed from `git cat-file`, which cannot seek, so a range reads and discards the bytes before it and copies only the requested part. The win is what does not cross the network: a 64 KiB range of an 8 MiB file is still 8 MiB of reading on the server, but 64 KiB of transfer instead of 8 MiB. - A single `Range` is answered with `206`, `Content-Range`, and only the requested bytes; prefix, open-ended and suffix forms all work. - A range that starts past the end is `416` with `Content-Range: bytes */<size>`. - Multiple ranges, an unknown unit, and a malformed header get the whole file with `200`, which RFC 9110 allows. - The no-range response keeps its bytes and gains `Accept-Ranges: bytes`. - `HEAD` stops after the headers instead of reading the blob. The load harness's `raw-range` scenario now reports `206`: 365.6 req/s at concurrency 4, p50 11 ms, against 265.6 req/s and p50 15 ms when the full file was sent.
server/raw_range_test.go +101
| @@ -0,0 +1,101 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // A raw download is the route a range request is for: resuming a download, | ||
| 4 | // previewing a large asset, or seeking in a file. These tests hold the contract: | ||
| 5 | // a range gets exactly its bytes with 206, a bad range is refused with 416, and | ||
| 6 | // a header the server does not implement still gets the whole file. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "io" | ||
| 10 | "net/http" | ||
| 11 | "strconv" | ||
| 12 | "testing" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestRawAnswersByteRanges(t *testing.T) { | ||
| 16 | t.Parallel() | ||
| 17 | f := newFixture(t) | ||
| 18 | f.publish(f.repo) | ||
| 19 | const body = "0123456789abcdefghij" // 20 bytes | ||
| 20 | work := workRepo(t, map[string]string{"data.txt": body}) | ||
| 21 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 22 | t.Fatalf("push: %s", errs) | ||
| 23 | } | ||
| 24 | |||
| 25 | get := func(rangeHeader string) (int, string, http.Header) { | ||
| 26 | t.Helper() | ||
| 27 | req, err := http.NewRequest(http.MethodGet, f.http.URL+"/r/"+f.repo+"/raw/main/data.txt", nil) | ||
| 28 | if err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | if rangeHeader != "" { | ||
| 32 | req.Header.Set("Range", rangeHeader) | ||
| 33 | } | ||
| 34 | resp, err := http.DefaultClient.Do(req) | ||
| 35 | if err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | defer resp.Body.Close() | ||
| 39 | got, _ := io.ReadAll(resp.Body) | ||
| 40 | return resp.StatusCode, string(got), resp.Header | ||
| 41 | } | ||
| 42 | |||
| 43 | code, got, h := get("") | ||
| 44 | if code != http.StatusOK || got != body { | ||
| 45 | t.Fatalf("no range: %d %q, want 200 %q", code, got, body) | ||
| 46 | } | ||
| 47 | if h.Get("Accept-Ranges") != "bytes" { | ||
| 48 | t.Fatalf("Accept-Ranges = %q, want bytes", h.Get("Accept-Ranges")) | ||
| 49 | } | ||
| 50 | if h.Get("Content-Length") != "20" { | ||
| 51 | t.Fatalf("Content-Length = %q, want 20", h.Get("Content-Length")) | ||
| 52 | } | ||
| 53 | |||
| 54 | cases := []struct { | ||
| 55 | name, header, wantBody, wantRange string | ||
| 56 | }{ | ||
| 57 | {"prefix", "bytes=0-9", "0123456789", "bytes 0-9/20"}, | ||
| 58 | {"middle", "bytes=5-9", "56789", "bytes 5-9/20"}, | ||
| 59 | {"open ended", "bytes=15-", "fghij", "bytes 15-19/20"}, | ||
| 60 | {"end past the file", "bytes=15-99", "fghij", "bytes 15-19/20"}, | ||
| 61 | {"suffix", "bytes=-5", "fghij", "bytes 15-19/20"}, | ||
| 62 | {"suffix larger than the file", "bytes=-99", body, "bytes 0-19/20"}, | ||
| 63 | } | ||
| 64 | for _, c := range cases { | ||
| 65 | t.Run(c.name, func(t *testing.T) { | ||
| 66 | code, got, h := get(c.header) | ||
| 67 | if code != http.StatusPartialContent { | ||
| 68 | t.Fatalf("%s: status %d, want 206", c.header, code) | ||
| 69 | } | ||
| 70 | if got != c.wantBody { | ||
| 71 | t.Fatalf("%s: body %q, want %q", c.header, got, c.wantBody) | ||
| 72 | } | ||
| 73 | if h.Get("Content-Range") != c.wantRange { | ||
| 74 | t.Fatalf("%s: Content-Range = %q, want %q", c.header, h.Get("Content-Range"), c.wantRange) | ||
| 75 | } | ||
| 76 | if h.Get("Content-Length") != strconv.Itoa(len(c.wantBody)) { | ||
| 77 | t.Fatalf("%s: Content-Length = %q, want %d", c.header, h.Get("Content-Length"), len(c.wantBody)) | ||
| 78 | } | ||
| 79 | }) | ||
| 80 | } | ||
| 81 | |||
| 82 | code, got, h = get("bytes=20-") | ||
| 83 | if code != http.StatusRequestedRangeNotSatisfiable { | ||
| 84 | t.Fatalf("range past the end: status %d, want 416", code) | ||
| 85 | } | ||
| 86 | if h.Get("Content-Range") != "bytes */20" { | ||
| 87 | t.Fatalf("Content-Range = %q, want bytes */20", h.Get("Content-Range")) | ||
| 88 | } | ||
| 89 | if got != "" { | ||
| 90 | t.Fatalf("416 body = %q, want empty", got) | ||
| 91 | } | ||
| 92 | |||
| 93 | // A header the server does not implement is not an error: RFC 9110 allows | ||
| 94 | // answering with the whole representation. | ||
| 95 | for _, header := range []string{"bytes=0-1,3-4", "bytes=abc", "bytes=5-3", "items=0-1"} { | ||
| 96 | code, got, _ := get(header) | ||
| 97 | if code != http.StatusOK || got != body { | ||
| 98 | t.Fatalf("%q: %d %q, want 200 and the whole file", header, code, got) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||