Summary
BaseProvider.resolver_cache in resolver.py is a class-level dict with no thread safety. When build_parallel() launches multiple threads via ThreadPoolExecutor, concurrent access to the cache can silently corrupt dependency resolution results.
Problem
resolver_cache (line 425) is a class-level dict with no locking.
_get_cached_candidates() (lines 555-565) returns a direct reference to the internal cached list — its docstring even states "The caller can mutate the list in place to update the cache."
_find_cached_candidates() (line 582) mutates the cached list in-place with cached_candidates[:] = candidates.
build_parallel() in commands/build.py (line 645) uses ThreadPoolExecutor to run builds concurrently.
When multiple threads resolve the same identifier simultaneously, they all see an empty cache, all call find_candidates(), and all write back via in-place slice assignment — silently clobbering each other's results. This can produce wrong dependency resolution without any error or warning.
The codebase already has threading_utils.with_thread_lock() which could be applied here.
How to reproduce
Run a parallel build where multiple packages share a common dependency. Under thread contention, find_candidates() will be called multiple times for the same identifier instead of once, and the cached candidate list may contain results from a different thread's resolution.
Testing suggestions
- Defensive copy: Call
_get_cached_candidates(), mutate the returned list, call again — assert the mutation didn't leak back into the cache.
- Thread safety: Stub
find_candidates() with a brief sleep, launch 4 threads synchronized via a Barrier, assert find_candidates() was called exactly once. Without locking, all 4 threads bypass the cache.
Summary
BaseProvider.resolver_cacheinresolver.pyis a class-level dict with no thread safety. Whenbuild_parallel()launches multiple threads viaThreadPoolExecutor, concurrent access to the cache can silently corrupt dependency resolution results.Problem
resolver_cache(line 425) is a class-leveldictwith no locking._get_cached_candidates()(lines 555-565) returns a direct reference to the internal cached list — its docstring even states "The caller can mutate the list in place to update the cache."_find_cached_candidates()(line 582) mutates the cached list in-place withcached_candidates[:] = candidates.build_parallel()incommands/build.py(line 645) usesThreadPoolExecutorto run builds concurrently.When multiple threads resolve the same identifier simultaneously, they all see an empty cache, all call
find_candidates(), and all write back via in-place slice assignment — silently clobbering each other's results. This can produce wrong dependency resolution without any error or warning.The codebase already has
threading_utils.with_thread_lock()which could be applied here.How to reproduce
Run a parallel build where multiple packages share a common dependency. Under thread contention,
find_candidates()will be called multiple times for the same identifier instead of once, and the cached candidate list may contain results from a different thread's resolution.Testing suggestions
_get_cached_candidates(), mutate the returned list, call again — assert the mutation didn't leak back into the cache.find_candidates()with a brief sleep, launch 4 threads synchronized via aBarrier, assertfind_candidates()was called exactly once. Without locking, all 4 threads bypass the cache.