diff --git a/src/poetry/utils/helpers.py b/src/poetry/utils/helpers.py index 4f7cf114e55..c7faf72212b 100644 --- a/src/poetry/utils/helpers.py +++ b/src/poetry/utils/helpers.py @@ -3,6 +3,7 @@ import shutil import stat import tempfile +import time from collections.abc import Mapping from contextlib import contextmanager @@ -22,7 +23,6 @@ from poetry.config.config import Config - _canonicalize_regex = re.compile("[-_]+") @@ -45,7 +45,7 @@ def temporary_directory(*args: Any, **kwargs: Any) -> Iterator[str]: yield name - shutil.rmtree(name, onerror=_del_ro) + robust_rmtree(name, onerror=_del_ro) def get_cert(config: "Config", repository_name: str) -> Optional[Path]: @@ -72,11 +72,34 @@ def _on_rm_error(func: Callable, path: str, exc_info: Exception) -> None: func(path) +def robust_rmtree(path: str, onerror: Callable = None, max_timeout: float = 1) -> None: + """ + Robustly tries to delete paths. + Retries several times if an OSError occurs. + If the final attempt fails, the Exception is propagated + to the caller. + """ + timeout = 0.001 + while timeout < max_timeout: + try: + shutil.rmtree(path) + return # Only hits this on success + except OSError: + # Increase the timeout and try again + time.sleep(timeout) + timeout *= 2 + + # Final attempt, pass any Exceptions up to caller. + shutil.rmtree(path, onerror=onerror) + + def safe_rmtree(path: str) -> None: if Path(path).is_symlink(): return os.unlink(str(path)) - shutil.rmtree(path, onerror=_on_rm_error) + shutil.rmtree( + path, onerror=_on_rm_error + ) # maybe we could call robust_rmtree here just in case ? def merge_dicts(d1: Dict, d2: Dict) -> None: diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index a4781062ca7..d8573cdd622 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,3 +1,5 @@ +import tempfile + from pathlib import Path from typing import TYPE_CHECKING @@ -8,12 +10,38 @@ from poetry.utils.helpers import canonicalize_name from poetry.utils.helpers import get_cert from poetry.utils.helpers import get_client_cert +from src.poetry.utils.helpers import robust_rmtree if TYPE_CHECKING: from tests.conftest import Config +def test_robust_rmtree(mocker): + mocked_rmtree = mocker.patch("shutil.rmtree") + + # this should work after an initial exception + name = tempfile.mkdtemp() + mocked_rmtree.side_effect = [ + OSError( + "Couldn't delete file yet, waiting for references to clear", "mocked path" + ), + None, + ] + robust_rmtree(name) + + # this should give up after retrying multiple times + name = tempfile.mkdtemp() + mocked_rmtree.side_effect = OSError( + "Couldn't delete file yet, this error won't go away after first attempt" + ) + with pytest.raises(OSError): + robust_rmtree(name, max_timeout=0.04) + + # clear the side effect (breaks the tear-down otherwise) + mocked_rmtree.side_effect = None + + def test_parse_requires(): requires = """\ jsonschema>=2.6.0.0,<3.0.0.0