Skip to content

MultiPackIndex.find() hands out shared mutable state — concurrent lookups return wrong object offsets #291

Open

Description

@Jallibad

Version

7.7.1.202607240634-r

Operating System

MacOS, Linux/Unix

Bug description

Summary

When a repository has a multi-pack-index, MultiPackIndex.find() returns a reference to a
single PackOffset instance that it mutates in place. PackMidx.MidxPackIndex.findOffset()
reads that instance after the call returns, without copying. Two threads looking up different
objects therefore race: one can compute an offset belonging to the other's object.

Impact

ObjectDirectory is documented and widely treated as thread-safe, and is used concurrently by
multi-threaded consumers. core.multiPackIndex defaults to true in JGit, and
PackDirectory.scanPacksImpl() removes every midx-covered pack from the pack list and replaces
it with the PackMidx instance:

if (useMidx && theMidx != null) {
    // Replace the covered packs with the midx in the list
    ...
    list.add(theMidx);
}

Why this is a JGit issue and not a caller issue

The reuse contract on find() is addressed to its caller, which is JGit's own
PackMidx.MidxPackIndex.findOffset() in org.eclipse.jgit.internal.storage.file. Downstream
consumers never see MultiPackIndex; they use ObjectReader / TreeWalk.

The contract is also about lifetime, not concurrency — "callers must create a #copy() if they
want to keep a reference". findOffset() does not keep a reference; it reads the fields
immediately. That is correct single-threaded and still unsafe concurrently, so honouring the
documented contract is not sufficient to make this correct.

Meanwhile Repository documents the opposite guarantee for exactly this class:

// Repository.java
/**
 * The thread-safety of a Repository very much depends on the concrete implementation...
 * users of a Repository type must not assume the instance is thread-safe.
 * - FileRepository is thread-safe.
 * - DfsRepository thread-safety is determined by its subclass.
 */

and ObjectReader is documented as per-thread:

// ObjectReader.java
/** Reads an ObjectDatabase for a single thread. */

A consumer that shares one FileRepository across threads and takes a fresh ObjectReader per
operation is therefore using the documented API correctly. new TreeWalk(Repository) does exactly
that internally:

public TreeWalk(Repository repo) {
    this(repo, repo.newObjectReader(), true);
}

So the midx path introduces a non-thread-safe component beneath a type that promises
thread-safety, without changing that promise.

Root cause

MultiPackIndexV1 keeps one PackOffset and returns it from every call:

// MultiPackIndexV1.java
private final PackOffset result = new PackOffset();

@Override
public PackOffset find(AnyObjectId objectId) {
    int position = idx.findMultiPackIndexPosition(objectId);
    if (position == -1) {
        return null;
    }
    offsets.getObjectOffset(position, result);   // mutates the shared instance
    return result;                                // returns a reference to it
}

This is intentional and documented on the interface:

// MultiPackIndex.java
/**
 * The returned object can be reused by the implementations. Callers must
 * create a #copy() if they want to keep a reference.
 */
PackOffset find(AnyObjectId objectId);

The caller does not copy, and reads the fields after find() has returned:

// PackMidx.MidxPackIndex
@Override
public long findOffset(AnyObjectId objId) {
    MultiPackIndex.PackOffset packOffset = midx.find(objId);
    return offsetCalculator.encode(packOffset);   // reads getPackId() / getOffset()
}

Thread A's values can be overwritten by thread B between the write in find() and the reads in
encode(), yielding accSizes[B.packId] + B.offset for A's object id.

PackMidx.OffsetCalculator has the same pattern independently, on the read path:

private final MultiPackIndex.PackOffset mutablePo = new MultiPackIndex.PackOffset();

MultiPackIndex.PackOffset decode(long totalOffset) {
    ...
    return mutablePo.setValues(i, totalOffset - accSizes[i]);
}

decode() is consumed by load(), read(), mmap() and findObjectForOffset(), each of
which reads po.getPackId() / po.getOffset() after the call returns.

Actual behavior

Repository state: two packs with a multi-pack-index covering them.

git init repo && cd repo
git remote add origin <large repo>
git fetch --no-tags origin <some older ref>:refs/remotes/origin/main
git multi-pack-index write        # midx covers the big pack
git fetch --no-tags origin main   # a second, uncovered pack

Then look up known object ids concurrently through the midx-backed PackIndex, comparing
against single-threaded ground truth:

FileRepository repo = new FileRepository(gitDir);
ObjectDirectory od = (ObjectDirectory) repo.getObjectDatabase();
Pack packMidx = od.getPacks().stream()
    .filter(p -> p.getClass().getSimpleName().equals("PackMidx"))
    .findFirst().orElseThrow();
PackIndex idx = packMidx.getIndex();

// ground truth, single-threaded
long[] expected = new long[ids.size()];
for (int i = 0; i < ids.size(); i++) {
    expected[i] = idx.findOffset(ids.get(i));
}

// N threads, same PackIndex instance
for (int t = 0; t < threads; t++) {
    pool.submit(() -> {
        for (int n = 0; n < iterations; n++) {
            int i = n % ids.size();
            long got = idx.findOffset(ids.get(i));
            if (got != expected[i]) {
                wrong.incrementAndGet();   // records id, expected, got
            }
        }
    });
}

Results

512 object ids, 300,000 lookups per thread:

threads   lookups     wrong offsets
1         200,000     0
2         599,976     24
4       1,199,276     724
16      4,796,592     3,408

The returned values are not random corruption — they are other objects' valid offsets:

id 0715b34840cc20d1f6e82c395446fc5d536529aa  expected 52624527  got 52668839

where 52668839 is the correct offset of a different object looked up concurrently.

Setting core.multiPackIndex=false removes PackMidx from the pack list and the anomalies
disappear (4,800,000 concurrent lookups, zero wrong).

Note: the race is hard to observe through the full ObjectReader.open() path — 1.28M concurrent
reads produced no failures, because object loading dominates the few-instruction window. Testing
findOffset directly is what makes it reproducible.

Expected behavior

Concurrent lookups on a thread-safe ObjectDatabase return correct offsets, or the midx path is
documented as requiring external synchronisation.

Relevant log output

org.eclipse.jgit.errors.MissingObjectException: Missing tree <sha>
    at org.eclipse.jgit.internal.storage.file.WindowCursor.open(WindowCursor.java:154)
    at org.eclipse.jgit.treewalk.CanonicalTreeParser.reset(CanonicalTreeParser.java:191)
    at org.eclipse.jgit.treewalk.TreeWalk.enterSubtree(TreeWalk.java:1371)
    at org.eclipse.jgit.treewalk.TreeWalk.next(TreeWalk.java:925)

Other information

This has bitten us using Spotless with a Git ratchet. Occasionally CI flakes with a MissingObjectException. We're setting core.multiPackIndex to false as a workaround.

Activity

  1. Digital-shaman87 commented on Oct 7, 2026

    @Digital-shaman87

    Independent confirmation, with a minimal reproduction, a note on how widely this now triggers, and a request to prioritise it.

    Still reproducible on the newest release (7.8.0)

    Repository: an ordinary ~56 MB repo (4 packs, ~48k objects) whose objects/pack/multi-pack-index was written by stock Git. Same object store copied for every run; 30 rounds × 32 threads, each thread opening the same 1,600 existing object ids:

    JGit core.multiPackIndex Result
    7.7.0 true (default) 17–22 spurious MissingObjectException: Missing unknown <id>
    7.7.1 true 27
    7.8.0 true 15 / 27 / 22 (three runs)
    7.8.0 false 0
    7.7.0 false 0

    Every reported id exists (git cat-file -t succeeds). The shared PackOffset in MultiPackIndexV1.find() is still present in 7.8.0 and on master.

    // args: <gitDir> <file with one object id per line> <rounds> <threads>
    import org.eclipse.jgit.lib.*;
    import org.eclipse.jgit.storage.file.*;
    import java.io.File; import java.nio.file.*; import java.util.*;
    import java.util.concurrent.*; import java.util.concurrent.atomic.*;
    public class Stress {
      public static void main(String[] a) throws Exception {
        List<String> ids = Files.readAllLines(Paths.get(a[1]));
        int rounds = Integer.parseInt(a[2]), threads = Integer.parseInt(a[3]);
        AtomicInteger fails = new AtomicInteger();
        for (int r = 0; r < rounds; r++) {
          new WindowCacheConfig().install(); // cold start each round
          try (Repository repo = new FileRepositoryBuilder().setGitDir(new File(a[0])).setMustExist(true).build()) {
            ExecutorService ex = Executors.newFixedThreadPool(threads); CountDownLatch go = new CountDownLatch(1);
            List<Future<?>> fs = new ArrayList<>();
            for (int t = 0; t < threads; t++) { final int off = t; fs.add(ex.submit(() -> { go.await();
              try (ObjectReader rd = repo.newObjectReader()) {
                for (int i = 0; i < ids.size(); i++) {
                  try { rd.open(ObjectId.fromString(ids.get((i + off * 97) % ids.size()))).getSize(); }
                  catch (Exception e) { fails.incrementAndGet(); } } }
              return null; })); }
            go.countDown(); for (Future<?> f : fs) f.get(); ex.shutdown();
          }
        }
        System.out.println((fails.get() == 0 ? "GREEN" : "RED") + " fails=" + fails.get());
      }
    }

    Why this is now urgent: stock Git creates the midx automatically

    With Git 2.55 (Git for Windows 2.55.0.windows.5 here, default configuration, no maintenance.*/repack.* settings), ordinary commits run git maintenance run --auto, whose default geometric strategy repacks with a multi-pack-index once about 100 loose objects accumulate (maintenance.geometric-repack.auto, default 100). In our case the midx appeared during a normal working session. So users do not opt in to a midx; they get one, and from then on every multithreaded JGit consumer of that repository can fail at random.

    Concrete downstream impact: SonarQube's SCM blame (multithreaded JGit blame in the scanner engine) fails the whole analysis non-deterministically with MissingObjectException ("Error during SonarScanner Engine execution"). The scanner engines we checked all bundle an affected JGit: sonar-scanner-engine 13.7.0 (SonarQube Community Build 26.9) → JGit 7.7.0; 13.12.0 → 7.7.1; 13.15.0 → 7.8.0. A newer SonarQube does not help until JGit is fixed.

    Workaround for anyone hitting this: git config core.multiPackIndex false in the repository that owns the objects (for a clone using objects/info/alternates, the alternate's repository config is the one JGit reads); JGit then ignores the existing midx.

    Suggested fix

    Do not share the result between calls: have MultiPackIndexV1.find() return a new PackOffset per call (it is a small value object), or have PackMidx.MidxPackIndex.findOffset() copy the fields under the reader's own instance. Either removes the shared mutable state; the stress test above makes a regression test (it fails within seconds on a repo with a midx).

    Given that stock Git now writes a midx by default and that the failure is silent data "loss" (existing objects reported missing), could this be prioritised for the next 7.x service release? Happy to run a candidate build against the reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions