Conversation
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src/poetry/installation/wheel_installer.py" line_range="41" />
<code_context>
+ with _file_locks_lock:
+ lock = _file_locks.get(key)
+ if lock is None:
+ lock = _file_locks[key] = threading.Lock()
+
+ return lock
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Assigning `threading.Lock()` to the `WeakValueDictionary` raises `TypeError` because `_thread.lock` objects cannot be weak-referenced, so every wheel file installation fails when `_file_lock()` creates its first lock.
**Triggers:** When any file is installed in a process where that path does not already have a lock.
**Suggested fix:** Store the locks in a regular dictionary, or wrap each `threading.Lock` in a weak-referenceable holder object.
```suggestion
_file_locks: dict[str, threading.Lock] = {}
```
</issue_to_address>Sourcery assessment
Needs a human reviewer. 1 finding to address first, and if the per-path locking is wrong, parallel wheel installation could still leave a shared file with interleaved or otherwise corrupted contents. Reverting prevents future races, but an already corrupted file would need to be repaired by reinstalling or rerunning the installation.
Blocking findings: src/poetry/installation/wheel_installer.py:41
kokokoXUY
left a comment
There was a problem hiding this comment.
The Sourcery inline finding that storing threading.Lock() in WeakValueDictionary necessarily raises TypeError appears incorrect for supported CPython versions. I ran this minimal check on CPython 3.10.19, 3.11.14, 3.12.12, and 3.13.12 (Windows); every version printed True without an exception:
import threading, weakref
locks = weakref.WeakValueDictionary()
lock = threading.Lock()
locks["path"] = lock
print(locks["path"] is lock)In this patch, the returned lock is also held strongly by the with _file_lock(target_path_str): statement for the whole write, so a weak-value entry cannot disappear while that critical section uses it. This addresses the bot's specific blocking claim; it is not a claim that I have run the full Poetry suite or verified every concurrency case. I would not switch to a permanent dictionary solely to fix that reported TypeError.
Review and cross-version reproduction assisted by Codex.
|
How does this affect performance? (Fixing an edge case may not be worth it if installing will be much slower in general.) |
|
The only added per-file cost is one dict lookup and acquiring an uncontended lock around the write. Different paths never wait on each other; only threads writing the very same file are serialized. I measured it on Windows with Python 3.11:
The difference is within the run-to-run noise. |
|
@radoering @Secrus when you have a moment, could one of you take a look at this one? The checks are green on the current head. |
Several wheels may contain the same file, e.g. the __init__.py of a pkgutil-style namespace package. Since wheels are installed in parallel, two threads could write such a file at the same time, which could result in a file with interleaved contents, i.e. a corrupted installation. Therefore, write each file under a lock that is specific to its path.
555042f to
36762e9
Compare
Pull Request Check List
Resolves: #9158
Description
Poetry installs wheels in parallel (
installer.parallel = trueby default). When two wheels contain the same file, for example the__init__.pyof a pkgutil-style namespace package likedbt-coreand its adapters in #9158, two threads can write that file at the same time inWheelDestination.write_to_fs(). Both threads open the file with"wb", so their writes interleave and the installed file ends up corrupted.This change takes a lock for each target path while the file is written. Different files are still written in parallel. Only writes to the same file wait for each other, so the file ends up with the complete content of the wheel that was written last, the same as with a sequential installation.
The issue suggests writing to a temporary file and moving it into place with
os.replace(). I tried that first, but on Windows two concurrentos.replace()calls onto the same target fail withPermissionError: [WinError 5] Access is denied. A lock avoids that.Tests
The new test
test_parallel_installation_of_file_contained_in_several_wheelsinstalls two wheels that ship the same file from two threads. The first write pauses after a few bytes so the second installation can write the same file. Without the fix the test fails onmain, because the file contains both contents:With the fix it passes, and
pytest tests/installation, the fullpytestsuite,mypy, andruffpass on Ubuntu (3.10 and 3.12), macOS (3.12) and Windows (3.12).Without any mocking, a script that installs two wheels sharing a large
ns/__init__.pyin parallel 300 times gave these results:mainReproducer script