Skip to content

Commit a844708

Browse files
committed
Harden pickle temp-file cleanup and lock fallback
1 parent 1aef855 commit a844708

2 files changed

Lines changed: 148 additions & 14 deletions

File tree

src/cachier/cores/pickle.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,18 @@ def cache_fpath(self) -> str:
107107

108108
@property
109109
def _shared_lock_fpath(self) -> str:
110-
lock_dir = os.path.join(tempfile.gettempdir(), "cachier-locks")
111-
os.makedirs(lock_dir, exist_ok=True)
112110
cache_hash = hashlib.sha256(self.cache_fpath.encode("utf-8")).hexdigest()
113-
return os.path.join(lock_dir, f"{cache_hash}{self._SHARED_LOCK_SUFFIX}")
111+
candidate_dirs = (
112+
os.path.join(tempfile.gettempdir(), "cachier-locks"),
113+
os.path.join(os.path.dirname(self.cache_fpath), ".cachier-locks"),
114+
)
115+
for lock_dir in candidate_dirs:
116+
try:
117+
os.makedirs(lock_dir, exist_ok=True)
118+
return os.path.join(lock_dir, f"{cache_hash}{self._SHARED_LOCK_SUFFIX}")
119+
except OSError:
120+
continue
121+
return os.path.join(os.path.dirname(self.cache_fpath), f".{cache_hash}{self._SHARED_LOCK_SUFFIX}")
114122

115123
@staticmethod
116124
def _convert_legacy_cache_entry(
@@ -203,20 +211,22 @@ def _save_cache(
203211
pickle.dump(cache, cast(IO[bytes], cache_file), protocol=4)
204212
else:
205213
with portalocker.Lock(self._shared_lock_fpath, mode="a+b"):
206-
with tempfile.NamedTemporaryFile(
207-
mode="wb",
208-
dir=parent_dir,
209-
delete=False,
210-
) as temp_file:
211-
temp_path = temp_file.name
212-
pickle.dump(cache, cast(IO[bytes], temp_file), protocol=4)
213-
temp_file.flush()
214-
os.fsync(temp_file.fileno())
214+
temp_path = ""
215215
try:
216+
with tempfile.NamedTemporaryFile(
217+
mode="wb",
218+
dir=parent_dir,
219+
delete=False,
220+
) as temp_file:
221+
temp_path = temp_file.name
222+
pickle.dump(cache, cast(IO[bytes], temp_file), protocol=4)
223+
temp_file.flush()
224+
os.fsync(temp_file.fileno())
216225
os.replace(temp_path, fpath)
217226
finally:
218-
with suppress(FileNotFoundError):
219-
os.remove(temp_path)
227+
if temp_path:
228+
with suppress(FileNotFoundError):
229+
os.remove(temp_path)
220230
# the same as check for separate_file, but changed for typing
221231
if isinstance(cache, dict):
222232
self._cache_dict = cache

tests/pickle_tests/test_pickle_core.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,130 @@ def mock_func():
709709
core._save_cache({"key": "value"}, separate_file_key="test_key")
710710

711711

712+
@pytest.mark.pickle
713+
def test_save_cache_removes_temp_file_when_fsync_fails(tmp_path):
714+
"""Test _save_cache removes the temp file when fsync fails."""
715+
core = _PickleCore(
716+
hash_func=None,
717+
cache_dir=tmp_path,
718+
pickle_reload=False,
719+
wait_for_calc_timeout=10,
720+
separate_files=False,
721+
)
722+
723+
def mock_func():
724+
pass
725+
726+
core.set_func(mock_func)
727+
728+
with patch("cachier.cores.pickle.os.fsync", side_effect=OSError("fsync failed")), pytest.raises(
729+
OSError, match="fsync failed"
730+
):
731+
core._save_cache(
732+
{"key": CacheEntry(value="value", time=datetime.now(), stale=False, _processing=False)}
733+
)
734+
735+
assert list(tmp_path.iterdir()) == []
736+
737+
738+
@pytest.mark.pickle
739+
def test_save_cache_propagates_tempfile_creation_failure_without_cleanup_error(tmp_path):
740+
"""Test _save_cache handles temp-file creation failures before temp_path exists."""
741+
core = _PickleCore(
742+
hash_func=None,
743+
cache_dir=tmp_path,
744+
pickle_reload=False,
745+
wait_for_calc_timeout=10,
746+
separate_files=False,
747+
)
748+
749+
def mock_func():
750+
pass
751+
752+
core.set_func(mock_func)
753+
754+
with patch("cachier.cores.pickle.tempfile.NamedTemporaryFile", side_effect=OSError("tempfile failed")), patch(
755+
"cachier.cores.pickle.os.replace"
756+
) as mock_replace, patch("cachier.cores.pickle.os.remove") as mock_remove, pytest.raises(
757+
OSError, match="tempfile failed"
758+
):
759+
core._save_cache(
760+
{"key": CacheEntry(value="value", time=datetime.now(), stale=False, _processing=False)}
761+
)
762+
763+
mock_replace.assert_not_called()
764+
mock_remove.assert_not_called()
765+
assert list(tmp_path.iterdir()) == []
766+
767+
768+
@pytest.mark.pickle
769+
def test_shared_lock_fpath_falls_back_to_cache_dir_when_temp_dir_unwritable(tmp_path):
770+
"""Test _shared_lock_fpath falls back when the system temp dir is not writable."""
771+
core = _PickleCore(
772+
hash_func=None,
773+
cache_dir=tmp_path,
774+
pickle_reload=False,
775+
wait_for_calc_timeout=10,
776+
separate_files=False,
777+
)
778+
779+
def mock_func():
780+
pass
781+
782+
core.set_func(mock_func)
783+
784+
temp_lock_dir = os.path.join("/non-writable-temp", "cachier-locks")
785+
fallback_lock_dir = os.path.join(core.cache_dir, ".cachier-locks")
786+
787+
def mock_makedirs(path, exist_ok=False):
788+
if path in (core.cache_dir, fallback_lock_dir):
789+
return None
790+
if path == temp_lock_dir:
791+
raise PermissionError("temp dir not writable")
792+
raise AssertionError(f"Unexpected os.makedirs path: {path}")
793+
794+
with patch("cachier.cores.pickle.tempfile.gettempdir", return_value="/non-writable-temp"), patch(
795+
"cachier.cores.pickle.os.makedirs", side_effect=mock_makedirs
796+
):
797+
assert core._shared_lock_fpath == os.path.join(
798+
fallback_lock_dir,
799+
f"{hashlib.sha256(core.cache_fpath.encode('utf-8')).hexdigest()}{core._SHARED_LOCK_SUFFIX}",
800+
)
801+
802+
803+
@pytest.mark.pickle
804+
def test_shared_lock_fpath_uses_cache_dir_file_when_lock_dirs_unwritable(tmp_path):
805+
"""Test _shared_lock_fpath falls back to a lockfile in the cache dir."""
806+
core = _PickleCore(
807+
hash_func=None,
808+
cache_dir=tmp_path,
809+
pickle_reload=False,
810+
wait_for_calc_timeout=10,
811+
separate_files=False,
812+
)
813+
814+
def mock_func():
815+
pass
816+
817+
core.set_func(mock_func)
818+
819+
temp_lock_dir = os.path.join("/non-writable-temp", "cachier-locks")
820+
fallback_lock_dir = os.path.join(core.cache_dir, ".cachier-locks")
821+
cache_hash = hashlib.sha256(core.cache_fpath.encode("utf-8")).hexdigest()
822+
823+
def mock_makedirs(path, exist_ok=False):
824+
if path == core.cache_dir:
825+
return None
826+
if path in (temp_lock_dir, fallback_lock_dir):
827+
raise PermissionError("lock dir not writable")
828+
raise AssertionError(f"Unexpected os.makedirs path: {path}")
829+
830+
with patch("cachier.cores.pickle.tempfile.gettempdir", return_value="/non-writable-temp"), patch(
831+
"cachier.cores.pickle.os.makedirs", side_effect=mock_makedirs
832+
):
833+
assert core._shared_lock_fpath == os.path.join(core.cache_dir, f".{cache_hash}{core._SHARED_LOCK_SUFFIX}")
834+
835+
712836
@pytest.mark.pickle
713837
def test_set_entry_should_not_store(tmp_path):
714838
"""Test set_entry when value should not be stored."""

0 commit comments

Comments
 (0)