Skip to content

LooseObjects#tryMove fails with AccessDeniedException on Windows when several threads insert the same object #288

Description

@YoshikiHigo

LooseObjects#tryMove fails with AccessDeniedException on Windows when several threads insert the same object

Summary

On Windows, inserting the same loose object concurrently from several threads fails with
ObjectWritingException: Unable to create new object, caused by a java.nio.file.AccessDeniedException
thrown out of LooseObjects#tryMove.

There is a TOCTOU window between the dst.exists() check at the top of LooseObjects#insert and the
Files.move(..., ATOMIC_MOVE) performed by tryMove:

Thread A                                     Thread B
-----------------------------------------    -----------------------------------------
dst.exists() -> false                        dst.exists() -> false
Files.move(tmpA, dst, ATOMIC_MOVE) -> ok
dst.setReadOnly()
                                             Files.move(tmpB, dst, ATOMIC_MOVE)
                                               -> dst now exists AND is read-only
                                               -> AccessDeniedException

On POSIX this race is harmless: rename(2) replaces the destination regardless of the destination's
mode bits. On Windows it is not: ATOMIC_MOVE maps to MoveFileEx(..., MOVEFILE_REPLACE_EXISTING),
which fails with ERROR_ACCESS_DENIED when the destination carries the read-only attribute -- and JGit
itself sets that attribute, one line below the move, in tryMove.

The failure is spurious. The object that the winning thread placed at dst is complete and correct
(it is necessarily byte-identical, since the object id is a hash of the content), so the losing thread
should have observed EXISTS_LOOSE rather than FAILURE. I verified this: after a failure, the
destination file is a valid, readable blob.

This is the sibling of bug 397217

Bug 397217 -- "race condition affecting
org.eclipse.jgit.storage.file.ObjectDirectoryInserter.insert(...) when called concurrently from
multiple threads"
-- fixed the first race in this very code path: concurrent creation of the
fan-out objects/?? directory. It was fixed in
5dcc8693
("Fix concurrent creation of fan-out object directories") with the rationale:

All we require is that the directory does indeed exist, so not being able to create it is not
actually a fatal problem.

The identical rationale applies one step later: all we require is that the object does indeed exist.
The Files.move step was never covered by that fix.

JGit already has the Windows-aware rename helper -- tryMove just does not use it

FileUtils#rename implements precisely the workaround needed here: a retry loop gated on
FS.DETECTED.retryFailedLockFileCommit() (which FS_Win32 returns true for), plus a
delete-then-move fallback. It even carries the comment // On *nix there is no try, you do or do not.

LooseObjects#tryMove, however, calls Files.move directly and therefore gets none of it.

Steps to reproduce

Compile/run with org.eclipse.jgit, JavaEWAH and slf4j-api on the classpath
(add slf4j-simple to also see the AccessDeniedException that LooseObjects logs):

java -cp "<jgit.jar>;<JavaEWAH.jar>;<slf4j-api.jar>" JGitLooseObjectRace.java <threads> <rounds>
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.Repository;

/**
 * Several threads insert the *same* blob into the same repository at the same
 * time. On Windows this fails with ObjectWritingException, caused by
 * AccessDeniedException raised by LooseObjects#tryMove.
 */
public class JGitLooseObjectRace {
  public static void main(String[] args) throws Exception {
    int threads = args.length > 0 ? Integer.parseInt(args[0]) : 8;
    int rounds = args.length > 1 ? Integer.parseInt(args[1]) : 200;

    Path gitDir = Files.createTempDirectory("jgit-race").resolve("repo.git");
    Repository repo = new FileRepositoryBuilder().setGitDir(gitDir.toFile()).build();
    repo.create(true);

    ExecutorService pool = Executors.newFixedThreadPool(threads);
    CyclicBarrier barrier = new CyclicBarrier(threads);
    int failures = 0;
    String first = null;

    for (int r = 0; r < rounds; r++) {
      final byte[] content = ("identical blob content, round " + r + "\n").getBytes("UTF-8");
      List<Future<String>> futures = new ArrayList<>();
      for (int t = 0; t < threads; t++) {
        futures.add(pool.submit(() -> {
          try {
            barrier.await();
          } catch (Exception ignored) {
            // barrier only maximises the collision window
          }
          try (ObjectInserter ins = repo.newObjectInserter()) {
            ins.insert(Constants.OBJ_BLOB, content);
            ins.flush();
            return null;
          } catch (Throwable e) {
            return stack(e);
          }
        }));
      }
      for (Future<String> f : futures) {
        String err = f.get();
        if (err != null) {
          failures++;
          if (first == null) first = err;
        }
      }
    }
    pool.shutdown();
    repo.close();

    if (first != null) {
      System.out.println("---- first failure ----");
      System.out.println(first);
    }
    System.out.printf("jgit=%s threads=%d rounds=%d -> failed inserts: %d%n",
        Repository.class.getPackage().getImplementationVersion(),
        threads, rounds, failures);
  }

  private static String stack(Throwable e) {
    java.io.StringWriter w = new java.io.StringWriter();
    e.printStackTrace(new java.io.PrintWriter(w));
    return w.toString();
  }
}

Results

JGit threads rounds inserts attempted inserts failed
7.7.1.202607240634-r 1 200 200 0
7.7.1.202607240634-r 2 200 400 88
7.7.1.202607240634-r 8 200 1600 1035
7.6.0.202603022253-r 8 200 1600 1013

Single-threaded is always clean; anything from two threads up fails. This is not a recent regression --
7.6.0 behaves the same.

Stack traces

Root cause, as logged by LooseObjects (the LOG.error in insert):

java.nio.file.AccessDeniedException: C:\...\repo.git\objects\noz18053320105354648219.tmp -> C:\...\repo.git\objects\97\d5dbe2d2d2ccd2b1579fc894158ba36b58eeb5
	at java.base/sun.nio.fs.WindowsException.translateToIOException(WindowsException.java:89)
	at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:103)
	at java.base/sun.nio.fs.WindowsFileCopy.move(WindowsFileCopy.java:310)
	at java.base/sun.nio.fs.WindowsFileSystemProvider.move(WindowsFileSystemProvider.java:287)
	at java.base/java.nio.file.Files.move(Files.java:1319)
	at org.eclipse.jgit.internal.storage.file.LooseObjects.tryMove(LooseObjects.java:342)
	at org.eclipse.jgit.internal.storage.file.LooseObjects.insert(LooseObjects.java:328)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectory.insertUnpackedObject(ObjectDirectory.java:591)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insertOneObject(ObjectDirectoryInserter.java:130)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insert(ObjectDirectoryInserter.java:86)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insert(ObjectDirectoryInserter.java:55)
	at org.eclipse.jgit.lib.ObjectInserter.insert(ObjectInserter.java:337)

What the caller actually sees:

org.eclipse.jgit.errors.ObjectWritingException: Unable to create new object: C:\...\repo.git\objects\97\d5dbe2d2d2ccd2b1579fc894158ba36b58eeb5
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insertOneObject(ObjectDirectoryInserter.java:142)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insert(ObjectDirectoryInserter.java:86)
	at org.eclipse.jgit.internal.storage.file.ObjectDirectoryInserter.insert(ObjectDirectoryInserter.java:55)
	at org.eclipse.jgit.lib.ObjectInserter.insert(ObjectInserter.java:337)

Real-world impact

Found while running FinerGit, a tool that rewrites a Git
repository into a finer-grained one (built on git-stein). It
rewrites commits in parallel -- by default with #CPU - 1 threads -- and writes a very large number of
identical blobs, because a method that does not change across commits produces the same blob every
time. Collisions are therefore constant rather than exotic.

Converting a 440-commit repository on Windows:

--nthreads runs succeeded aborted
1 3 3 0
2 3 0 3
4 3 0 3
8 3 0 3
19 3 0 3

The whole conversion aborts, because ObjectWritingException propagates out of the worker threads.
This is the same class of workload as The BFG Repo Cleaner, which is what motivated bug 397217.

Suggested fix

In LooseObjects#insert, before treating an IOException from tryMove as FAILURE, re-check
whether the destination now exists -- if it does, another thread won the race and the correct result is
EXISTS_LOOSE, not a failure:

} catch (IOException e) {
    if (dst.isFile()) {
        // Another thread inserted the very same object concurrently. On
        // Windows the ATOMIC_MOVE onto the (now read-only) destination fails
        // with AccessDeniedException; this is not a real error.
        FileUtils.delete(tmp, FileUtils.RETRY | FileUtils.SKIP_MISSING);
        unpackedObjectCache().add(id);
        return InsertLooseObjectResult.EXISTS_LOOSE;
    }
    LOG.error(e.getMessage(), e);
    FileUtils.delete(tmp, FileUtils.RETRY | FileUtils.SKIP_MISSING);
    return InsertLooseObjectResult.FAILURE;
}

Both catch (IOException) blocks in insert need this. Alternatively, route tryMove through
FileUtils#rename, which already carries the Windows retry/fallback logic.

Environment

  • JGit 7.7.1.202607240634-r (also reproduced on 7.6.0.202603022253-r)
  • Windows 11 Pro 10.0.26200, NTFS, local fixed disk
  • Oracle JDK 25.0.2 (build 25.0.2+10-LTS-69, HotSpot 64-Bit Server VM)
  • 20 logical CPUs

Only Windows was available for testing; POSIX platforms are expected to be unaffected, for the reason
given above.

Activity

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