Skip to content

fix: atomic writes for cache and config files#1243

Open
HelifeWasTaken wants to merge 1 commit into
GitGuardian:mainfrom
HelifeWasTaken:mdalleau/atomic-cache-writes
Open

fix: atomic writes for cache and config files#1243
HelifeWasTaken wants to merge 1 commit into
GitGuardian:mainfrom
HelifeWasTaken:mdalleau/atomic-cache-writes

Conversation

@HelifeWasTaken

@HelifeWasTaken HelifeWasTaken commented May 15, 2026

Copy link
Copy Markdown

Summary

Note — possible SIGBUS under concurrency. Torn writes on a cache file that another process has memory-mapped trigger SIGBUS (bus error, core dumped) on the reader the moment it touches a page that's no longer backed by the file. This has been observed in practice when 5–10 ggshield secret scan ai-hook processes were running in parallel from AI-agent PreToolUse hooks: ggshield exits with bus error (core dumped) and the failure is silent in the surrounding except blocks. Native extensions in the dependency tree (cryptography, _cffi_backend, pydantic_core, the mypyc-compiled module, …) and downstream consumers of these caches are exactly the kind of readers that can hit this. Eliminating torn writes — never letting a reader see a half-written / truncated cache file — closes that class of crash.

Several ggshield cache/config writes were unsafe under concurrent invocations:

  • ai_discovery.json (ggshield/verticals/ai/cache.py), auth_config.yaml / update_check.yaml (ggshield/core/config/utils.py), the secret-scan cache (ggshield/core/cache.py) and the AI fallback user-id (ggshield/verticals/ai/user.py) used truncate-in-place writes (open("w") / O_TRUNC). A second process reading mid-write would see a zero-length or partial file.
  • The docker-image ID cache (ggshield/core/scan/id_cache.py) and the plugin manifest (ggshield/core/plugin/downloader.py) already used a tmp+rename pattern, but with a fixed .tmp filename, so two concurrent writers would race on the same tempfile.

Both classes of bug surface when several ggshield secret scan ai-hook invocations run in parallel from AI-agent PreToolUse hooks. Failures are silently swallowed by the surrounding except OSError: pass / except Exception blocks, so when nothing else goes wrong the visible symptom is just degraded caching — but as called out in the note above, a torn write on a file another process has mmap'd surfaces as SIGBUS and kills the reader outright.

Changes

  • Introduce atomic_write_text / atomic_write_bytes in ggshield/utils/files.py. They use tempfile.mkstemp (per-process unique name in the destination directory) followed by os.replace. POSIX guarantees the rename is atomic — readers see either the previous content or the new content, never a partial write.
  • Switch every affected call site to use the helper:
    • ggshield/verticals/ai/cache.py (save_discovery_cache)
    • ggshield/verticals/ai/user.py (UUID fallback)
    • ggshield/core/config/utils.py (save_yaml_dict, covers auth_config.yaml and update_check.yaml)
    • ggshield/core/cache.py (Cache.save)
    • ggshield/core/scan/id_cache.py (IDCache._save)
    • ggshield/core/plugin/downloader.py (_write_manifest)
  • Add 7 unit tests under tests/unit/utils/test_files.py, including a ThreadPoolExecutor-based concurrent-writers test that asserts the final file is always one of the inputs (never a torn mix) and no temp files leak.
  • Update tests/unit/core/plugin/test_downloader.py::test_tmp_file_is_cleaned_up_on_rename_failure to patch os.replace (now used by the helper) instead of pathlib.Path.replace, and assert no leftover temp files in the directory.

Test plan

  • pytest tests/unit — full unit suite (1919 passed)
  • pytest tests/unit/utils/test_files.py — new atomic-write tests (21 passed, incl. concurrent-writers)
  • pytest tests/unit/core/test_cache.py tests/unit/core/config tests/unit/core/scan tests/unit/core/plugin tests/unit/verticals/ai tests/unit/core/test_check_updates.py — modules with touched call sites
  • CI on this PR

@HelifeWasTaken HelifeWasTaken requested review from a team as code owners May 15, 2026 12:50
@HelifeWasTaken HelifeWasTaken force-pushed the mdalleau/atomic-cache-writes branch 2 times, most recently from 544095a to 965bfa6 Compare May 15, 2026 13:16
@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.93151% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.51%. Comparing base (6a2ba9c) to head (d28520e).

Files with missing lines Patch % Lines
ggshield/utils/files.py 83.01% 9 Missing ⚠️
ggshield/core/cache.py 66.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1243      +/-   ##
==========================================
- Coverage   93.56%   93.51%   -0.05%     
==========================================
  Files         181      181              
  Lines        9433     9474      +41     
==========================================
+ Hits         8826     8860      +34     
- Misses        607      614       +7     
Flag Coverage Δ
unittests 93.51% <84.93%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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) previously did one of:

  - truncate-in-place writes (open("w") / O_TRUNC), or
  - tmp + rename with a fixed ".tmp" filename shared by every process

Both patterns are unsafe under concurrent ggshield invocations: the
first leaves concurrent readers seeing a zero-length / partial file,
the second has two processes racing on the same tempfile. This shows
up in practice when several `ggshield secret scan ai-hook` invocations
run in parallel from AI-agent PreToolUse hooks - cache contents get
truncated or partially written, and the failures are silently swallowed
by surrounding `except OSError: pass` / `except Exception` blocks.

Introduce `atomic_write_text` / `atomic_write_bytes` in
`ggshield/utils/files.py` that use `tempfile.mkstemp` (per-process
unique name in the destination directory) followed by `os.replace`,
and switch every affected call site over.

Co-Authored-By: Mattis Dalleau <mattisdalleau@gmail.com>
@HelifeWasTaken HelifeWasTaken force-pushed the mdalleau/atomic-cache-writes branch from 965bfa6 to d28520e Compare May 15, 2026 13:41

@paulpetit-gg-ext paulpetit-gg-ext left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the context: is this an error you or someone actually encountered? Under which OS?

The PR description looks AI generated and based on a code analysis, but mentions that the phenomenon has been "observed in practice".

The main idea seems sound, and I see how concurrent writes could corrupt a file like ai_discovery.json, so this kind of change is likely welcomed. Still, I'm very surprised this would ever trigger a SIGBUS, which sounds more like a "Python" problem than a "ggshield" problem. I would have expected a simple deserialization error (still undesirable, but which would have been handled gracefully in the code).

Moreover, the most important test, which is supposed to exhibit that the new code prevents the corruption, passes consistently with a naive implementation, so there is no guarantee that we actually fixed an issue here.

Comment thread ggshield/utils/files.py
Comment on lines +228 to +244
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is, barring the fdopen flags, an exact duplication of atomic_write_text. We should probably have a single implementation. If we want to keep the two *_text and *_bytes methods (which is fine), have them be thin wrappers around a common implementation.

@mattisdalleau-gg mattisdalleau-gg May 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi the actual problem was verified:

❯ ggshield auth login
[1]    368219 bus error (core dumped)  ggshield auth login

SIGBUS is sound due to data corruption opening the file with O_TRUNC and re-reading it conccurently (around 5~6 agents using the ggshield secret scan ai-hook) which is probably a timeframe of <1-2ms but very existant actually

Once the SIGBUS occur and the agents are actively calling it becomes persistent as mmap (probably?) cannot sanitize the inode of the file fast enough when truncating


assert target.read_bytes() == payload

def test_concurrent_writers_produce_valid_file(self, tmp_path: Path) -> None:

@paulpetit-gg-ext paulpetit-gg-ext May 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test does not justify the change, as this naive (and non-atomic) implementation of atomic_write_text() also passes it:

def atomic_write_text(path, data, mode, encoding):    
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(data, encoding=encoding)
    path.chmod(mode)

To be honest, I tried to make this test exhibit the error with the naive implementation but couldn't, even with larger and more convoluted payloads.

@agateau-gg agateau-gg removed their request for review May 21, 2026 07:08
@paulpetit-gg-ext

paulpetit-gg-ext commented May 21, 2026

Copy link
Copy Markdown
Contributor

Two additional comments:

  • it looks like feat: cache auth check api calls for secret scans #1216 would benefit from this PR, as it implements something similar on its own :)
  • this PR avoids having corrupted files (the latest process to write wins), but it doesn't prevent "cache stampede": multiple ggshield processes won't know there is already another one accessing/updating the cache. This is something a lib like filelock would provide. We can also probably use it for the use case addressed by this PR as well.

Maybe we want to have utils for both needs, but the "cache stampede" avoidance is definitely useful: we will soon need to debounce hook calls as some agents call multiple times the same hook with the same payload if it is configured in multiple places.

Comment thread ggshield/core/cache.py
fd = os.open(
str(self.cache_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
text = json.dumps(self.to_dict())
except Exception as e:

Copy link
Copy Markdown
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants