-
Notifications
You must be signed in to change notification settings - Fork 209
fix: atomic writes for cache and config files #1243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| ### Fixed | ||
|
|
||
| - Cache and config writes (`ai_discovery.json`, `update_check.yaml`, | ||
| `auth_config.yaml`, the secret-scan cache, the docker-image ID cache and the | ||
| plugin manifest) now use an atomic write-then-rename pattern with a | ||
| per-process temp file. Previously these were truncate-in-place writes | ||
| (or used a fixed `.tmp` filename), which would corrupt cache contents when | ||
| several ggshield processes ran concurrently — including the `secret scan | ||
| ai-hook` codepath invoked from AI-agent hooks. A torn write on a cache | ||
| file another process has memory-mapped surfaces as `SIGBUS` and crashes | ||
| the reader, which is what triggered this fix. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import os | ||
| import time | ||
| from enum import Enum, auto | ||
| from pathlib import Path, PurePath, PurePosixPath | ||
| from typing import List, Pattern, Set, Tuple, Union | ||
|
|
@@ -145,3 +147,98 @@ def url_for_path(path: PurePath) -> str: | |
| else: | ||
| # This happens for Windows paths: `path_str` is something like "c:/foo/bar" | ||
| return f"file:///{path_str}" | ||
|
|
||
|
|
||
| def _open_new_sibling(path: Path, mode: int) -> Tuple[int, Path]: | ||
| """Create a unique sibling of *path* with ``O_CREAT | O_EXCL`` and return | ||
| its open fd and path. Used as the staging file for atomic writes — it is | ||
| the new version of *path*, not a tempfile.""" | ||
| while True: | ||
| suffix = os.urandom(8).hex() | ||
| new_path = path.with_name(f".{path.name}.{suffix}.new") | ||
| try: | ||
| fd = os.open( | ||
| new_path, | ||
| os.O_WRONLY | os.O_CREAT | os.O_EXCL, | ||
| mode, | ||
| ) | ||
| return fd, new_path | ||
| except FileExistsError: | ||
| # Astronomically unlikely with 64 bits of entropy; retry rather | ||
| # than swallow. | ||
| continue | ||
|
|
||
|
|
||
| def _replace_with_retry(src: Path, dst: Path) -> None: | ||
| """``os.replace`` with Windows-friendly retries. | ||
|
|
||
| On POSIX, ``rename`` succeeds even when other processes hold the | ||
| destination open. On Windows, the rename fails with ``PermissionError`` | ||
| while any handle to the destination is open. Under concurrent writers, | ||
| those handles are brief, so a short retry-with-backoff converges. POSIX | ||
| almost always succeeds on the first try, so the loop has no overhead | ||
| there. | ||
| """ | ||
| delay = 0.005 | ||
| attempts = 10 | ||
| for attempt in range(attempts): | ||
| try: | ||
| os.replace(src, dst) | ||
| return | ||
| except PermissionError: | ||
| if attempt == attempts - 1: | ||
| raise | ||
| time.sleep(delay) | ||
| delay = min(delay * 2, 0.1) | ||
|
|
||
|
|
||
| def atomic_write_text( | ||
| path: Path, | ||
| text: str, | ||
| *, | ||
| mode: int = 0o644, | ||
| encoding: str = "utf-8", | ||
| ) -> None: | ||
| """Atomically write *text* to *path*. | ||
|
|
||
| Writes a unique sibling file then ``os.replace()``s it onto *path*. POSIX | ||
| guarantees the rename leaves *path* pointing at either the previous | ||
| content or the new content, never a partial write — which prevents | ||
| readers (especially native code that mmaps) from observing a torn file | ||
| during concurrent writes. | ||
| """ | ||
| path = Path(path) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| fd, new_path = _open_new_sibling(path, mode) | ||
| try: | ||
| # O_CREAT mode is umask-masked; force the requested mode exactly. | ||
| # Path-based chmod (not fchmod) so this works under pyfakefs in tests. | ||
| os.chmod(new_path, mode) | ||
| with os.fdopen(fd, "w", encoding=encoding) as f: | ||
| f.write(text) | ||
| _replace_with_retry(new_path, path) | ||
| except BaseException: | ||
| try: | ||
| os.unlink(new_path) | ||
| except OSError: | ||
| pass | ||
| raise | ||
|
|
||
|
|
||
| def atomic_write_bytes(path: Path, data: bytes, *, mode: int = 0o644) -> None: | ||
| """Atomically write *data* to *path*. See :func:`atomic_write_text`.""" | ||
| path = Path(path) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| fd, new_path = _open_new_sibling(path, mode) | ||
| try: | ||
| # See atomic_write_text: path-based chmod for pyfakefs / Windows compat. | ||
| os.chmod(new_path, mode) | ||
| with os.fdopen(fd, "wb") as f: | ||
| f.write(data) | ||
| _replace_with_retry(new_path, path) | ||
| except BaseException: | ||
| try: | ||
| os.unlink(new_path) | ||
| except OSError: | ||
| pass | ||
| raise | ||
|
Comment on lines
+228
to
+244
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is, barring the fdopen flags, an exact duplication of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi the actual problem was verified:
Once the |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the except should be after the next try