···11+# Fix: clone leaves `warning: remote HEAD refers to nonexistent ref, unable to checkout`
22+33+## Context
44+55+Cloning a mirror of `golang/go` from objgitd downloads every object and resolves all
66+deltas, then aborts the checkout with:
77+88+```
99+warning: remote HEAD refers to nonexistent ref, unable to checkout
1010+```
1111+1212+**Root cause — a `main` vs `master` HEAD mismatch that is never healed.**
1313+`loadOrInit` (`cmd/objgitd/git_protocol.go:198`) initializes _every_ new repository
1414+with `git.Init(..., git.WithDefaultBranch(refs/heads/main))`, so its `HEAD` file is
1515+the symbolic ref `ref: refs/heads/main`. Receive-pack only writes the branch refs the
1616+client pushed (`updateReferences` in `receivepack.go:287`) and **never touches HEAD**.
1717+When you push a project whose default branch is not `main` — `golang/go` uses
1818+`master` — the repo ends up with `refs/heads/master` (plus release branches) but a
1919+`HEAD` that still points at the nonexistent `refs/heads/main`. HEAD dangles forever.
2020+2121+Confirmed empirically (both reproduced this turn):
2222+2323+- go-git v6.0.0-alpha.4's advertiser (`plumbing/transport/serve.go:96` `addReferences`)
2424+ resolves HEAD's symbolic target and, on `ErrReferenceNotFound`, **drops HEAD from the
2525+ advertisement entirely** (no `symref=HEAD:...` capability). Pointing HEAD at an
2626+ existing branch instead produces the correct `symref=HEAD:refs/heads/master`.
2727+- A real bare repo (`git init --bare -b main`, then push only `master`) reproduces the
2828+ exact client warning over `file://`. So the trigger is purely the dangling HEAD;
2929+ it is not a go-git wire bug.
3030+3131+This is a generic correctness gap, not specific to `golang/go`: any pushed repo whose
3232+default branch ≠ `main` is unclonable-to-worktree. Real git hosts (GitHub/GitLab/Gitea)
3333+repoint HEAD on push; objgitd does not.
3434+3535+**Intended outcome:** after a push, and for repos already sitting in the bucket, HEAD
3636+resolves to a branch that exists, so `git clone` checks out a worktree with no warning.
3737+3838+## Approach
3939+4040+Add an idempotent **heal HEAD** step and call it (a) on every load and (b) after every
4141+push. Healing only acts when HEAD is symbolic _and_ its target is missing; a detached
4242+HEAD, an already-valid HEAD, or a branch-less repo is left untouched. This fixes future
4343+pushes immediately and lets repos already broken in the bucket (e.g. `golang/go`)
4444+recover on their next clone — one `HEAD` rewrite, then self-correcting — without a
4545+re-push.
4646+4747+### New helpers (in `cmd/objgitd/git_protocol.go`, next to `loadOrInit`)
4848+4949+```go
5050+// ensureHEAD repoints a repo's HEAD at an existing branch when its symbolic target
5151+// is missing. objgitd inits every repo with HEAD -> refs/heads/main, but pushing a
5252+// project whose default branch differs (golang/go uses master) leaves HEAD dangling:
5353+// clients fetch every object yet cannot check out a worktree. Idempotent and best-
5454+// effort; a detached/valid HEAD or a branch-less repo is left alone.
5555+func ensureHEAD(st storage.Storer) error {
5656+ head, err := st.Reference(plumbing.HEAD)
5757+ if err != nil { return err }
5858+ if head.Type() != plumbing.SymbolicReference { return nil } // detached: leave
5959+ if _, err := st.Reference(head.Target()); err == nil { return nil } // already valid
6060+ else if !errors.Is(err, plumbing.ErrReferenceNotFound) { return err }
6161+ target, err := pickDefaultBranch(st)
6262+ if err != nil || target == "" { return err } // no branches: leave
6363+ return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, target))
6464+}
6565+6666+// pickDefaultBranch chooses a branch to point HEAD at: prefer refs/heads/main, then
6767+// master, then trunk; otherwise the lexicographically smallest branch (deterministic).
6868+func pickDefaultBranch(st storage.Storer) (plumbing.ReferenceName, error) { ... IterReferences, IsBranch ... }
6969+```
7070+7171+### Wiring (minimal, centralized)
7272+7373+1. **New method `(d *daemon) load(repoPath string) (storage.Storer, error)`** — wraps the
7474+ existing `d.loader.Load(&url.URL{Path: repoPath})` and, on success, calls
7575+ `ensureHEAD`, logging (not failing) on heal error so a clone is never broken by a
7676+ transient write failure. Returns the loader's error verbatim (preserves
7777+ `transport.ErrRepositoryNotFound` → 404 semantics).
7878+7979+2. **Replace the five direct read-path `d.loader.Load` calls with `d.load`:**
8080+ - `git_protocol.go:147` (git:// upload-pack), `:157` (upload-archive)
8181+ - `ssh.go:192` (ssh upload-pack), `:204` (upload-archive)
8282+ - `http.go:190` (`resolve`, the shared read path for both info/refs advertise and RPC)
8383+8484+3. **`loadOrInit` (`git_protocol.go:184`)** — call `d.load` instead of `d.loader.Load`
8585+ for its found path, so the receive-pack advertise phase and pre-push load also heal.
8686+ The create path is unchanged (fresh repo has no branches → `pickDefaultBranch` returns
8787+ "" → HEAD stays `main` until a branch is pushed).
8888+8989+4. **Post-push heal in `(d *daemon) receivePack` (`hooks.go:88`)** — after
9090+ `receivePackStreaming` returns `nil`, call `ensureHEAD(st)` (log on error). This makes
9191+ HEAD correct the moment a first push creates branches, so the write lands during a
9292+ push (a write op) rather than during the first subsequent clone.
9393+9494+No changes to the vendored go-git fork (`receivepack.go`) are needed.
9595+9696+## Critical files
9797+9898+- `cmd/objgitd/git_protocol.go` — add `ensureHEAD` + `pickDefaultBranch` + `d.load`; route `loadOrInit` and the two git:// read sites through `d.load`.
9999+- `cmd/objgitd/ssh.go` — two read sites → `d.load`.
100100+- `cmd/objgitd/http.go` — `resolve` read site (line 190) → `d.load`.
101101+- `cmd/objgitd/hooks.go` — `receivePack` calls `ensureHEAD` after a successful receive.
102102+103103+## Tests
104104+105105+Follow the **xe-go:go-table-driven-tests** skill (per project memory) and the existing
106106+`tt` table style; reuse `runGit`/`tryGit`/`seedRepo` from `git_protocol_test.go`.
107107+108108+1. **Unit (fast, no git binary)** — `ensureHEAD`/`pickDefaultBranch` over a memfs
109109+ `filesystem.Storage`: dangling `HEAD->main` with only `master` present → HEAD repoints
110110+ to `master`; `main` present → unchanged; detached HEAD → unchanged; no branches →
111111+ unchanged; main+master both present → prefers `main`.
112112+2. **End-to-end (gated on `git` on PATH)** — drive the daemon (HTTP and/or SSH like the
113113+ existing protocol tests): create a repo and push a single `master` branch (no `main`),
114114+ then `git clone` it and assert the worktree checked out (clone exit 0, expected file
115115+ present, no "nonexistent ref" warning) and that `info/refs` advertises
116116+ `symref=HEAD:refs/heads/master`.
117117+118118+## Verification
119119+120120+- `go build ./...` and `go test ./...` (and `go test ./cmd/objgitd/...` with `git` on PATH).
121121+- Manual against the live setup (local Garage at `:3903`, bucket `xe-git-repos`,
122122+ `SSH_BIND=:2222`): with `golang/go` already in the bucket, run
123123+ `git clone ssh://localhost:2222/github.com/golang/go.git` and confirm it checks out
124124+ `master` with no warning. The first clone heals HEAD (one `HEAD` write); subsequent
125125+ clones find HEAD already valid.
+301
docs/plans/pack-streaming.md
···11+# Stream pack downloads: serve reads while downloading (don't block clones)
22+33+## Context
44+55+Commit `e114543` added `internal/s3fs/packcache.go` to fix clone hangs: immutable
66+pack-directory files (`.pack`/`.idx`/`.rev`) are now downloaded **whole** to a
77+local temp file on first open and served from disk, collapsing thousands of S3
88+`GetObject` round-trips into one. That fixed the hang, but introduced a new
99+latency wall: `PackCache.open` blocks inside `e.once.Do` → `download` →
1010+`io.Copy(tmp, out.Body)` until the **entire** object lands on disk. No byte of
1111+the pack can be read until the last byte arrives, and every concurrent opener of
1212+the same key serializes behind that single `sync.Once`. For a multi-hundred-MB
1313+pack this is dead wait before upload-pack can even start walking the pack header.
1414+1515+The goal: **overlap the download with reads.** Stream the S3 body into the temp
1616+file, advancing a watermark as bytes land, and hand callers a reader
1717+_immediately_. Reads of already-downloaded byte ranges return at once; reads
1818+ahead of the watermark block only until that specific range arrives (the S3 body
1919+is a single sequential stream, so the watermark is monotonic and any requested
2020+offset below `size` is eventually satisfiable). This turns "wait for the whole
2121+pack, then serve" into "serve as it streams."
2222+2323+Per the design decisions taken up front:
2424+2525+- **RAM bound = bounded trailing window ("free read prefix").** We write-through
2626+ to the temp file as we download, so every byte below the watermark is already
2727+ durable on disk. The in-RAM buffer therefore only needs to hold a fixed-size
2828+ _trailing window_ `[n-ringCap, n)`; older bytes are dropped from RAM and served
2929+ from disk on demand. Peak RAM ≈ `inflight_entries × ringCap`, independent of
3030+ pack size. (Reads that fall below the window — e.g. a backward seek before the
3131+ download finishes — re-read from the disk fd, which is correct and cheap via
3232+ the page cache.)
3333+- **Eviction preserves unlink-while-open.** Eviction `os.Remove`s the temp path
3434+ even while readers (and the in-flight writer) hold it open; they survive via
3535+ their already-open fds on the unlinked inode. To make this work with streaming
3636+ we give the entry **one shared read fd** opened at creation (before any unlink)
3737+ rather than `os.Open`-per-reader, plus a refcount so the fds are closed only
3838+ once the last reader is gone.
3939+4040+## Current shape (what changes)
4141+4242+All in `internal/s3fs/packcache.go`. The entry/open/download trio is rewritten;
4343+`isPackCacheable`, `NewPackCache`, `Cleanup`, and the `WithPackCache`/`basic.go`
4444+wiring stay as-is. `basic.go:77-84` still calls `fs3.packCache.open(...)` and
4545+gets back a `billy.File`.
4646+4747+Today:
4848+4949+- `packEntry` = `{key, once, path, size, err, used}` — `once` runs the full
5050+ blocking `download`.
5151+- `download` = `GetObject` + `io.Copy(tmp, body)` + `tmp.Close()`, fully
5252+ synchronous.
5353+- `open` blocks in `once.Do`, then `os.Open(e.path)` per reader → `packCachedFile`
5454+ embedding an independent `*os.File`.
5555+- `evictLocked` unlinks victim temp files; open readers survive via their own fds.
5656+5757+## Design
5858+5959+### 1. Rework `packEntry` into a streaming entry
6060+6161+```go
6262+type packEntry struct {
6363+ key string
6464+ once sync.Once // guards the header GetObject + pump launch only
6565+6666+ mu sync.Mutex
6767+ cond *sync.Cond // broadcast as n advances, and when done/err flips
6868+6969+ wfd *os.File // write side: pump appends sequentially
7070+ rfd *os.File // shared read side: readers ReadAt at offsets < n (survives unlink)
7171+ path string
7272+7373+ win []byte // trailing RAM window; win covers [winStart, n)
7474+ winStart int64
7575+ n int64 // bytes downloaded+written so far (monotonic watermark)
7676+ size int64 // total, from Content-Length (-1 if unknown)
7777+ done bool // body fully drained, success
7878+ err error // terminal error (header or body)
7979+8080+ used uint64 // LRU, set on each open
8181+ refs int // live reader handles
8282+ evicted bool // path unlinked; close fds when refs hits 0
8383+}
8484+```
8585+8686+`ringCap` is a package const (start ~4 MiB; small enough to bound RAM, large
8787+enough to absorb the read-ahead the scanner does). Optionally surface as a flag
8888+later — not required for v1.
8989+9090+### 2. `open`: launch the pump once, return immediately
9191+9292+```go
9393+func (c *PackCache) open(ctx, client, bucket, key, name) (billy.File, error) {
9494+ // find/create entry under c.mu (unchanged)
9595+ e.once.Do(func() { c.start(ctx, client, bucket, key, e) })
9696+ if e.err != nil { return nil, e.err } // header GetObject failed (e.g. not-found)
9797+ // refs++, seq/used update under c.mu
9898+ return &packCachedFile{e: e, name: name}, nil // NO blocking download
9999+}
100100+```
101101+102102+`c.start` does the **header** fetch synchronously (so not-found / auth errors
103103+surface to the caller exactly as today, and `size` is known before the first
104104+read), then hands the body to a background goroutine:
105105+106106+```go
107107+func (c *PackCache) start(ctx, client, bucket, key, e) {
108108+ out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket:&bucket, Key:&key})
109109+ observeS3("GetObject", start, err)
110110+ if err != nil { /* notFound→fs.ErrNotExist; drop entry from map; e.err=... */ return }
111111+ tmp, _ := os.CreateTemp(c.dir, "obj-")
112112+ rfd, _ := os.Open(tmp.Name()) // shared read fd, opened before any unlink
113113+ e.wfd, e.rfd, e.path = tmp, rfd, tmp.Name()
114114+ if out.ContentLength != nil { e.size = *out.ContentLength } else { e.size = -1 }
115115+ c.mu.Lock(); c.curBytes += max(e.size,0); c.evictLocked(key); c.mu.Unlock() // reserve budget
116116+ go c.pump(e, out.Body)
117117+}
118118+```
119119+120120+Note the `GetObject` runs **outside** `c.mu` (inside `once.Do`), so concurrent
121121+opens of _different_ keys never serialize on the global lock; only the brief
122122+header fetch for the _same_ key is serialized (subsequent openers see `once`
123123+already done and attach instantly).
124124+125125+### 3. `pump`: write-through + advance watermark + trim RAM window
126126+127127+```go
128128+func (c *PackCache) pump(e *packEntry, body io.ReadCloser) {
129129+ defer body.Close()
130130+ chunk := make([]byte, 256<<10)
131131+ for {
132132+ m, rerr := body.Read(chunk)
133133+ if m > 0 {
134134+ if _, werr := e.wfd.Write(chunk[:m]); werr != nil { e.fail(werr); return }
135135+ e.mu.Lock()
136136+ e.win = append(e.win, chunk[:m]...)
137137+ e.n += int64(m)
138138+ if int64(len(e.win)) > ringCap { // drop prefix now safe on disk
139139+ drop := int64(len(e.win)) - ringCap
140140+ e.win = e.win[drop:]; e.winStart += drop
141141+ }
142142+ e.cond.Broadcast()
143143+ e.mu.Unlock()
144144+ }
145145+ if rerr == io.EOF { break }
146146+ if rerr != nil { e.fail(rerr); return }
147147+ }
148148+ e.wfd.Close()
149149+ e.mu.Lock()
150150+ e.done = true; e.size = e.n; e.win = nil; e.winStart = e.n // free RAM; disk has it all
151151+ e.cond.Broadcast(); e.mu.Unlock()
152152+ // if evicted && refs==0 during streaming, close rfd + remove (orphan cleanup)
153153+}
154154+```
155155+156156+`e.fail(err)` sets `e.err`, broadcasts, closes `wfd`, removes the temp file, and
157157+drops the entry from the map so a later open re-downloads (mirrors today's
158158+failed-download cleanup). Write-through means **every byte `< n` is on disk**, so
159159+`win` is purely a hot cache; trimming its prefix never loses data.
160160+161161+### 4. Reader: `packCachedFile` becomes a streaming view over the entry
162162+163163+Keep the type name `packCachedFile` (minimizes churn in `basic.go` and the
164164+existing tests, which call `.Read`/`.ReadAt`/`.Seek`/`.Name`/`.Close`). It no
165165+longer embeds `*os.File`; it holds `*packEntry` + its own cursor:
166166+167167+```go
168168+type packCachedFile struct {
169169+ e *packEntry
170170+ name string
171171+ pos int64
172172+ closed bool
173173+}
174174+```
175175+176176+Core read primitive — `readAt` blocks only until the requested range is
177177+available:
178178+179179+```go
180180+func (f *packCachedFile) ReadAt(p []byte, off int64) (int, error) {
181181+ if f.closed { return 0, os.ErrClosed } // FSObject reopen contract (see CLAUDE.md)
182182+ return f.e.readAt(p, off)
183183+}
184184+185185+func (e *packEntry) readAt(p []byte, off int64) (int, error) {
186186+ if off < 0 { return 0, errNegativeOffset }
187187+ e.mu.Lock()
188188+ for {
189189+ if e.err != nil { err := e.err; e.mu.Unlock(); return 0, err }
190190+ if e.size >= 0 && off >= e.size { e.mu.Unlock(); return 0, io.EOF }
191191+ if off+int64(len(p)) <= e.n { // full range available
192192+ // serve from RAM window if covered, else from disk fd
193193+ if off >= e.winStart {
194194+ n := copy(p, e.win[off-e.winStart:])
195195+ e.mu.Unlock(); return n, nil
196196+ }
197197+ rfd := e.rfd; e.mu.Unlock()
198198+ return rfd.ReadAt(p, off) // below window → disk (page cache)
199199+ }
200200+ if e.done { // range extends past EOF
201201+ rfd := e.rfd; e.mu.Unlock()
202202+ return rfd.ReadAt(p, off) // returns (partial, io.EOF) correctly
203203+ }
204204+ e.cond.Wait() // ahead of watermark → wait for more
205205+ }
206206+}
207207+```
208208+209209+`Read`/`Seek` reuse `readAt` against `f.pos` (Seek tracks pos; `SeekEnd` waits
210210+for `size` to be known — it is set at header time, so no real wait). `Write*`,
211211+`Truncate`, `Lock`/`Unlock`, `Name`, `Stat` mirror the current
212212+`packCachedFile`; `Stat().Size()` returns `e.size`.
213213+214214+`Close`:
215215+216216+```go
217217+func (f *packCachedFile) Close() error {
218218+ if f.closed { return ErrFileClosed-or-nil-per-current }
219219+ f.closed = true
220220+ f.e.cache.release(f.e) // refs--; if evicted && refs==0 → close rfd, (wfd already closed)
221221+ return nil
222222+}
223223+```
224224+225225+### 5. Eviction with refcount + unlink-while-open
226226+227227+`evictLocked` picks the least-recently-used victim (skipping `keep` and entries
228228+with no `path` yet), then:
229229+230230+- `os.Remove(victim.path)` — unlink; live `wfd`/`rfd`/reader fds keep working on
231231+ the inode (Linux).
232232+- `victim.evicted = true`, `curBytes -= victim.size`, delete from `entries` map
233233+ (new opens re-download).
234234+- If `victim.refs == 0 && victim.done`, close `rfd` now; otherwise the last
235235+ `release` (or `pump` finishing) closes it. The shared `rfd` is what makes
236236+ unlink-while-open work for readers that haven't issued a disk read yet —
237237+ there's no per-reader `os.Open(path)` that could race the unlink.
238238+239239+`Cleanup` additionally closes any open `wfd`/`rfd` before `os.RemoveAll(dir)`.
240240+241241+## Files to modify
242242+243243+- `internal/s3fs/packcache.go` — the whole rewrite above (entry, `open`,
244244+ `start`, `pump`, `readAt`, reader methods, `evictLocked`, refcount/`release`,
245245+ `Cleanup`). Add `ringCap` const and `errNegativeOffset`.
246246+- `internal/s3fs/packcache_test.go` — keep existing tests passing (they use a
247247+ synchronous full-bytes body, which still works: the pump drains it before the
248248+ first read in practice, and reads block-then-serve regardless). Add a
249249+ controllable body to exercise the streaming path (see Verification).
250250+- No changes needed in `basic.go`/`chroot.go`/`filesystem.go`/`main.go`:
251251+ `open` still returns a `billy.File`; `WithPackCache`, flags, and Chroot
252252+ sharing are untouched.
253253+254254+Reuse: the read-while-write blocking pattern is the same idea already proven in
255255+`internal/s3fs/tempfs.go` (`tempBuffer.readAt` returns `io.EOF` past the end for
256256+the _write_ path); here the read path **blocks on a cond** instead of returning
257257+EOF, because go-git's `packfile.FSObject`/scanner on the clone path expects
258258+`ReadAt` to fill its buffer, not retry. The `observeS3("GetObject", …)` metric
259259+hook stays at the header fetch. The FSObject `os.ErrClosed` contract
260260+(`internal/s3fs/file.go`, documented in CLAUDE.md) is preserved by the `closed`
261261+guard returning `os.ErrClosed` from `Read`/`ReadAt`/`Seek`.
262262+263263+## Verification
264264+265265+1. `go test ./internal/s3fs/...` — existing `TestPackCache*` must stay green
266266+ (downloads-once, independent cursors, closed-read-fails, missing-object,
267267+ eviction, `isPackCacheable`).
268268+2. New table-driven tests (follow `xe-go:go-table-driven-tests`) in
269269+ `packcache_test.go` using a **gated body** — an `io.ReadCloser` that releases
270270+ bytes only when the test signals (channel/`sync.Cond`), wrapping the
271271+ `packStub` pattern:
272272+ - **serve-while-downloading:** open returns before the body is fully
273273+ released; `ReadAt` at a low offset returns once that prefix is released,
274274+ _before_ the tail is; assert it does not wait for EOF.
275275+ - **read ahead of watermark blocks then unblocks:** `ReadAt` for an offset
276276+ past the released watermark blocks; releasing more bytes unblocks it with
277277+ correct data (run the read in a goroutine, assert it's pending, release,
278278+ assert completion).
279279+ - **below-window reads hit disk:** with `ringCap` set small in the test,
280280+ release > ringCap bytes, then `ReadAt` at offset 0 (now trimmed from RAM)
281281+ returns correct bytes from the disk fd.
282282+ - **error mid-stream:** body returns a non-EOF error after N bytes; in-flight
283283+ and subsequent `ReadAt`s past N return that error; a later open re-downloads.
284284+ - **header not-found:** unchanged — `open` returns `fs.ErrNotExist`
285285+ synchronously (no goroutine launched).
286286+ - **eviction unlink-while-open:** existing test semantics, plus a variant
287287+ evicting an entry that is _still streaming_ and confirming the holding
288288+ reader finishes correctly.
289289+ - **os.ErrClosed after Close** during streaming.
290290+ - **concurrent readers** of one streaming key all see identical full bytes;
291291+ `GetObject` count == 1.
292292+3. End-to-end clone against a real bucket (manual): build
293293+ `go build -o objgitd ./cmd/objgitd`, run with `-pack-cache-bytes` set, and
294294+ `git clone http://localhost:8080/<repo>.git` of a repo with a large pack;
295295+ confirm the clone makes progress (objects counting) while the pack is still
296296+ downloading rather than stalling until it completes, and that a second
297297+ concurrent clone of the same repo benefits from the shared in-flight entry.
298298+ Watch `objgit_s3_requests_total{operation="GetObject"}` stays ~one per
299299+ pack-dir file.
300300+4. `go test ./cmd/objgitd/...` — protocol tests still pass (clone/fetch over
301301+ HTTP, git://, SSH) with `git` on PATH.
+135
docs/plans/s3fs-reduce-listobjectsv2.md
···11+# Reduce ListObjectsV2 volume in objgitd
22+33+## Context
44+55+objgitd issues a high volume of `ListObjectsV2` calls against Tigris. The
66+codebase already has a sophisticated listing cache (`internal/s3fs/listingcache.go`)
77+with a **subtree-scan** optimization meant to collapse per-folder listings into a
88+single recursive scan — but investigation shows that optimization is **effectively
99+dead in any real deployment**, and a background warmer multiplies the remaining
1010+waste. This plan fixes the root cause so the subtree optimization actually
1111+engages.
1212+1313+## Root-cause findings (from reading the code)
1414+1515+**1. Subtree caching never matches chrooted repos — the big one.**
1616+Every repo is served through a `Chroot`: both `loadOrInit` (`cmd/objgitd/git_protocol.go:192`)
1717+and go-git's `FilesystemLoader.Load` (`plumbing/transport/loader.go:46`) chroot the
1818+filesystem to the repo path. So canonical S3 keys are `<repo>.git/refs/...` and
1919+`<repo>.git/objects/...`.
2020+2121+But the recursive-root match is anchored at the bucket root:
2222+2323+```go
2424+// listingcache.go:290
2525+func (c *ListingCache) recursiveRoot(prefix string) (string, bool) {
2626+ for _, r := range c.roots { // default {"refs/"}
2727+ if prefix == r || strings.HasPrefix(prefix, r) { ... }
2828+ }
2929+}
3030+```
3131+3232+`strings.HasPrefix("myrepo.git/refs/heads/", "refs/")` is **false**. So the
3333+default `-s3-cache-recursive-prefixes refs/` matches nothing once a repo lives at
3434+`myrepo.git/…`, and **every** listing falls to delimited per-folder mode
3535+(`listFolder`).
3636+3737+**2. Loose-object negative lookups storm `objects/<xx>/`.**
3838+go-git runs non-exclusive, so `hasObject` returns nil without listing
3939+(`dotgit.go:646`) and `Object`/`ObjectStat` go straight to
4040+`fs.Open`/`fs.Stat("objects/<xx>/<38hex>")` for any object not already in the
4141+in-memory LRU (`dotgit.go:753,771`). Because objgitd keeps packs whole (no loose
4242+objects), each distinct two-hex prefix probed → `resolve` → `cache.list("…/objects/<xx>/")`
4343+→ one delimited `ListObjectsV2` returning empty. Up to **256 list calls per clone**,
4444+none of which the (dead) subtree cache can absorb.
4545+4646+**3. The warmer multiplies it.**
4747+`list()` calls `touch(prefix)` for every listed prefix, so `seen` accumulates all
4848+~256 `objects/<xx>/` prefixes plus the refs folders. With `-s3-cache-refresh 30s`
4949+and `-s3-cache-idle 10m`, `RunWarmer` re-lists every seen prefix every 30s for up
5050+to 10 minutes — on the order of **thousands of background `ListObjectsV2` calls
5151+following a single clone**, even with no further traffic. (`warmOnce` _does_
5252+dedupe recursive prefixes to their root — but since no prefix matches a recursive
5353+root today, nothing is deduped.)
5454+5555+## Fix: register each chroot repo root as a recursive subtree root
5656+5757+The architecture already gives us the right unit: **a chroot == a repo**. Make
5858+each `Chroot` register its own root with the listing cache as a recursive subtree
5959+prefix. Then one delimiter-less `ListObjectsV2` over `myrepo.git/` serves the
6060+entire repo (refs, `objects/pack/` listing, HEAD, config, packed-refs, _and_ every
6161+loose-object negative lookup) from a single scan — for objgitd's whole-pack repos
6262+that subtree is a dozen-ish objects.
6363+6464+This single change:
6565+6666+- revives the subtree optimization (currently dead) for the real chrooted layout;
6767+- extends it to `objects/`, eliminating the ~256 negative-lookup list calls/clone;
6868+- collapses the warmer from ~260 lists/tick/repo to **1** (warmOnce already dedupes
6969+ to the root).
7070+7171+`MaxSubtreeKeys` (default 50000) still guards pathological repos: an oversized
7272+subtree is marked `Truncated` and the code falls back to delimited per-folder
7373+listing automatically (`list()` at `listingcache.go:206`).
7474+7575+### Changes
7676+7777+**`internal/s3fs/listingcache.go`** — make `roots` mutable and add a registrar:
7878+7979+- Change `roots []string` to a concurrency-safe snapshot. Simplest:
8080+ `roots atomic.Pointer[[]string]` (kept longest-first), or a `sync.RWMutex`
8181+ guarding the slice. `recursiveRoot` is hot and read-only, so an atomic snapshot
8282+ read is preferred.
8383+- Add `func (c *ListingCache) registerRoot(root string)`: normalize to a trailing
8484+ `/`, no-op if already present or if `c == nil`, otherwise insert and re-sort
8585+ longest-first (reuse the logic in `normalizeRoots`, `listingcache.go:331`).
8686+- `recursiveRoot` reads the snapshot instead of the immutable field. No logic
8787+ change — once `myrepo.git/` is a root it returns it for any prefix beneath it.
8888+8989+**`internal/s3fs/chroot.go`** — register the new root on chroot:
9090+9191+- After building `nfs` (`chroot.go:21`), if `fs3.cache != nil && p != ""` call
9292+ `fs3.cache.registerRoot(p + "/")`. `p` is the joined canonical root
9393+ (e.g. `myrepo.git`); the cache wants prefixes ending in `/`.
9494+9595+No change needed in `cmd/objgitd` — both load paths chroot already, so registration
9696+happens automatically the first time a repo's storer is built.
9797+9898+### Notes / decisions
9999+100100+- **Keep the `-s3-cache-recursive-prefixes refs/` default.** It is now redundant
101101+ for chrooted repos but harmless, and still works for a bucket-root single-repo
102102+ layout or as an explicit override.
103103+- **Whole-repo root vs. per-subdir (`objects/`+`refs/`).** Whole-repo is simpler
104104+ and strictly fewer scans (1 vs 2). Invalidation blast radius is fine: `invalidate`
105105+ already walks to the bucket root (`listingcache.go:472`), so any push/ref update
106106+ invalidates the repo subtree and the next read re-scans once.
107107+- **Optional hardening (only if a giant-repo regression shows up):** have `warmOnce`
108108+ skip a root whose last subtree result was `Truncated`, so the warmer doesn't
109109+ re-scan up to `MaxSubtreeKeys` every tick for a pathological repo. Not part of the
110110+ core fix; objgitd's whole-pack repos never hit this.
111111+112112+## Verification
113113+114114+1. `go build ./...` and `go test ./internal/s3fs/...`.
115115+2. **New table-driven test** in `internal/s3fs/listingcache_test.go` using the
116116+ existing `stubClient` (has an atomic `lists` counter and honors delimiter/
117117+ pagination, `listingcache_test.go:25-96`). Pattern after `TestListingCacheChrootShares`
118118+ (`listingcache_test.go:461`):
119119+ - Seed the stub with `myrepo.git/refs/heads/main`, `myrepo.git/objects/pack/pack-X.pack`,
120120+ `myrepo.git/objects/pack/pack-X.idx`, `myrepo.git/HEAD`.
121121+ - `Chroot("myrepo.git")`, then drive the loose-object access pattern: `Stat`
122122+ several non-existent `objects/<xx>/<hash>` paths across different `xx`,
123123+ `ReadDir("objects/pack")`, `ReadDir("refs/heads")`.
124124+ - Assert `stub.lists` is **1** (one subtree scan), versus the pre-fix behavior of
125125+ one list per distinct `objects/<xx>/` prefix + one per refs folder.
126126+ - Add a write (`Create`+`Rename` a new pack, or invalidate) and assert exactly
127127+ one additional scan on the next read (re-scan after invalidation).
128128+ - Use `xe-go:go-table-driven-tests` conventions (see MEMORY: tests here follow
129129+ that skill).
130130+3. **Manual end-to-end** against a real bucket: run `./objgitd -bucket $BUCKET
131131+-http-bind :8080 -allow-push`, push a repo, then `git clone` it twice. Watch
132132+ `objgit_s3_requests_total{api="ListObjectsV2"}` on `/metrics` (or the
133133+ listing-cache hit/miss log emitted from `main.go:111`): the per-clone
134134+ `ListObjectsV2` count should drop from order-of-hundreds to a small constant,
135135+ and the steady-state warmer rate should drop to ~1 per repo per refresh tick.
+443-73
internal/s3fs/packcache.go
···2233import (
44 "context"
55+ "errors"
56 "fmt"
67 "io"
78 "io/fs"
···1112 "time"
12131314 "github.com/aws/aws-sdk-go-v2/service/s3"
1515+ "github.com/go-git/go-billy/v6"
1416)
15171818+// ringCap bounds the in-RAM trailing window held for an in-flight download.
1919+// Every byte below the watermark is already written to the temp file, so the
2020+// window is only a hot cache for the most-recently-downloaded bytes; reads that
2121+// fall below it are served from the disk fd. Peak RAM is therefore roughly
2222+// (concurrent in-flight packs) * ringCap regardless of pack size.
2323+const ringCap = 4 << 20 // 4 MiB
2424+2525+var errNegativeOffset = errors.New("s3fs: negative offset")
2626+1627// isPackCacheable reports whether key names an immutable pack-directory file
1728// that benefits from the local temp-file cache. These files are content-
1829// addressed (pack-<sha>.{pack,idx,rev}) and re-read with random access many
···3041// fresh S3 GetObject and a clone never completes. The cache is shared by pointer
3142// across an S3FS and all of its Chroot children.
3243//
4444+// Downloads stream: open launches a background pump that writes the S3 body to a
4545+// temp file while advancing a watermark, and returns a reader immediately.
4646+// Reads of already-downloaded ranges return at once; reads ahead of the
4747+// watermark block only until that range arrives (the body is one sequential
4848+// stream, so the watermark is monotonic). This overlaps the download with the
4949+// clone instead of blocking it until the whole pack lands.
5050+//
3351// Entries are keyed by S3 object key. Each downloads its object once and is
3452// reused across opens; a total-bytes budget evicts the least-recently-opened
3535-// entries. Eviction unlinks the temp file, which on Linux leaves already-open
3636-// readers working until they close, so eviction never corrupts an in-flight
3737-// read.
5353+// entries. Eviction unlinks the temp file, which on Linux leaves the in-flight
5454+// writer and already-open readers working on the unlinked inode, so eviction
5555+// never corrupts an in-flight read.
3856type PackCache struct {
3957 dir string
4058 maxBytes int64
···4563 seq uint64 // monotonic open counter; entry.used orders the LRU
4664}
47654848-// packEntry is one cached object. once guards the single download; path/size/err
4949-// are set by it. used is the seq of the most recent open, for LRU ordering.
6666+// packEntry is one cached object, possibly still downloading. once guards the
6767+// header GetObject and the launch of the pump goroutine; the pump then fills the
6868+// temp file and the RAM window concurrently with readers.
5069type packEntry struct {
5151- key string
5252- once sync.Once
7070+ cache *PackCache
7171+ key string
7272+ once sync.Once
7373+7474+ mu sync.Mutex
7575+ cond *sync.Cond
7676+7777+ wfd *os.File // write side: the pump appends sequentially
7878+ rfd *os.File // shared read side: serves offsets < n; survives unlink
5379 path string
5454- size int64
5555- err error
5656- used uint64
8080+8181+ win []byte // trailing RAM window covering [winStart, n)
8282+ winStart int64
8383+ n int64 // bytes downloaded and written so far (monotonic watermark)
8484+ size int64 // total size from Content-Length; -1 until known
8585+ done bool // body fully drained successfully
8686+ err error // terminal error (header or body)
8787+8888+ used uint64 // seq of the most recent open, for LRU ordering
8989+ refs int // live reader handles
9090+ reserved int64 // bytes this entry has added to PackCache.curBytes
9191+ evicted bool // path unlinked; close fds once refs hits 0
9292+9393+ closeOnce sync.Once // guards closing rfd exactly once
5794}
58955996// NewPackCache creates a pack cache writing temp files under a fresh directory
···76113 }, nil
77114}
781157979-// Cleanup removes the cache's temp directory and all files in it. Already-open
8080-// readers keep working (unlinked-while-open); new opens after Cleanup fail.
116116+// Cleanup removes the cache's temp directory and all files in it, closing any
117117+// open read/write handles first. Readers that still hold a *packCachedFile keep
118118+// their entry pointer but will see closed fds; new opens after Cleanup fail.
81119func (c *PackCache) Cleanup() error {
82120 c.mu.Lock()
83121 dir := c.dir
122122+ entries := c.entries
84123 c.entries = map[string]*packEntry{}
85124 c.curBytes = 0
86125 c.mu.Unlock()
126126+ for _, e := range entries {
127127+ e.closeRead()
128128+ e.mu.Lock()
129129+ if e.wfd != nil {
130130+ e.wfd.Close()
131131+ }
132132+ e.mu.Unlock()
133133+ }
87134 return os.RemoveAll(dir)
88135}
891369090-// open returns a billy.File for key, downloading the object to a temp file on
9191-// first use and serving from that file thereafter. Each call returns an
9292-// independent *os.File handle so concurrent readers have their own seek cursor.
9393-func (c *PackCache) open(ctx context.Context, client s3Client, bucket, key, name string) (*packCachedFile, error) {
137137+// open returns a billy.File for key. On first use it fetches the object header
138138+// (so not-found and auth errors surface synchronously) and launches a pump that
139139+// streams the body to a temp file; the returned reader serves bytes as they
140140+// arrive without waiting for the whole download. Each call returns an
141141+// independent handle with its own cursor over the shared entry.
142142+func (c *PackCache) open(ctx context.Context, client s3Client, bucket, key, name string) (billy.File, error) {
94143 c.mu.Lock()
95144 e := c.entries[key]
96145 if e == nil {
9797- e = &packEntry{key: key}
146146+ e = &packEntry{cache: c, key: key, size: -1}
147147+ e.cond = sync.NewCond(&e.mu)
98148 c.entries[key] = e
99149 }
150150+ // Reserve a reference before releasing the lock so a concurrent open of
151151+ // another key can't evict-and-close this entry out from under us. Eviction
152152+ // may still unlink it (unlink-while-open), but won't close its fds while
153153+ // refs > 0.
154154+ e.refs++
155155+ c.seq++
156156+ e.used = c.seq
100157 c.mu.Unlock()
101158102102- e.once.Do(func() {
103103- path, size, err := c.download(ctx, client, bucket, key)
104104- if err != nil {
105105- e.err = err
106106- // Drop the failed entry so a later open retries the download.
107107- c.mu.Lock()
108108- if c.entries[key] == e {
109109- delete(c.entries, key)
110110- }
111111- c.mu.Unlock()
112112- return
159159+ e.once.Do(func() { c.start(ctx, client, bucket, key, e) })
160160+161161+ e.mu.Lock()
162162+ startErr := e.err
163163+ e.mu.Unlock()
164164+ if startErr != nil {
165165+ c.release(e)
166166+ return nil, startErr
167167+ }
168168+169169+ return &packCachedFile{e: e, name: name}, nil
170170+}
171171+172172+// start fetches the object and launches the background pump. It runs once per
173173+// entry (guarded by entry.once) and outside c.mu, so concurrent opens of
174174+// different keys don't serialise on the network call. A header/GetObject error
175175+// is recorded on the entry and the entry is dropped so a later open retries.
176176+func (c *PackCache) start(ctx context.Context, client s3Client, bucket, key string, e *packEntry) {
177177+ began := time.Now()
178178+ out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &key})
179179+ observeS3("GetObject", began, err)
180180+ if err != nil {
181181+ if isNotFound(err) {
182182+ err = &os.PathError{Op: "open", Path: key, Err: fs.ErrNotExist}
183183+ } else {
184184+ err = fmt.Errorf("pack cache GetObject %q: %w", key, err)
113185 }
114114- e.path, e.size = path, size
186186+ c.dropFailed(e, err)
187187+ return
188188+ }
189189+190190+ tmp, terr := os.CreateTemp(c.dir, "obj-")
191191+ if terr != nil {
192192+ out.Body.Close()
193193+ c.dropFailed(e, fmt.Errorf("pack cache temp file: %w", terr))
194194+ return
195195+ }
196196+ rfd, rerr := os.Open(tmp.Name())
197197+ if rerr != nil {
198198+ tmp.Close()
199199+ os.Remove(tmp.Name())
200200+ out.Body.Close()
201201+ c.dropFailed(e, fmt.Errorf("pack cache reopen %q: %w", tmp.Name(), rerr))
202202+ return
203203+ }
204204+205205+ size := int64(-1)
206206+ if out.ContentLength != nil {
207207+ size = *out.ContentLength
208208+ }
209209+210210+ e.mu.Lock()
211211+ e.wfd, e.rfd, e.path, e.size = tmp, rfd, tmp.Name(), size
212212+ e.mu.Unlock()
213213+214214+ if size > 0 {
115215 c.mu.Lock()
216216+ e.reserved = size
116217 c.curBytes += size
117218 c.evictLocked(key)
118219 c.mu.Unlock()
119119- })
120120- if e.err != nil {
121121- return nil, e.err
220220+ }
221221+222222+ go c.pump(e, out.Body)
223223+}
224224+225225+// dropFailed records a terminal error on the entry, wakes any waiters, and
226226+// removes it from the map so a later open re-fetches.
227227+func (c *PackCache) dropFailed(e *packEntry, err error) {
228228+ e.mu.Lock()
229229+ e.err = err
230230+ e.cond.Broadcast()
231231+ e.mu.Unlock()
232232+ c.mu.Lock()
233233+ if c.entries[e.key] == e {
234234+ delete(c.entries, e.key)
122235 }
236236+ c.mu.Unlock()
237237+}
123238124124- f, err := os.Open(e.path)
125125- if err != nil {
126126- // The cached file was evicted/cleaned between download and open; retry
127127- // through a fresh entry so the object is fetched again.
128128- c.mu.Lock()
129129- if c.entries[key] == e {
130130- delete(c.entries, key)
239239+// pump streams body into the entry's temp file, advancing the watermark and the
240240+// trailing RAM window as bytes arrive, and broadcasting so blocked readers wake.
241241+func (c *PackCache) pump(e *packEntry, body io.ReadCloser) {
242242+ defer body.Close()
243243+ chunk := make([]byte, 256<<10)
244244+ for {
245245+ m, rerr := body.Read(chunk)
246246+ if m > 0 {
247247+ if _, werr := e.wfd.Write(chunk[:m]); werr != nil {
248248+ c.failStream(e, fmt.Errorf("pack cache write %q: %w", e.key, werr))
249249+ return
250250+ }
251251+ e.mu.Lock()
252252+ e.win = append(e.win, chunk[:m]...)
253253+ e.n += int64(m)
254254+ if int64(len(e.win)) > ringCap {
255255+ drop := int64(len(e.win)) - ringCap
256256+ e.win = e.win[drop:]
257257+ e.winStart += drop
258258+ }
259259+ e.cond.Broadcast()
260260+ e.mu.Unlock()
131261 }
132132- c.mu.Unlock()
133133- return nil, err
262262+ if rerr == io.EOF {
263263+ break
264264+ }
265265+ if rerr != nil {
266266+ c.failStream(e, fmt.Errorf("pack cache download %q: %w", e.key, rerr))
267267+ return
268268+ }
134269 }
135270271271+ if cerr := e.wfd.Close(); cerr != nil {
272272+ c.failStream(e, fmt.Errorf("pack cache flush %q: %w", e.key, cerr))
273273+ return
274274+ }
275275+276276+ e.mu.Lock()
277277+ e.wfd = nil
278278+ e.done = true
279279+ e.size = e.n
280280+ e.win = nil // every byte is on disk now; drop the RAM window
281281+ e.winStart = e.n
282282+ e.cond.Broadcast()
283283+ e.mu.Unlock()
284284+285285+ // If we under-reserved (size was unknown), reconcile the budget now.
136286 c.mu.Lock()
137137- c.seq++
138138- e.used = c.seq
287287+ if e.reserved != e.n {
288288+ c.curBytes += e.n - e.reserved
289289+ e.reserved = e.n
290290+ c.evictLocked(e.key)
291291+ }
139292 c.mu.Unlock()
140293141141- return &packCachedFile{File: f, name: name}, nil
294294+ c.maybeCloseEvicted(e)
142295}
143296144144-// download streams the full object to a temp file and returns its path and size.
145145-func (c *PackCache) download(ctx context.Context, client s3Client, bucket, key string) (string, int64, error) {
146146- start := time.Now()
147147- out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &key})
148148- observeS3("GetObject", start, err)
149149- if err != nil {
150150- if isNotFound(err) {
151151- return "", 0, &os.PathError{Op: "open", Path: key, Err: fs.ErrNotExist}
152152- }
153153- return "", 0, fmt.Errorf("pack cache GetObject %q: %w", key, err)
297297+// failStream records a mid-stream download error, discards the partial temp
298298+// file, and drops the entry. Already-open readers see the error on their next
299299+// blocked or out-of-range read.
300300+func (c *PackCache) failStream(e *packEntry, err error) {
301301+ e.mu.Lock()
302302+ if e.wfd != nil {
303303+ e.wfd.Close()
304304+ e.wfd = nil
154305 }
155155- defer out.Body.Close()
306306+ path := e.path
307307+ reserved := e.reserved
308308+ e.reserved = 0
309309+ e.err = err
310310+ e.cond.Broadcast()
311311+ e.mu.Unlock()
312312+313313+ e.closeRead()
314314+ if path != "" {
315315+ os.Remove(path)
316316+ }
156317157157- tmp, err := os.CreateTemp(c.dir, "obj-")
158158- if err != nil {
159159- return "", 0, fmt.Errorf("pack cache temp file: %w", err)
318318+ c.mu.Lock()
319319+ c.curBytes -= reserved
320320+ if c.entries[e.key] == e {
321321+ delete(c.entries, e.key)
160322 }
161161- n, err := io.Copy(tmp, out.Body)
162162- if cerr := tmp.Close(); err == nil {
163163- err = cerr
323323+ c.mu.Unlock()
324324+}
325325+326326+// release drops one reader reference. When the last reader of an evicted entry
327327+// closes, its shared read fd is closed (the temp file is already unlinked).
328328+func (c *PackCache) release(e *packEntry) {
329329+ c.mu.Lock()
330330+ e.refs--
331331+ closeNow := e.refs == 0 && e.evicted
332332+ c.mu.Unlock()
333333+ if closeNow {
334334+ e.closeRead()
164335 }
165165- if err != nil {
166166- os.Remove(tmp.Name())
167167- return "", 0, fmt.Errorf("pack cache download %q: %w", key, err)
336336+}
337337+338338+// maybeCloseEvicted closes an evicted entry's read fd if no readers remain.
339339+// Called after the pump finishes, since an entry can be evicted mid-stream.
340340+func (c *PackCache) maybeCloseEvicted(e *packEntry) {
341341+ c.mu.Lock()
342342+ closeNow := e.refs == 0 && e.evicted
343343+ c.mu.Unlock()
344344+ if closeNow {
345345+ e.closeRead()
168346 }
169169- return tmp.Name(), n, nil
347347+}
348348+349349+// closeRead closes the shared read fd exactly once.
350350+func (e *packEntry) closeRead() {
351351+ e.closeOnce.Do(func() {
352352+ e.mu.Lock()
353353+ rfd := e.rfd
354354+ e.mu.Unlock()
355355+ if rfd != nil {
356356+ rfd.Close()
357357+ }
358358+ })
170359}
171360172361// evictLocked removes least-recently-opened entries until the cache is within
173362// budget. keep is never evicted (it is the entry the caller just populated).
174174-// Callers hold c.mu. A non-positive maxBytes disables eviction.
363363+// Callers hold c.mu. A non-positive maxBytes disables eviction. Eviction unlinks
364364+// the temp file even while readers or the in-flight writer hold it open; they
365365+// survive via their fds on the unlinked inode (Linux). The shared read fd is
366366+// closed only once the last reader leaves (see release/maybeCloseEvicted).
175367func (c *PackCache) evictLocked(keep string) {
176368 if c.maxBytes <= 0 {
177369 return
···189381 if victim == nil {
190382 return // nothing evictable
191383 }
192192- os.Remove(victim.path) // open readers survive on Linux (unlinked fd)
193193- c.curBytes -= victim.size
384384+ os.Remove(victim.path) // open fds survive on Linux (unlinked inode)
385385+ victim.mu.Lock()
386386+ victim.evicted = true
387387+ refs := victim.refs
388388+ victim.mu.Unlock()
389389+ c.curBytes -= victim.reserved
194390 delete(c.entries, victim.key)
391391+ if refs == 0 {
392392+ victim.closeRead()
393393+ }
195394 }
196395}
197396198198-// packCachedFile is a read-only billy.File backed by a local temp file. It
199199-// embeds *os.File for Read/ReadAt/Seek/Close/Stat and supplies the billy-only
200200-// Lock/Unlock; writes are rejected.
397397+// readAt fills p from the entry, blocking until the full requested range has
398398+// been downloaded (or the download finishes or fails). This matches ReadAt's
399399+// fill-or-error contract, which go-git's packfile.FSObject relies on. Bytes in
400400+// the RAM window are served from memory; bytes that have scrolled below the
401401+// window are served from the shared read fd.
402402+func (e *packEntry) readAt(p []byte, off int64) (int, error) {
403403+ if off < 0 {
404404+ return 0, errNegativeOffset
405405+ }
406406+ if len(p) == 0 {
407407+ return 0, nil
408408+ }
409409+ e.mu.Lock()
410410+ for {
411411+ if e.err != nil {
412412+ err := e.err
413413+ e.mu.Unlock()
414414+ return 0, err
415415+ }
416416+ if e.size >= 0 && off >= e.size {
417417+ e.mu.Unlock()
418418+ return 0, io.EOF
419419+ }
420420+ if off+int64(len(p)) <= e.n {
421421+ if off >= e.winStart {
422422+ n := copy(p, e.win[off-e.winStart:])
423423+ e.mu.Unlock()
424424+ return n, nil
425425+ }
426426+ rfd := e.rfd
427427+ e.mu.Unlock()
428428+ return rfd.ReadAt(p, off)
429429+ }
430430+ if e.done {
431431+ // Range extends past EOF: serve what exists from disk; os.File.ReadAt
432432+ // returns the partial read plus io.EOF.
433433+ rfd := e.rfd
434434+ e.mu.Unlock()
435435+ return rfd.ReadAt(p, off)
436436+ }
437437+ e.cond.Wait()
438438+ }
439439+}
440440+441441+// readSome fills p with whatever is available at off (at least one byte once any
442442+// data past off exists), without waiting for the full buffer. It backs the
443443+// sequential Read path, whose callers (io.ReadAll, io.Copy) tolerate short
444444+// reads.
445445+func (e *packEntry) readSome(p []byte, off int64) (int, error) {
446446+ if off < 0 {
447447+ return 0, errNegativeOffset
448448+ }
449449+ if len(p) == 0 {
450450+ return 0, nil
451451+ }
452452+ e.mu.Lock()
453453+ for {
454454+ if e.err != nil {
455455+ err := e.err
456456+ e.mu.Unlock()
457457+ return 0, err
458458+ }
459459+ if e.size >= 0 && off >= e.size {
460460+ e.mu.Unlock()
461461+ return 0, io.EOF
462462+ }
463463+ if off < e.n {
464464+ if off >= e.winStart {
465465+ n := copy(p, e.win[off-e.winStart:])
466466+ e.mu.Unlock()
467467+ return n, nil
468468+ }
469469+ rfd := e.rfd
470470+ avail := e.n - off
471471+ e.mu.Unlock()
472472+ if int64(len(p)) > avail {
473473+ p = p[:avail]
474474+ }
475475+ return rfd.ReadAt(p, off)
476476+ }
477477+ if e.done { // off >= n and size unknown path: nothing more is coming
478478+ e.mu.Unlock()
479479+ return 0, io.EOF
480480+ }
481481+ e.cond.Wait()
482482+ }
483483+}
484484+485485+// sizeOrWait returns the total size, waiting for the download to finish if the
486486+// size was not advertised in the object header (Content-Length absent).
487487+func (e *packEntry) sizeOrWait() (int64, error) {
488488+ e.mu.Lock()
489489+ defer e.mu.Unlock()
490490+ for {
491491+ if e.size >= 0 {
492492+ return e.size, nil
493493+ }
494494+ if e.err != nil {
495495+ return 0, e.err
496496+ }
497497+ if e.done {
498498+ return e.n, nil
499499+ }
500500+ e.cond.Wait()
501501+ }
502502+}
503503+504504+// packCachedFile is a read-only billy.File backed by a (possibly still
505505+// downloading) pack cache entry. Each handle carries its own cursor; ReadAt and
506506+// the shared read fd are offset-explicit, so concurrent handles never interfere.
201507type packCachedFile struct {
202202- *os.File
203203- name string
508508+ e *packEntry
509509+ name string
510510+ pos int64
511511+ closed bool
204512}
205513206514func (f *packCachedFile) Name() string { return f.name }
207515516516+func (f *packCachedFile) Read(p []byte) (int, error) {
517517+ if f.closed {
518518+ return 0, os.ErrClosed
519519+ }
520520+ n, err := f.e.readSome(p, f.pos)
521521+ f.pos += int64(n)
522522+ return n, err
523523+}
524524+525525+func (f *packCachedFile) ReadAt(p []byte, off int64) (int, error) {
526526+ if f.closed {
527527+ return 0, os.ErrClosed
528528+ }
529529+ return f.e.readAt(p, off)
530530+}
531531+532532+func (f *packCachedFile) Seek(offset int64, whence int) (int64, error) {
533533+ if f.closed {
534534+ return 0, os.ErrClosed
535535+ }
536536+ switch whence {
537537+ case io.SeekStart:
538538+ f.pos = offset
539539+ case io.SeekCurrent:
540540+ f.pos += offset
541541+ case io.SeekEnd:
542542+ size, err := f.e.sizeOrWait()
543543+ if err != nil {
544544+ return 0, err
545545+ }
546546+ f.pos = size + offset
547547+ default:
548548+ return 0, fmt.Errorf("s3fs: invalid whence %d", whence)
549549+ }
550550+ return f.pos, nil
551551+}
552552+553553+func (f *packCachedFile) Stat() (fs.FileInfo, error) {
554554+ if f.closed {
555555+ return nil, os.ErrClosed
556556+ }
557557+ f.e.mu.Lock()
558558+ size := f.e.size
559559+ if size < 0 {
560560+ size = f.e.n
561561+ }
562562+ f.e.mu.Unlock()
563563+ return newFileInfo(f.name, size, time.Now()), nil
564564+}
565565+566566+func (f *packCachedFile) Close() error {
567567+ if f.closed {
568568+ return ErrFileClosed
569569+ }
570570+ f.closed = true
571571+ f.e.cache.release(f.e)
572572+ return nil
573573+}
574574+208575func (f *packCachedFile) Write(p []byte) (int, error) { return 0, ErrCantWriteToReadOnly }
209576func (f *packCachedFile) WriteAt(p []byte, off int64) (int, error) { return 0, ErrCantWriteToReadOnly }
210577func (f *packCachedFile) Truncate(size int64) error { return ErrTruncateNotSupported }
211578func (f *packCachedFile) Lock() error { return ErrLockNotSupported }
212579func (f *packCachedFile) Unlock() error { return ErrLockNotSupported }
580580+581581+// Compile-time assertion: the streaming handle satisfies billy.File.
582582+var _ billy.File = (*packCachedFile)(nil)
+404-5
internal/s3fs/packcache_test.go
···99 "os"
1010 "sync"
1111 "testing"
1212+ "time"
12131314 "github.com/aws/aws-sdk-go-v2/aws"
1415 "github.com/aws/aws-sdk-go-v2/service/s3"
1616+ "github.com/go-git/go-billy/v6"
1517)
16181719// packStub serves fixed object bytes and counts GetObject calls per key, so a
···4951 }, nil
5052}
51535454+// gateReader is an io.ReadCloser whose bytes are released to readers only when
5555+// the test calls release/releaseAll, so a test can drive the streaming pump byte
5656+// by byte and observe reads that race the download. Optionally it injects an
5757+// error once the reader reaches a given offset.
5858+type gateReader struct {
5959+ mu sync.Mutex
6060+ cond *sync.Cond
6161+ data []byte
6262+ released int
6363+ pos int
6464+ errAt int // -1 disables; otherwise return failErr once pos reaches it
6565+ failErr error
6666+ closed bool
6767+}
6868+6969+func newGateReader(data []byte) *gateReader {
7070+ g := &gateReader{data: data, errAt: -1}
7171+ g.cond = sync.NewCond(&g.mu)
7272+ return g
7373+}
7474+7575+func (g *gateReader) release(n int) {
7676+ g.mu.Lock()
7777+ g.released += n
7878+ if g.released > len(g.data) {
7979+ g.released = len(g.data)
8080+ }
8181+ g.cond.Broadcast()
8282+ g.mu.Unlock()
8383+}
8484+8585+func (g *gateReader) releaseAll() { g.release(len(g.data)) }
8686+8787+func (g *gateReader) Read(p []byte) (int, error) {
8888+ g.mu.Lock()
8989+ defer g.mu.Unlock()
9090+ for {
9191+ if g.errAt >= 0 && g.pos >= g.errAt {
9292+ return 0, g.failErr
9393+ }
9494+ if g.pos < g.released {
9595+ n := copy(p, g.data[g.pos:g.released])
9696+ g.pos += n
9797+ return n, nil
9898+ }
9999+ if g.pos >= len(g.data) {
100100+ return 0, io.EOF
101101+ }
102102+ if g.closed {
103103+ return 0, io.ErrClosedPipe
104104+ }
105105+ g.cond.Wait()
106106+ }
107107+}
108108+109109+func (g *gateReader) Close() error {
110110+ g.mu.Lock()
111111+ g.closed = true
112112+ g.cond.Broadcast()
113113+ g.mu.Unlock()
114114+ return nil
115115+}
116116+117117+// gateStub serves a gateReader body per key so streaming behaviour can be
118118+// driven deterministically.
119119+type gateStub struct {
120120+ s3Client
121121+ mu sync.Mutex
122122+ gates map[string]*gateReader
123123+ gets map[string]int
124124+}
125125+126126+func newGateStub() *gateStub {
127127+ return &gateStub{gates: map[string]*gateReader{}, gets: map[string]int{}}
128128+}
129129+130130+func (s *gateStub) put(key string, g *gateReader) {
131131+ s.mu.Lock()
132132+ s.gates[key] = g
133133+ s.mu.Unlock()
134134+}
135135+136136+func (s *gateStub) getCount(key string) int {
137137+ s.mu.Lock()
138138+ defer s.mu.Unlock()
139139+ return s.gets[key]
140140+}
141141+142142+func (s *gateStub) GetObject(_ context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
143143+ s.mu.Lock()
144144+ defer s.mu.Unlock()
145145+ key := aws.ToString(in.Key)
146146+ g, ok := s.gates[key]
147147+ if !ok {
148148+ return nil, notFound()
149149+ }
150150+ s.gets[key]++
151151+ return &s3.GetObjectOutput{
152152+ Body: g,
153153+ ContentLength: aws.Int64(int64(len(g.data))),
154154+ }, nil
155155+}
156156+52157// newTestPackCache builds a PackCache under t.TempDir with the given budget and
53158// registers Cleanup.
54159func newTestPackCache(t *testing.T, maxBytes int64) *PackCache {
···61166 return pc
62167}
631686464-func mustReadAll(t *testing.T, f *packCachedFile) []byte {
169169+func mustReadAll(t *testing.T, f billy.File) []byte {
65170 t.Helper()
66171 if _, err := f.Seek(0, io.SeekStart); err != nil {
67172 t.Fatalf("seek: %v", err)
···73178 return b
74179}
75180181181+// readAtChan runs f.ReadAt in a goroutine and returns a channel that yields the
182182+// result, so a test can assert whether a read is pending or has completed
183183+// without hanging the test on a buggy block.
184184+type readAtResult struct {
185185+ n int
186186+ err error
187187+ p []byte
188188+}
189189+190190+func readAtChan(f billy.File, length int, off int64) <-chan readAtResult {
191191+ ch := make(chan readAtResult, 1)
192192+ go func() {
193193+ p := make([]byte, length)
194194+ n, err := f.ReadAt(p, off)
195195+ ch <- readAtResult{n: n, err: err, p: p}
196196+ }()
197197+ return ch
198198+}
199199+76200func TestPackCacheReads(t *testing.T) {
77201 ctx := context.Background()
78202 const key = "repo.git/objects/pack/pack-abc.pack"
···82206 tests := []struct {
83207 name string
84208 // check exercises one read path and returns (got, wantSlice) to compare.
8585- check func(t *testing.T, f *packCachedFile) (got, exp []byte)
209209+ check func(t *testing.T, f billy.File) (got, exp []byte)
86210 }{
87211 {
88212 name: "sequential Read",
8989- check: func(t *testing.T, f *packCachedFile) ([]byte, []byte) {
213213+ check: func(t *testing.T, f billy.File) ([]byte, []byte) {
90214 return mustReadAll(t, f), want
91215 },
92216 },
93217 {
94218 name: "ReadAt mid-object",
9595- check: func(t *testing.T, f *packCachedFile) ([]byte, []byte) {
219219+ check: func(t *testing.T, f billy.File) ([]byte, []byte) {
96220 p := make([]byte, 100)
97221 if _, err := f.ReadAt(p, 8000); err != nil && err != io.EOF {
98222 t.Fatalf("ReadAt: %v", err)
···151275}
152276153277// TestPackCacheIndependentCursors verifies two open handles over the same cached
154154-// file have independent seek positions (each gets its own *os.File).
278278+// file have independent seek positions.
155279func TestPackCacheIndependentCursors(t *testing.T) {
156280 ctx := context.Background()
157281 const key = "r.git/objects/pack/pack-y.idx"
···237361 t.Fatal(err)
238362 }
239363 defer fa.Close()
364364+ // Touch A so its download completes and it is fully accounted in the budget
365365+ // before B arrives.
366366+ if got := mustReadAll(t, fa); !bytes.Equal(got, dataA) {
367367+ t.Fatalf("A bytes mismatch")
368368+ }
240369241370 // Open B: total (2000) exceeds 1500, so A is evicted from the cache.
242371 fb, err := pc.open(ctx, stub, "bucket", keyB, "b")
243372 if err != nil {
244373 t.Fatal(err)
374374+ }
375375+ if got := mustReadAll(t, fb); !bytes.Equal(got, dataB) {
376376+ t.Fatalf("B bytes mismatch")
245377 }
246378 fb.Close()
247379···255387 if err != nil {
256388 t.Fatal(err)
257389 }
390390+ if got := mustReadAll(t, fa2); !bytes.Equal(got, dataA) {
391391+ t.Fatalf("A re-download bytes mismatch")
392392+ }
258393 fa2.Close()
259394 if n := stub.getCount(keyA); n != 2 {
260395 t.Errorf("A GetObject count = %d, want 2 (re-downloaded after eviction)", n)
261396 }
262397}
263398399399+// TestPackCacheServeWhileDownloading confirms a read of an already-arrived
400400+// prefix returns without waiting for the rest of the object to download.
401401+func TestPackCacheServeWhileDownloading(t *testing.T) {
402402+ ctx := context.Background()
403403+ const key = "r.git/objects/pack/stream.pack"
404404+ data := bytes.Repeat([]byte("STREAMDATA"), 1000) // 10 KiB
405405+ g := newGateReader(data)
406406+ stub := newGateStub()
407407+ stub.put(key, g)
408408+ pc := newTestPackCache(t, 0)
409409+410410+ f, err := pc.open(ctx, stub, "bucket", key, "n")
411411+ if err != nil {
412412+ t.Fatalf("open: %v", err)
413413+ }
414414+ defer f.Close()
415415+416416+ // Release only the first 200 bytes; the rest of the object is still
417417+ // "downloading" (gated). A read of the prefix must succeed promptly.
418418+ g.release(200)
419419+ ch := readAtChan(f, 100, 0)
420420+ select {
421421+ case res := <-ch:
422422+ if res.err != nil {
423423+ t.Fatalf("ReadAt prefix: %v", res.err)
424424+ }
425425+ if !bytes.Equal(res.p, data[:100]) {
426426+ t.Fatalf("ReadAt prefix bytes mismatch")
427427+ }
428428+ case <-time.After(2 * time.Second):
429429+ t.Fatal("ReadAt of arrived prefix blocked waiting for full download")
430430+ }
431431+432432+ // Let the rest finish so Close/Cleanup don't race a stuck pump.
433433+ g.releaseAll()
434434+}
435435+436436+// TestPackCacheReadAheadBlocks confirms a read past the current watermark blocks
437437+// until those bytes arrive, then returns them.
438438+func TestPackCacheReadAheadBlocks(t *testing.T) {
439439+ ctx := context.Background()
440440+ const key = "r.git/objects/pack/ahead.pack"
441441+ data := bytes.Repeat([]byte("0123456789"), 1000) // 10 KiB
442442+ g := newGateReader(data)
443443+ stub := newGateStub()
444444+ stub.put(key, g)
445445+ pc := newTestPackCache(t, 0)
446446+447447+ f, err := pc.open(ctx, stub, "bucket", key, "n")
448448+ if err != nil {
449449+ t.Fatalf("open: %v", err)
450450+ }
451451+ defer f.Close()
452452+453453+ g.release(100)
454454+ // Read at offset 5000, far past the released watermark: must stay pending.
455455+ ch := readAtChan(f, 64, 5000)
456456+ select {
457457+ case res := <-ch:
458458+ t.Fatalf("ReadAt past watermark returned early: n=%d err=%v", res.n, res.err)
459459+ case <-time.After(200 * time.Millisecond):
460460+ // good, still blocked
461461+ }
462462+463463+ // Release enough to cover the requested range; the read now completes.
464464+ g.releaseAll()
465465+ select {
466466+ case res := <-ch:
467467+ if res.err != nil && res.err != io.EOF {
468468+ t.Fatalf("ReadAt after release: %v", res.err)
469469+ }
470470+ if !bytes.Equal(res.p, data[5000:5064]) {
471471+ t.Fatalf("ReadAt bytes mismatch")
472472+ }
473473+ case <-time.After(2 * time.Second):
474474+ t.Fatal("ReadAt stayed blocked after data arrived")
475475+ }
476476+}
477477+478478+// TestPackCacheBelowWindowReadsDisk confirms that once the RAM window has
479479+// scrolled past an offset, reads of that offset are served from disk correctly.
480480+func TestPackCacheBelowWindowReadsDisk(t *testing.T) {
481481+ ctx := context.Background()
482482+ const key = "r.git/objects/pack/big.pack"
483483+ // Larger than ringCap so the front of the object is dropped from RAM.
484484+ data := bytes.Repeat([]byte("Z"), ringCap+(1<<20))
485485+ for i := range data { // make bytes position-dependent to catch offset bugs
486486+ data[i] = byte(i % 251)
487487+ }
488488+ stub := newPackStub(map[string][]byte{key: data})
489489+ pc := newTestPackCache(t, 0)
490490+491491+ f, err := pc.open(ctx, stub, "bucket", key, "n")
492492+ if err != nil {
493493+ t.Fatalf("open: %v", err)
494494+ }
495495+ defer f.Close()
496496+497497+ // Force the whole download (synchronous body), so the window is fully
498498+ // trimmed and the entry is done; offset 0 must come from disk.
499499+ if got := mustReadAll(t, f); !bytes.Equal(got, data) {
500500+ t.Fatalf("full read mismatch")
501501+ }
502502+ p := make([]byte, 128)
503503+ if _, err := f.ReadAt(p, 0); err != nil && err != io.EOF {
504504+ t.Fatalf("ReadAt offset 0 from disk: %v", err)
505505+ }
506506+ if !bytes.Equal(p, data[:128]) {
507507+ t.Fatalf("disk-served bytes mismatch at offset 0")
508508+ }
509509+ // A mid-object offset below the (now empty) window, too.
510510+ off := int64(ringCap / 2)
511511+ if _, err := f.ReadAt(p, off); err != nil && err != io.EOF {
512512+ t.Fatalf("ReadAt mid offset from disk: %v", err)
513513+ }
514514+ if !bytes.Equal(p, data[off:off+128]) {
515515+ t.Fatalf("disk-served bytes mismatch at offset %d", off)
516516+ }
517517+}
518518+519519+// TestPackCacheStreamError confirms a mid-stream download error surfaces to
520520+// readers and the entry is dropped so a later open re-downloads.
521521+func TestPackCacheStreamError(t *testing.T) {
522522+ ctx := context.Background()
523523+ const key = "r.git/objects/pack/err.pack"
524524+ data := bytes.Repeat([]byte("E"), 4096)
525525+ g := newGateReader(data)
526526+ boom := errors.New("boom")
527527+ g.errAt = 1024
528528+ g.failErr = boom
529529+ stub := newGateStub()
530530+ stub.put(key, g)
531531+ pc := newTestPackCache(t, 0)
532532+533533+ f, err := pc.open(ctx, stub, "bucket", key, "n")
534534+ if err != nil {
535535+ t.Fatalf("open: %v", err)
536536+ }
537537+538538+ // Release past the error point; the pump reads 1024 bytes then errors.
539539+ g.release(4096)
540540+541541+ // A read past the failed watermark eventually returns the error.
542542+ deadline := time.Now().Add(2 * time.Second)
543543+ var readErr error
544544+ for time.Now().Before(deadline) {
545545+ p := make([]byte, 64)
546546+ _, readErr = f.ReadAt(p, 2048)
547547+ if readErr != nil {
548548+ break
549549+ }
550550+ time.Sleep(5 * time.Millisecond)
551551+ }
552552+ if !errors.Is(readErr, boom) {
553553+ t.Fatalf("read after stream error: err = %v, want %v", readErr, boom)
554554+ }
555555+ f.Close()
556556+557557+ // The failed entry was dropped: a later open re-fetches. Use a fresh,
558558+ // fully-releasable gate this time.
559559+ g2 := newGateReader(data)
560560+ g2.releaseAll()
561561+ stub.put(key, g2)
562562+ f2, err := pc.open(ctx, stub, "bucket", key, "n2")
563563+ if err != nil {
564564+ t.Fatalf("reopen after error: %v", err)
565565+ }
566566+ if got := mustReadAll(t, f2); !bytes.Equal(got, data) {
567567+ t.Fatalf("reopen bytes mismatch")
568568+ }
569569+ f2.Close()
570570+ if n := stub.getCount(key); n != 2 {
571571+ t.Errorf("GetObject count = %d, want 2 (re-fetched after stream error)", n)
572572+ }
573573+}
574574+575575+// TestPackCacheConcurrentStreamingReaders confirms many readers opened while a
576576+// pack is still downloading all observe the full, correct bytes, with a single
577577+// GetObject.
578578+func TestPackCacheConcurrentStreamingReaders(t *testing.T) {
579579+ ctx := context.Background()
580580+ const key = "r.git/objects/pack/concurrent.pack"
581581+ data := bytes.Repeat([]byte("CONCURRENT"), 2000) // 20 KiB
582582+ g := newGateReader(data)
583583+ stub := newGateStub()
584584+ stub.put(key, g)
585585+ pc := newTestPackCache(t, 0)
586586+587587+ const readers = 16
588588+ files := make([]billy.File, readers)
589589+ for i := range files {
590590+ f, err := pc.open(ctx, stub, "bucket", key, "n")
591591+ if err != nil {
592592+ t.Fatalf("open %d: %v", i, err)
593593+ }
594594+ files[i] = f
595595+ }
596596+597597+ // Drip the body out in chunks while readers race to read it.
598598+ go func() {
599599+ for released := 0; released < len(data); released += 1024 {
600600+ g.release(1024)
601601+ time.Sleep(time.Millisecond)
602602+ }
603603+ g.releaseAll()
604604+ }()
605605+606606+ var wg sync.WaitGroup
607607+ errs := make([]error, readers)
608608+ for i := range files {
609609+ wg.Add(1)
610610+ go func(i int) {
611611+ defer wg.Done()
612612+ got := make([]byte, len(data))
613613+ if _, err := io.ReadFull(io.NewSectionReader(asReaderAt(files[i]), 0, int64(len(data))), got); err != nil {
614614+ errs[i] = err
615615+ return
616616+ }
617617+ if !bytes.Equal(got, data) {
618618+ errs[i] = errors.New("bytes mismatch")
619619+ }
620620+ }(i)
621621+ }
622622+ wg.Wait()
623623+624624+ for i, err := range errs {
625625+ if err != nil {
626626+ t.Errorf("reader %d: %v", i, err)
627627+ }
628628+ files[i].Close()
629629+ }
630630+ if n := stub.getCount(key); n != 1 {
631631+ t.Errorf("GetObject count = %d, want 1", n)
632632+ }
633633+}
634634+635635+// TestPackCacheClosedDuringStreaming confirms a handle closed mid-download
636636+// returns os.ErrClosed and does not wedge the pump.
637637+func TestPackCacheClosedDuringStreaming(t *testing.T) {
638638+ ctx := context.Background()
639639+ const key = "r.git/objects/pack/closing.pack"
640640+ data := bytes.Repeat([]byte("X"), 8192)
641641+ g := newGateReader(data)
642642+ stub := newGateStub()
643643+ stub.put(key, g)
644644+ pc := newTestPackCache(t, 0)
645645+646646+ f, err := pc.open(ctx, stub, "bucket", key, "n")
647647+ if err != nil {
648648+ t.Fatalf("open: %v", err)
649649+ }
650650+ g.release(100)
651651+ if err := f.Close(); err != nil {
652652+ t.Fatalf("close: %v", err)
653653+ }
654654+ if _, err := f.ReadAt(make([]byte, 1), 0); !errors.Is(err, os.ErrClosed) {
655655+ t.Errorf("ReadAt after close: err = %v, want os.ErrClosed", err)
656656+ }
657657+ g.releaseAll()
658658+}
659659+264660func TestIsPackCacheable(t *testing.T) {
265661 tests := map[string]bool{
266662 "r.git/objects/pack/pack-1.pack": true,
···276672 }
277673 }
278674}
675675+676676+// asReaderAt adapts a billy.File to io.ReaderAt for use with io.SectionReader.
677677+func asReaderAt(f billy.File) io.ReaderAt { return f }