cacheprovider: write files atomically

Make `Cache.set()` safe to use together with xdist.
This commit is contained in:
Aleksandr Mezin 2024-06-02 18:39:22 +03:00
parent 98021838fd
commit 2989caa17c
3 changed files with 151 additions and 8 deletions

View File

@ -0,0 +1,3 @@
Fixed file write in `Cache.set()` to be atomic.
Non-atomic write could cause issues when `Cache.set()` called from different pytest-xdist workers for the same key. Now it should be safe to use in combination with pytest-xdist.

View File

@ -8,6 +8,7 @@ import json
import os
from pathlib import Path
import tempfile
import time
from typing import Dict
from typing import final
from typing import Generator
@ -75,6 +76,10 @@ class Cache:
self._cachedir = cachedir
self._config = config
# Note: there's no way to get the current umask atomically, eek.
self._umask = os.umask(0o022)
os.umask(self._umask)
@classmethod
def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache":
"""Create the Cache instance for a Config.
@ -124,6 +129,44 @@ class Cache:
stacklevel=3,
)
def _write_atomic(self, path: Path, content: str) -> None:
tmpfile = tempfile.NamedTemporaryFile(
delete=False, dir=self._cachedir, mode="w", encoding="UTF-8"
)
with tmpfile:
tmpfile.write(content)
# Reset permissions to the default, see #12308.
os.chmod(tmpfile.name, 0o666 - self._umask)
# On Windows, replace() might fail with ERROR_ACCESS_DENIED (5) if
# the target file is open by another process.
# Retry with exponential backoff in this case.
retry_delay = 1 / (2**10)
deadline = time.perf_counter() + 5
while True:
try:
os.replace(tmpfile.name, path)
# Note: trying to remove tmpfile.name after successful replace()
# can cause a race condition, so it can't be done in 'finally:'
return
except OSError as ex:
if getattr(ex, "winerror", None) != 5 or time.perf_counter() > deadline:
os.remove(tmpfile.name)
raise
except BaseException:
os.remove(tmpfile.name)
raise
time.sleep(max(0, min(retry_delay, deadline - time.perf_counter())))
if retry_delay < 0.25:
retry_delay *= 2
def _mkdir(self, path: Path) -> None:
self._ensure_cache_dir_and_supporting_files()
path.mkdir(exist_ok=True, parents=True)
@ -192,15 +235,12 @@ class Cache:
return
data = json.dumps(value, ensure_ascii=False, indent=2)
try:
f = path.open("w", encoding="UTF-8")
self._write_atomic(path, data)
except OSError as exc:
self.warn(
f"cache could not write path {path}: {exc}",
_ispytest=True,
)
else:
with f:
f.write(data)
def _ensure_cache_dir_and_supporting_files(self) -> None:
"""Create the cache dir and its supporting files."""
@ -215,10 +255,7 @@ class Cache:
path = Path(newpath)
# Reset permissions to the default, see #12308.
# Note: there's no way to get the current umask atomically, eek.
umask = os.umask(0o022)
os.umask(umask)
path.chmod(0o777 - umask)
path.chmod(0o777 - self._umask)
with open(path.joinpath("README.md"), "xt", encoding="UTF-8") as f:
f.write(README_CONTENT)

View File

@ -3,11 +3,14 @@ from enum import Enum
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Any
from typing import Generator
from typing import List
from typing import Sequence
from typing import Tuple
from typing import Union
from _pytest.compat import assert_never
from _pytest.config import ExitCode
@ -1360,3 +1363,103 @@ def test_cachedir_tag(pytester: Pytester) -> None:
def test_clioption_with_cacheshow_and_help(pytester: Pytester) -> None:
result = pytester.runpytest("--cache-show", "--help")
assert result.ret == 0
def test_key_permissions(pytester: Pytester) -> None:
ini_path = pytester.makeini("[pytest]")
config = pytester.parseconfigure()
assert config.cache is not None
config.cache.set("test/key", "value")
key_path = config.cache._getvaluepath("test/key")
assert key_path.stat().st_mode == ini_path.stat().st_mode
def test_write_fail_permissions(pytester: Pytester) -> None:
pytester.makeini("[pytest]")
config = pytester.parseconfigure()
assert config.cache is not None
config.cache.set("test/key", "initial")
key_path = config.cache._getvaluepath("test/key")
old_key_file_mode = key_path.stat().st_mode
old_key_dir_mode = key_path.parent.stat().st_mode
try:
key_path.chmod(0)
key_path.parent.chmod(0)
with pytest.warns(pytest.PytestCacheWarning) as warns:
config.cache.set("test/key", "value")
assert len(warns) == 1
assert str(warns[0].message).startswith("cache could not write path")
finally:
key_path.parent.chmod(old_key_dir_mode)
key_path.chmod(old_key_file_mode)
def test_concurrent_write(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
pytester.makeini("[pytest]")
config = pytester.parseconfigure()
assert config.cache is not None
config.cache.set("test/key", "initial")
key_path = config.cache._getvaluepath("test/key")
code_file = pytester.makepyfile(
f"""
import sys
f = open({str(key_path)!r}, mode="rb")
print("opened", flush=True)
line = sys.stdin.readline()
if line != "close\\n":
raise ValueError(f"Expected 'close', got {{line!r}}")
f.close()
print("done", flush=True)
"""
)
old_replace = os.replace
replace_attempts = 0
def mock_replace(
src: "Union[str, os.PathLike[str]]",
dst: "Union[str, os.PathLike[str]]",
) -> None:
try:
old_replace(src, dst)
finally:
if dst == key_path:
nonlocal replace_attempts
replace_attempts += 1
if replace_attempts == 1:
print("close", file=child.stdin, flush=True)
child = pytester.popen(
[sys.executable, code_file],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=sys.stderr,
encoding="utf-8",
)
with child:
assert child.stdout.readline() == "opened\n"
monkeypatch.setattr("os.replace", mock_replace)
config.cache.set("test/key", "value")
assert child.stdout.read() == "done\n"
assert child.returncode == 0
assert config.cache.get("test/key", None) == "value"
if sys.platform.startswith("win"):
assert replace_attempts > 1
else:
assert replace_attempts == 1