Skip to content

fix(hooks): Make cache-miss download race-safe - #1012

Merged
MaxymVlasov merged 8 commits into
downloadable_and_version_controlled_hooksfrom
lock_issue
Aug 20, 2026
Merged

fix(hooks): Make cache-miss download race-safe#1012
MaxymVlasov merged 8 commits into
downloadable_and_version_controlled_hooksfrom
lock_issue

Conversation

@MaxymVlasov

@MaxymVlasov MaxymVlasov commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Put an x into the box if that apply:

  • This PR introduces breaking change.
  • This PR fixes a bug.
  • This PR adds new functionality.
  • This PR enhances existing functionality.

Description of your changes

What

This section was generated by AI.

  • common::resolve_tool_path's cache-miss path downloaded/unzipped every pinned tool version directly into the shared, version-keyed cache directory. When multiple pre-commit hook processes raced the same uncached (tool, version) at once, they stepped on each other's curl/unzip output: one process' cleanup could delete the archive out from under another's still-running unzip (cannot find or open ... .zip), or unzip could meet a binary a sibling already extracted and block on an interactive overwrite prompt - hanging under pre-commit's non-interactive stdin.
  • Add common::populate_tool_cache: downloads/installs into a private per-process temp directory (mktemp -d), then atomically publishes the resulting binary into the cache via a plain-file mv. A process that loses the race discards its own redundant copy instead of corrupting the winner's.
  • Add test_concurrent_cache_miss_is_race_free (network): 2 concurrent real downloads against an empty cache, asserting a clean single binary and no leftover temp dirs.

Why

Fix race condition during concurrent (pre-commit usually proceeds with 4 bunches simultaneously), which end in over9999 errors like this:

NOTE: The requested 'terraform' version '0.12.0' will be downloaded/used instead of whatever is on $PATH.
Downloading 'terraform' version '0.12.0'...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 14.2M  100 14.2M    0     0  1333k      0  0:00:10  0:00:10 --:--:-- 1721k
unzip:  cannot find or open terraform.zip, terraform.zip.zip or terraform.zip.ZIP.
ERROR: Failed to download 'terraform' version '0.12.0' via '/home/vm/.cache/pre-commit/repo29gptgn5/hooks/../tools/install/terraform.sh'.

How can we test changes

repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
  rev: 700a514858ab09d7ad0ac20029ddf3e8f1592550
    - id: terraform_fmt
      args:
        - --hook-config=--tool-version=0.12.2

This section was generated by AI.

pytest tests/pytest/tool_version_test.py -m "not network"   # 33 passed
pytest tests/pytest/tool_version_test.py -m network          # 2 passed (real download)
shellcheck -x hooks/_common.sh
shfmt -i 2 -ci -sr -d hooks/_common.sh

All pre-commit hooks pass locally: shellcheck, shfmt, ruff check, ruff format, wemake-python-styleguide, mypy (py3.10/3.12/3.14).

Assisted-by

Specific models used per commit are specified in the commit messages.

`common::resolve_tool_path` downloaded/unzipped every pinned tool
version directly into the shared, version-keyed cache directory. When
N pre-commit hook processes raced the same uncached (tool, version) at
once, they stepped on each other's `curl`/`unzip` output: one
process' cleanup could delete the archive out from under another's
still-running `unzip` ("cannot find or open ... .zip"), or `unzip`
could meet a binary a sibling already extracted and block on an
interactive overwrite prompt - hanging under pre-commit's
non-interactive stdin.

- Add `common::populate_tool_cache`: downloads/installs into a
  private per-process staging dir (`mktemp -d`), then atomically
  publishes the resulting binary into the cache via a plain-file
  `mv`. A process that loses the race discards its own redundant
  copy instead of corrupting the winner's.
- Add `test_concurrent_cache_miss_is_race_free` (network): 2
  concurrent real downloads against an empty cache, asserting a
  clean single binary and no leftover staging dirs.

Assisted-by: Sisyphus:claude-sonnet-5 opencode
- Shrink the function header to the same Globals/Arguments/Outputs
  shape every other function here uses; drop the standalone
  rationale paragraph (already covered by the previous commit
  message).
- Trim inline comments to one line each, keep only the non-obvious
  ones (race-check-before-mv, atomic-rename, lost-race handling).
- Rename `staging_dir` -> `tmp_dir`, matching this file's own
  `tmp_file` mktemp precedent (terraform_wrapper_module_for_each.sh).

Assisted-by: Sisyphus:claude-sonnet-5 opencode
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: de8ae34b-0643-4880-8f72-f1ad83f429be

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.87%. Comparing base (6078e48) to head (58baaf4).

Additional details and impacted files
@@                              Coverage Diff                              @@
##           downloadable_and_version_controlled_hooks    #1012      +/-   ##
=============================================================================
+ Coverage                                      98.81%   98.87%   +0.05%     
=============================================================================
  Files                                             11       11              
  Lines                                            762      800      +38     
  Branches                                          12       13       +1     
=============================================================================
+ Hits                                             753      791      +38     
  Misses                                             9        9              
Flag Coverage Δ
CI-GHA 98.87% <100.00%> (+0.05%) ⬆️
MyPy 97.23% <100.00%> (+0.13%) ⬆️
OS-Linux 98.87% <100.00%> (+0.05%) ⬆️
OS-Windows 100.00% <100.00%> (ø)
OS-macOS 100.00% <100.00%> (ø)
Py-3.10.11 100.00% <100.00%> (ø)
Py-3.10.20 100.00% <100.00%> (ø)
Py-3.11.15 ?
Py-3.11.16 100.00% <100.00%> (?)
Py-3.11.9 100.00% <100.00%> (ø)
Py-3.12.10 100.00% <100.00%> (ø)
Py-3.12.13 ?
Py-3.12.14 100.00% <100.00%> (?)
Py-3.13.14 100.00% <100.00%> (ø)
Py-3.13.15 98.87% <100.00%> (+0.05%) ⬆️
Py-3.14.6 100.00% <100.00%> (ø)
Py-3.14.7 100.00% <100.00%> (ø)
VM-macos-15 100.00% <100.00%> (ø)
VM-macos-15-intel 100.00% <100.00%> (ø)
VM-ubuntu-24.04 100.00% <100.00%> (ø)
VM-ubuntu-latest 97.23% <100.00%> (+0.13%) ⬆️
VM-windows-2025 100.00% <100.00%> (ø)
pytest 100.00% <100.00%> (ø)

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

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

@MaxymVlasov MaxymVlasov changed the title fix(hooks): make cache-miss download race-safe fix(hooks): Make cache-miss download race-safe Aug 19, 2026
@MaxymVlasov
MaxymVlasov marked this pull request as ready for review August 19, 2026 16:57
Comment thread hooks/_common.sh Outdated
Comment thread hooks/_common.sh Outdated
MaxymVlasov and others added 3 commits August 20, 2026 13:04
Co-authored-by: George Yermulnik (Georgii Iermulnik) <yz@yz.kiev.ua>
`common::populate_tool_cache` only checked whether `cached_bin`
already existed right before the `mv` that publishes it - after the
full download had already run. Check for it again up front too, so a
process that starts after a sibling has already finished publishing
skips the download entirely instead of redundantly repeating it.

The pre-`mv` check stays: it's what stops a slower sibling's `mv`
from silently replacing a binary another process may already be
executing (`mv` doesn't refuse an existing destination, it just
replaces it).

Assisted-by: Sisyphus:claude-sonnet-5 opencode

Copilot AI 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.

Pull request overview

This PR hardens tool-version caching in the shell hook shared logic to be safe under concurrent cache-miss downloads (multiple pre-commit processes racing the same (tool, version)), and adds a regression test that reproduces the original race with real network downloads.

Changes:

  • Add common::populate_tool_cache to stage installs in a per-process temp directory and then publish into the shared cache.
  • Update common::resolve_tool_path to use the new cache-population helper instead of downloading directly into the shared cache directory.
  • Add a new network-marked pytest regression test that runs two concurrent hooks against an empty cache and asserts the cache entry is valid and staging dirs are cleaned.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
hooks/_common.sh Introduces a staging-and-publish cache population function and wires it into tool path resolution to avoid concurrent unzip/curl conflicts.
tests/pytest/tool_version_test.py Adds concurrent hook runner helper and a network regression test that exercises the cache-miss race scenario.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread hooks/_common.sh Outdated
Comment thread hooks/_common.sh Outdated
Comment thread tests/pytest/tool_version_test.py
`mv` silently replaces an existing destination, so a slower sibling
process could still clobber a binary another process already
published and might be executing (surfaced as "fork/exec ...
(deleted): no such file or directory"). `ln` (hard link, no `-f`)
fails with EEXIST instead of overwriting, so publishing is now
atomically "first process wins, nobody else touches it" - the temp
dir's own `rm -rf` at the end still finishes the move to the binary's
only remaining name.

- Correct a comment that attributed the stdout-capture contract to
  `populate_tool_cache` itself; it belongs to its caller
  `resolve_tool_path`.
- Add the same `hooks/` existence guard `_run_hook` already has to
  its sibling `_run_concurrent_hooks`, for the same packaging-
  regression message instead of a confusing raw exit code.

Assisted-by: Sisyphus:claude-sonnet-5 opencode
@MaxymVlasov
MaxymVlasov requested a review from yermulnik August 20, 2026 13:10
@MaxymVlasov
MaxymVlasov merged commit 43bf759 into downloadable_and_version_controlled_hooks Aug 20, 2026
50 checks passed
@MaxymVlasov
MaxymVlasov deleted the lock_issue branch August 20, 2026 19:30
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