Improve thread safety across modules#1902
Conversation
F-01: close(Throwable) now sets initialized=false before nulling socket/input/output fields, so ensureInitialized() will properly reinitialize on the next send() attempt after a close. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-02: Declare socket, output, input, and receiver fields as volatile so that writes in close(Throwable) are visible to concurrent readers in send() and receive() without requiring synchronization on this. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-03: Replace plain HashMap with ConcurrentHashMap for the clients map so that close() and expirationCheck() can safely access it without synchronization, preventing ConcurrentModificationException and data races. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-04: When client.lock() throws TimeoutException, send an unlock request to clean up the server-side context created by newContext(). Previously the context would leak in IpcServer.contexts indefinitely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d AuthCache F-06: Replace BasicAuthCache (backed by plain HashMap) with a ConcurrentAuthCache backed by ConcurrentHashMap, preventing data corruption when concurrent requests call authCache.put() during preemptive auth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-07: When executor.submit() throws RejectedExecutionException, fall back to running the task on the caller thread. This prevents RunnableErrorForwarder.await() from hanging forever when the counter was incremented by wrap() but the task never runs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-08: Replace get-then-put pattern with cache.computeIfAbsent() for all four intern pools, preventing concurrent DataPool constructors from creating and using detached pool instances. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-09: Use map.compute() instead of separate get/put to make the interning operation atomic, preventing duplicate objects from defeating heap deduplication during concurrent descriptor resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-10: Synchronize addException() and addCycle() in Results to prevent data races when called concurrently from BfDependencyCollector's parallelStream during version range resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-11: Move delegate.getCredentials() inside the synchronized(factories) block to prevent concurrent read/write on the underlying HashMap-backed BasicCredentialsProvider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-12: After transfer completes, check if the transporter was closed during execution. If so, disconnect and release the wagon instead of returning it to the queue where it would never be cleaned up. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-13: Replace plain ArrayList with CopyOnWriteArrayList to prevent ConcurrentModificationException when add() is called concurrently with getProxy() iteration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-14: Replace plain ArrayList with CopyOnWriteArrayList to prevent ConcurrentModificationException when add() is called concurrently with getMirror()/findMirror() iteration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-15: Replace plain HashMap with ConcurrentHashMap to prevent data corruption when add() and getAuthentication() are called from different threads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-16: Replace plain HashMap with ConcurrentHashMap to ensure visibility of types populated on the main thread to worker threads querying via the session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-17: Replace plain HashMap with ConcurrentHashMap to prevent corruption when concurrent parallel stream workers query the artifact type registry simultaneously. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-18: Clamp Thread.sleep() argument to minimum 1ms to prevent IllegalArgumentException when left goes negative while clients are still connected. Previously this silently killed the expiration thread, causing the server to never shut down. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-19: Remove Lock entries from the locks ConcurrentHashMap when holders and waiters are both empty after unlock, preventing unbounded accumulation over the server's lifetime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-20: Collect futures to complete during the synchronized block but complete them after releasing the Lock monitor, preventing I/O operations (socket writes in thenRun callbacks) from being serialized under the lock. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-21: Re-check shutdown flag inside the locks.compute() lambda to prevent creating locks on a backend that was shut down between the initial check and the actual lock creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-22: Check the return value of trySetPermits(). If it returns false (e.g., a crashed process left the semaphore in a depleted state), log a warning instead of silently proceeding with a potentially broken semaphore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-24: Use a single computeIfAbsent() call instead of separate get() then computeIfAbsent(), and derive hit/miss statistics from whether the mapping function was invoked. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-25: Make errorPath volatile to ensure cross-thread visibility when set from parallel stream workers and read from the main thread. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-26: Make the priority field volatile to ensure cross-thread visibility between setPriority() and getPriority() calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-27: Make contentLength and resumeOffset volatile to ensure visibility when written on worker threads and read by monitoring or logging threads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-29: Make cached hashCode fields volatile in ExclusionDependencySelector, AndDependencySelector, AndDependencyTraverser, and ChainedVersionFilter to ensure visibility across threads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-30: Make the repository field volatile to ensure cross-thread visibility between construction/lazy update and concurrent readers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-31: Return false immediately after unlockAll() on lock failure instead of breaking out of the loop and calling unlockAll() again on the already-empty deque. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nstead of re-queuing
F-33: Wrap task.newInputStream() in try-with-resources so the stream is properly closed after Files.copy(), preventing resource leaks on both normal and error paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
F-35: Replace plain ArrayList with CopyOnWriteArrayList for signatureTempFiles as a defensive measure against potential concurrent access between generate() and close(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remaining Findings Analysis (F-05, F-23, F-28, F-34)The 4 findings not addressed in this PR were analyzed in detail against the actual source code:
Full analysis: see |
…le overloads Pooled.submit(Callable) and Limited.submit(Callable) did not catch RejectedExecutionException, unlike their Runnable counterparts. On rejection, Pooled would let the REE propagate with a never-completed future, and Limited would additionally leak a semaphore permit. Apply the same try-catch-fallback pattern: on rejection, run the callable on the caller thread and complete the future inline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The unlock() path had a race: after checking l.isEmpty(), another thread could computeIfAbsent and re-use the same Lock object, then the remove(l.key, l) would remove a Lock that now has holders. Replace the two-step isEmpty() check + remove() with a single locks.compute() call that atomically checks emptiness and removes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Collections.synchronizedMap does not make computeIfAbsent atomic; the default Map.computeIfAbsent implementation performs a non-atomic get-then-put sequence, risking concurrent structural modification of the underlying WeakHashMap. Wrap the computeIfAbsent call in synchronized(versionCache) to use the same monitor that synchronizedMap uses internally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Collections.synchronizedMap does not make compute() atomic; the default Map.compute implementation performs a non-atomic read-modify sequence, risking concurrent structural modification of the underlying WeakHashMap. Wrap the compute() call in synchronized(map) to use the same monitor that synchronizedMap uses internally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The write paths addException() and addCycle() are synchronized but getResult() and getErrorPath() were not, so readers could observe stale data without a happens-before edge from the writer. Make both getter methods synchronized to establish proper visibility guarantees for cross-thread reads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tifacts The signatureTempFiles field was changed to CopyOnWriteArrayList for thread safety, but the artifacts field (also mutated via addAll in generate()) was left as a plain ArrayList. Change artifacts to CopyOnWriteArrayList for consistency, since both fields are mutated and iterated in the same methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR targets a broad set of concurrency and thread-safety fixes across Maven Resolver modules, primarily by improving cross-thread visibility, replacing non-thread-safe collections, and hardening resource lifecycle handling under concurrent use.
Changes:
- Replaced several shared mutable collections with concurrent alternatives (e.g.,
ConcurrentHashMap,CopyOnWriteArrayList) and tightened atomic cache initialization patterns. - Improved IPC named-lock server/client behavior (lock cleanup, lifecycle reset, avoiding completing futures under monitors).
- Hardened transport/executor behavior under shutdown/rejection scenarios and improved resource handling (e.g., ensuring streams are closed).
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java | Makes version cache hit/miss accounting atomic with cache population. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/repository/DefaultProxySelector.java | Uses a thread-safe list for proxy definitions. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/repository/DefaultMirrorSelector.java | Uses a thread-safe list for mirror definitions. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/repository/DefaultAuthenticationSelector.java | Uses a concurrent map for repository authentication lookup. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/repository/ChainedWorkspaceReader.java | Adds volatile for cross-thread visibility of repository reference. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ChainedVersionFilter.java | Makes lazy hashCode cache safely publishable across threads. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/traverser/AndDependencyTraverser.java | Makes lazy hashCode cache safely publishable across threads. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/selector/ExclusionDependencySelector.java | Makes lazy hashCode cache safely publishable across threads. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/selector/AndDependencySelector.java | Makes lazy hashCode cache safely publishable across threads. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/concurrency/SmartExecutor.java | Adds rejection handling to avoid hangs when submission is refused. |
| maven-resolver-util/src/main/java/org/eclipse/aether/util/artifact/SimpleArtifactTypeRegistry.java | Uses a concurrent map for artifact type registry storage. |
| maven-resolver-transport-wagon/src/main/java/org/eclipse/aether/transport/wagon/WagonTransporter.java | Improves wagon lifecycle handling on reconnect failures / transporter close. |
| maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporter.java | Ensures InputStream is closed when uploading from an input stream. |
| maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/DeferredCredentialsProvider.java | Extends synchronization to cover delegate read for credential lookup. |
| maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java | Introduces a concurrent AuthCache implementation and uses it for session caching. |
| maven-resolver-named-locks/src/main/java/org/eclipse/aether/named/support/NamedLockFactorySupport.java | Rejects lock creation after shutdown during map compute to prevent races. |
| maven-resolver-named-locks/src/main/java/org/eclipse/aether/named/support/CompositeNamedLock.java | Removes redundant unlock path on partial acquisition failures. |
| maven-resolver-named-locks-redisson/src/main/java/org/eclipse/aether/named/redisson/RedissonSemaphoreNamedLockFactory.java | Logs a warning when trySetPermits() fails. |
| maven-resolver-named-locks-ipc/src/main/java/org/eclipse/aether/named/ipc/IpcServer.java | Uses concurrent client map; avoids completing futures under lock; cleans up empty locks; guards sleep duration. |
| maven-resolver-named-locks-ipc/src/main/java/org/eclipse/aether/named/ipc/IpcNamedLock.java | Unlocks context on lock timeout to prevent server-side context leaks. |
| maven-resolver-named-locks-ipc/src/main/java/org/eclipse/aether/named/ipc/IpcClient.java | Improves cross-thread visibility and resets initialization on close for reconnect. |
| maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/DependencyCollectorDelegate.java | Synchronizes Results mutation/access for parallel collection safety. |
| maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/DataPool.java | Removes get-then-put races via computeIfAbsent; makes weak interning atomic. |
| maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/CachingArtifactTypeRegistry.java | Switches internal cache to ConcurrentHashMap and refines cache get flow. |
| maven-resolver-generator-gnupg/src/main/java/org/eclipse/aether/generator/gnupg/GnupgSignatureArtifactGenerator.java | Uses copy-on-write lists for thread-safe artifact/temp-file tracking. |
| maven-resolver-connector-basic/src/main/java/org/eclipse/aether/connector/basic/BasicRepositoryConnectorFactory.java | Makes priority safely publishable across threads via volatile. |
| maven-resolver-api/src/main/java/org/eclipse/aether/transfer/TransferResource.java | Adds volatile to mutable transfer state for cross-thread visibility. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (RejectedExecutionException e) { | ||
| runnable.run(); | ||
| } |
There was a problem hiding this comment.
Fixed. The fallback runnable.run() is now wrapped in a try-catch(RuntimeException | Error) that swallows the exception. This preserves the async submit() semantics where exceptions are captured by the Future. Callers like RunnableErrorForwarder already record the error before re-throwing, so it will still be reported via await().
| } catch (RejectedExecutionException e) { | ||
| try { | ||
| runnable.run(); | ||
| } finally { | ||
| semaphore.release(); |
There was a problem hiding this comment.
Fixed. Same approach as the Pooled case above — the fallback runnable.run() in Limited.submit(Runnable) now catches and swallows RuntimeException | Error to match async submission semantics. The semaphore is still properly released in the finally block.
| public ArtifactType get(String typeId) { | ||
| ArtifactType type = types.get(typeId); | ||
|
|
||
| if (type == null) { | ||
| type = delegate.get(typeId); | ||
| if (type != null) { | ||
| return type; | ||
| } |
There was a problem hiding this comment.
Fixed. Replaced the get-then-delegate-then-put sequence with types.computeIfAbsent(typeId, delegate::get) for atomic cache population. This avoids redundant delegate.get() calls under contention.
| this.artifacts = new CopyOnWriteArrayList<>(artifacts); | ||
| this.signableArtifactPredicate = signableArtifactPredicate; | ||
| this.secretKey = secretKey; | ||
| this.privateKey = privateKey; | ||
| this.hashSubPackets = hashSubPackets; | ||
| this.keyInfo = keyInfo; | ||
| this.signatureTempFiles = new ArrayList<>(); | ||
| this.signatureTempFiles = new CopyOnWriteArrayList<>(); | ||
| logger.debug("Created generator using key {}", keyInfo); |
There was a problem hiding this comment.
Fixed. Replaced CopyOnWriteArrayList with plain ArrayList for both artifacts and signatureTempFiles, and made generate() and close() synchronized. This avoids the array-copy overhead on each write while still providing thread safety and happens-before guarantees between the two methods.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| private final RequestTrace trace; | ||
|
|
||
| private long contentLength = -1L; | ||
| private volatile long contentLength = -1L; |
| private final Map<String, ProvidedChecksumsSource> providedChecksumsSources; | ||
|
|
||
| private float priority; | ||
| private volatile float priority; |
|
|
||
| @Override | ||
| public Collection<? extends Artifact> generate(Collection<? extends Artifact> generatedArtifacts) { | ||
| public synchronized Collection<? extends Artifact> generate(Collection<? extends Artifact> generatedArtifacts) { |
| return pooled; | ||
| } | ||
| Object[] result = new Object[1]; | ||
| synchronized (map) { |
| final int maxCycles; | ||
|
|
||
| String errorPath; | ||
| volatile String errorPath; |
| private final Set<? extends DependencySelector> selectors; | ||
|
|
||
| private int hashCode; | ||
| private volatile int hashCode; |
There was a problem hiding this comment.
The hashCode() is not concurrency safe here, so I'd make this final instead and modify hashCode() to just return the final int hashCode instead.
| private final Exclusion[] exclusions; | ||
|
|
||
| private int hashCode; | ||
| private volatile int hashCode; |
There was a problem hiding this comment.
The hashCode() is not concurrency safe here, so I'd make this final instead and modify hashCode() to just return the final int hashCode instead.
| private final Set<? extends DependencyTraverser> traversers; | ||
|
|
||
| private int hashCode; | ||
| private volatile int hashCode; |
There was a problem hiding this comment.
The hashCode() is not concurrency safe here, so I'd make this final instead and modify hashCode() to just return the final int hashCode instead.
| private final VersionFilter[] filters; | ||
|
|
||
| private int hashCode; | ||
| private volatile int hashCode; |
There was a problem hiding this comment.
The hashCode() is not concurrency safe here, so I'd make this final instead and modify hashCode() to just return the final int hashCode instead.
| private final List<WorkspaceReader> readers = new ArrayList<>(); | ||
|
|
||
| private WorkspaceRepository repository; | ||
| private volatile WorkspaceRepository repository; |
|
Overall looks good, but there are IMHO some unnecessary usage of |
Thread Safety Improvements
This PR addresses 31 thread safety issues identified through a systematic audit of the codebase. Each fix is a separate commit for easy review.
HIGH severity fixes (6)
initializedflag inclose()to allow proper reconnectionsocket/output/inputvolatile for cross-thread visibilityConcurrentHashMapinstead of plainHashMapConcurrentHashMap-backedAuthCacheto prevent corruption with preemptive authawait()hanging foreverMEDIUM severity fixes (15)
computeIfAbsentinstead of get-then-put racecompute()for atomic check-and-updateparallelStreamsafetyCopyOnWriteArrayListCopyOnWriteArrayListConcurrentHashMapConcurrentHashMapConcurrentHashMap.computeIfAbsent()Thread.sleep()argumentLOW severity fixes (10)
unlockAll()CopyOnWriteArrayListNot addressed (4)