···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.
···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.
···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.
···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)