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.
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 ajava.nio.file.AccessDeniedExceptionthrown out of
LooseObjects#tryMove.There is a TOCTOU window between the
dst.exists()check at the top ofLooseObjects#insertand theFiles.move(..., ATOMIC_MOVE)performed bytryMove:On POSIX this race is harmless:
rename(2)replaces the destination regardless of the destination'smode bits. On Windows it is not:
ATOMIC_MOVEmaps toMoveFileEx(..., MOVEFILE_REPLACE_EXISTING),which fails with
ERROR_ACCESS_DENIEDwhen the destination carries the read-only attribute -- and JGititself sets that attribute, one line below the move, in
tryMove.The failure is spurious. The object that the winning thread placed at
dstis 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_LOOSErather thanFAILURE. I verified this: after a failure, thedestination 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 frommultiple threads" -- fixed the first race in this very code path: concurrent creation of the
fan-out
objects/??directory. It was fixed in5dcc8693
("Fix concurrent creation of fan-out object directories") with the rationale:
The identical rationale applies one step later: all we require is that the object does indeed exist.
The
Files.movestep was never covered by that fix.JGit already has the Windows-aware rename helper --
tryMovejust does not use itFileUtils#renameimplements precisely the workaround needed here: a retry loop gated onFS.DETECTED.retryFailedLockFileCommit()(whichFS_Win32returnstruefor), plus adelete-then-move fallback. It even carries the comment
// On *nix there is no try, you do or do not.LooseObjects#tryMove, however, callsFiles.movedirectly and therefore gets none of it.Steps to reproduce
Compile/run with
org.eclipse.jgit,JavaEWAHandslf4j-apion the classpath(add
slf4j-simpleto also see theAccessDeniedExceptionthatLooseObjectslogs):Results
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(theLOG.errorininsert):What the caller actually sees:
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 - 1threads -- and writes a very large number ofidentical 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:
--nthreadsThe whole conversion aborts, because
ObjectWritingExceptionpropagates 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 anIOExceptionfromtryMoveasFAILURE, re-checkwhether the destination now exists -- if it does, another thread won the race and the correct result is
EXISTS_LOOSE, not a failure:Both
catch (IOException)blocks ininsertneed this. Alternatively, routetryMovethroughFileUtils#rename, which already carries the Windows retry/fallback logic.Environment
Only Windows was available for testing; POSIX platforms are expected to be unaffected, for the reason
given above.