Skip to content

fix(gitengine): bound REF_DELTA recursion and harden object-store lifetime - #21

Merged
hammadmajid merged 1 commit into
mainfrom
fix/12-object-store
Sep 5, 2026
Merged

hammadmajid merged 1 commit into
mainfrom
fix/12-object-store

Conversation

@hammadmajid

@hammadmajid hammadmajid commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Description

grg reads arbitrary .git directories, and a packfile containing an OBJ_REF_DELTA cycle killed the process outright. Four smaller object-store defects found by the same audit ride along, because they live in the same files.

Closes #12

Type of Change

  • Bug fix (non-breaking change fixing an issue)
  • New feature (non-breaking change adding functionality)
  • Performance improvement
  • Refactoring or code cleanup
  • Documentation update
  • CI/CD or build workflow change

Key Changes

1. CRITICAL — OBJ_REF_DELTA cycle caused fatal error: stack overflow.
maxDeltaDepth = 50 only counted intra-pack OFS_DELTA hops. Every REF_DELTA hop re-entered through ObjectReader.ReadObjectPackReader.ReadObjectreadObjectAt(offset, **0**), resetting the counter, so an A→B→A cycle recursed until the stack died. This was a fatal error, not a panic: unrecoverable, skipping defer reader.Close() and the whole exitCodeForError contract, triggered from a pipeline worker goroutine.

Added an unexported deltaBaseResolver interface with readObjectDepth(oid, depth). PackReader.ReadObject delegates to readObjectDepth(oid, 0); the REF_DELTA hop passes depth+1 through both the resolver and the self-lookup branch. RepositoryReader implements it and forwards. The exported ObjectReader surface is unchanged, and the resolver field is typed as the unexported interface so depth can never be silently dropped again.

2. Close() closed packfiles under in-flight readers. The snapshot-then-unlock in ReadObject correctly avoided the recursive-RLock deadlock through the resolver back-edge, but it made mu a field lock, not a lifetime lock — so Close could close an *os.File a reader still held. ErrFileClosed was then swallowed into ErrObjectNotFound: a silent wrong answer during shutdown, invisible to -race because it is a lifetime bug, not a data race. Now refcounted with inflight sync.WaitGroup + closed atomic.Bool; Close marks closed, unlocks, waits, and closes files outside the lock. New exported ErrReaderClosed. readObjectDepth also stops collapsing real source errors into ErrObjectNotFound.

3. SetResolver deleted. An exported unsynchronized write to a field read by every worker, on a type whose doc claimed thread-safety — safe only by accident of call-site placement. NewRepositoryReader now assigns the field before the reader is published; it is immutable afterwards.

4. ApplyDeltaWithBuffer takes *[]byte. Taking the slice by value meant a >64 KiB target reallocated and the caller recycled the original small array, defeating the pool on exactly the objects it was added for.

5. Failed pooled-zlib Reset no longer retries on a mutated stream. Reset eats the RFC-1950 header and installs a read-ahead bufio.Reader before it can fail, so the old zlib.NewReader(f) fallback parsed from a garbage offset. Shared newPooledZlibReader now returns the error directly on the pooled path.

Verification & Testing

  • Ran go test -v -count=1 ./...
  • Ran go test -race -shuffle=on -count=1 ./...
  • Ran go vet ./...
  • Added or updated unit/integration tests
  • Tested manually against sample Git repository histories
go build ./...                                             clean
go vet ./internal/gitengine/ ./test/benchmark/             clean
go test -race -shuffle=on -count=1 ./internal/gitengine/   ok 1.2s

Reproduction, before the fixdebug.SetMaxStack(16<<20) to fail fast:

runtime: goroutine stack exceeds 16777216-byte limit
fatal error: stack overflow

gitengine.(*PackReader).readObjectAt(0x2408..., 0x32, 0x0)   pack.go:278
gitengine.(*PackReader).ReadObject(...)                       pack.go:120
gitengine.(*RepositoryReader).ReadObject(...)                 object.go:138
gitengine.(*PackReader).readObjectAt(0x2408..., 0xc,  0x0)   pack.go:278

The depth argument is 0x0 in every frame.

New tests. TestRefDeltaCycle_ReturnsCorruptObject builds an A→B→A cycle and drives it through NewRepositoryReader (production resolver wiring), asserting the error wraps ErrCorruptObject and is not ErrObjectNotFound. TestPackReaderRefDelta adds the missing positive coverage for valid REF_DELTA on both the resolver and nil-resolver paths — there was none before. TestConcurrency_RepositoryReader_Deadlock previously used a zero-value reader with no files, so it exercised nothing; it now races 20 readers × 100 reads against 5 concurrent Close calls on a real packfile-backed reader. An instrumented probe over 50 iterations recorded ok=1854 closed=98146 notfound=0 other=0, confirming both asserted outcomes occur and neither forbidden outcome does. TestPerformance_ApplyDeltaWithBuffer_GrowsCallerBuffer fails on the old by-value signature.

buildTestPackAndIdx gained a base-SHA field so the suite can build REF_DELTA packs at all.

Note

Bottom of a 7-PR stack. Nothing below it.

Checklist

  • gofmt clean
  • Every new test mutation-checked: reverting the fix makes it fail
  • This layer builds and passes the full race suite on its own, not just at the top of the stack

…etime

A packfile containing an OBJ_REF_DELTA cycle crashed the process with
`fatal error: stack overflow`: every REF_DELTA hop re-entered the pack through
ObjectReader.ReadObject, which restarted readObjectAt at depth 0, so the
maxDeltaDepth ceiling only ever counted intra-pack OFS_DELTA hops. grg reads
arbitrary .git directories, so this was attacker-reachable, and a fatal error
cannot be recovered by the pipeline worker that hits it.

PackReader and RepositoryReader now share an unexported depth-carrying entry
point, readObjectDepth, reached through the unexported deltaBaseResolver
interface. The delta depth survives the resolver hop, so maxDeltaDepth bounds
mixed OFS/REF chains and a cycle terminates as ErrCorruptObject. The exported
ObjectReader interface is unchanged.

Also in the object store:

- RepositoryReader.Close no longer closes packfiles under in-flight readers.
  Reads register on a WaitGroup under RLock and bail on a closed atomic.Bool;
  Close flips the flag, drops the lock, drains the readers, and only then
  closes the files, outside the lock. A shutdown race now reports the new
  ErrReaderClosed instead of being silently misreported as ErrObjectNotFound,
  and a source that claims an object but fails to produce it propagates its
  real error rather than being downgraded to "not found".
- SetResolver is gone: an exported unsynchronized mutator on a type documented
  thread-safe, whose field is read by every pipeline worker. The resolver is
  now immutable after construction, assigned by NewRepositoryReader before the
  reader is published.
- ApplyDeltaWithBuffer takes *[]byte, so a target that outgrows the pooled
  64 KiB buffer writes the grown array back and is actually recycled instead
  of allocating twice on exactly the objects the pool exists for.
- A failed pooled-zlib Reset no longer falls back to zlib.NewReader. Reset has
  already consumed the RFC-1950 header and installed a bufio read-ahead, so
  the fallback parsed from a mutated offset and reported a misleading error;
  the corrupt-object error is returned directly. Both call sites now share
  newPooledZlibReader, which reaches zlib.NewReader only on an empty pool.
- Documented the real concurrency contract on RepositoryReader, LooseReader
  and PackReader.

Tests: buildTestPackAndIdx grows a baseSHA field so it can emit REF_DELTA
objects, which previously had no coverage at all. refdelta_cycle_test.go
promotes the parked reproduction to a regression test asserting
ErrCorruptObject, and TestPackReaderRefDelta covers both the resolver and
no-resolver paths. TestConcurrency_RepositoryReader_Deadlock raced a
zero-value struct and therefore exercised nothing; it now races 20 readers
against 5 concurrent Close calls on a real packfile-backed reader and asserts
every read either returns the payload or fails with ErrReaderClosed, never
ErrObjectNotFound. Removing the drain from Close makes it fail with "file
already closed", so the refcount is load-bearing.

Two call sites outside the object store move to the new pointer signature,
one line each: internal/gitengine/delta_test.go and
test/benchmark/search_bench_test.go.

Closes #12
@hammadmajid
hammadmajid marked this pull request as ready for review September 5, 2026 14:19
@hammadmajid
hammadmajid merged commit 22a2c9f into main Sep 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gitengine: REF_DELTA cycle crashes the process; object-store lifetime and pool defects

1 participant