Skip to content

Commit f54594c

Browse files
committed
fix(hooks): make cache-miss download race-safe
`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
1 parent 6078e48 commit f54594c

2 files changed

Lines changed: 186 additions & 14 deletions

File tree

hooks/_common.sh

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,104 @@ function common::detect_os_arch {
544544
export TARGETOS TARGETARCH
545545
}
546546

547+
#######################################################################
548+
# Download and install one tool version into a private, per-process
549+
# staging directory, then atomically publish the resulting binary
550+
# into the shared cache.
551+
#
552+
# `tools/install/<tool>.sh` installer scripts use a fixed filename
553+
# relative to their CWD (e.g. `terraform.zip`), which is only safe
554+
# with one process per CWD at a time - true for their original
555+
# purpose (one `RUN` per tool, per Docker build), not for N pre-commit
556+
# hook processes racing the same cache miss. Without this, two such
557+
# processes could interleave on the same shared directory: one's
558+
# `rm "$PKG"` deleting the archive out from under the other's still-
559+
# running `unzip` ("cannot find or open ... .zip"), or `unzip` meeting
560+
# a binary a sibling already extracted and blocking on an interactive
561+
# overwrite prompt that hangs under pre-commit's non-interactive
562+
# stdin. Giving every caller its own staging directory removes the
563+
# shared CWD these scripts were never designed to share.
564+
# Globals:
565+
# GITHUB_TOKEN - forwarded automatically; read directly by the
566+
# invoked installer script
567+
# Arguments:
568+
# tool_name (string) tool name, matching a `tools/install/<tool>.sh`
569+
# file and its expected `${TOOL^^}_VERSION` environment variable
570+
# version (string) exact version to install
571+
# installer_script (string) absolute path to the
572+
# `tools/install/<tool>.sh` script to invoke
573+
# env_var_name (string) name of the environment variable the
574+
# installer script reads its requested version from (e.g.
575+
# "TERRAFORM_VERSION")
576+
# cache_dir (string) final, shared cache directory for this
577+
# (tool, version) pair. Must already exist.
578+
# cached_bin (string) expected absolute path to the resolved binary
579+
# inside `cache_dir` once installed
580+
# Outputs:
581+
# Returns 0 once `cached_bin` exists - either from this process' own
582+
# install, or from another process that won the race first. Returns
583+
# 1 with an error message if the download/install itself failed.
584+
#######################################################################
585+
function common::populate_tool_cache {
586+
local -r tool_name="$1"
587+
local -r version="$2"
588+
local -r installer_script="$3"
589+
local -r env_var_name="$4"
590+
local -r cache_dir="$5"
591+
local -r cached_bin="$6"
592+
593+
# `mktemp -d` guarantees a directory no other process is also using,
594+
# so each process' installer run is isolated - regardless of how
595+
# many processes hit the same cache miss at once.
596+
local staging_dir
597+
staging_dir=$(mktemp -d "${cache_dir}.XXXXXXXXXX") || {
598+
common::colorify "red" "ERROR: Failed to create a staging directory for '$tool_name' version '$version'."
599+
return 1
600+
}
601+
602+
# Redirect the installer's own stdout to stderr: this function's stdout is
603+
# a contract (the resolved path, captured via "$(...)" by every caller),
604+
# and installers like terraform.sh/tflint.sh call bare `unzip` (no `-q`),
605+
# which prints "Archive: ... inflating: ..." to stdout by default -
606+
# harmless noise in a Docker build log, but it would otherwise corrupt
607+
# the path this function returns.
608+
if ! (
609+
cd "$staging_dir" || exit 1
610+
export "$env_var_name=$version"
611+
"$installer_script" 1>&2
612+
); then
613+
common::colorify "red" "ERROR: Failed to download '$tool_name' version '$version' via '$installer_script'."
614+
rm -rf "$staging_dir"
615+
return 1
616+
fi
617+
618+
# Another process may have already published `cached_bin` while this
619+
# one was downloading its own copy - if so, prefer that result over
620+
# ours (both are the same requested version) instead of racing again
621+
# on the `mv` below.
622+
if [[ -x $cached_bin ]]; then
623+
rm -rf "$staging_dir"
624+
return 0
625+
fi
626+
627+
# `mv` between two paths on the same filesystem is atomic (POSIX
628+
# `rename(2)`), and both sides here are plain files, not
629+
# directories, so a concurrent reader of `cached_bin` always sees
630+
# either the previous state or the fully-written new one - never a
631+
# partially-written file - on both GNU and BSD `mv`.
632+
if ! mv "$staging_dir/$(basename "$cached_bin")" "$cached_bin" 2> /dev/null; then
633+
rm -rf "$staging_dir"
634+
# Lost the race between the check above and this `mv`: some other
635+
# process' `mv` landed on `cached_bin` first. That's fine, its
636+
# result is equally valid, ours is simply redundant.
637+
[[ -x $cached_bin ]] && return 0
638+
common::colorify "red" "ERROR: Failed to move '$tool_name' version '$version' into place at '$cached_bin'."
639+
return 1
640+
fi
641+
642+
rm -rf "$staging_dir"
643+
}
644+
547645
#######################################################################
548646
# Resolve a specific version of a wrapped tool's binary, downloading
549647
# and caching it on demand if it isn't already cached.
@@ -682,20 +780,7 @@ function common::resolve_tool_path {
682780

683781
mkdir -p "$cache_dir"
684782

685-
# Redirect the installer's own stdout to stderr: this function's stdout is
686-
# a contract (the resolved path, captured via "$(...)" by every caller),
687-
# and installers like terraform.sh/tflint.sh call bare `unzip` (no `-q`),
688-
# which prints "Archive: ... inflating: ..." to stdout by default -
689-
# harmless noise in a Docker build log, but it would otherwise corrupt
690-
# the path this function returns.
691-
if ! (
692-
cd "$cache_dir" || exit 1
693-
export "$env_var_name=$version"
694-
"$installer_script" 1>&2
695-
); then
696-
common::colorify "red" "ERROR: Failed to download '$tool_name' version '$version' via '$installer_script'."
697-
exit 1
698-
fi
783+
common::populate_tool_cache "$tool_name" "$version" "$installer_script" "$env_var_name" "$cache_dir" "$cached_bin" || exit $?
699784

700785
if [[ ! -x $cached_bin ]]; then
701786
common::colorify "red" "ERROR: '$tool_name' installer completed but expected binary was not found at '$cached_bin'."

tests/pytest/tool_version_test.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,44 @@ def _run_hook( # pragma: win32 no cover
334334
)
335335

336336

337+
def _run_concurrent_hooks( # pragma: win32 no cover
338+
count: int,
339+
hook_name: str,
340+
args: list[str],
341+
*,
342+
cwd: Path,
343+
env: dict[str, str],
344+
) -> list[str]:
345+
"""Run `count` copies of a hook concurrently, on the same `a.tf`.
346+
347+
Every copy is started before any of them is waited on, so all
348+
`count` copies genuinely overlap instead of running one after
349+
another.
350+
351+
Returns:
352+
Each process' merged stdout/stderr, in start order.
353+
"""
354+
hook_path = HOOKS_DIR / hook_name
355+
processes = [
356+
subprocess.Popen( # noqa: S603
357+
(BASH, str(hook_path), *args, '--', 'a.tf'),
358+
cwd=cwd,
359+
env=env,
360+
stdout=subprocess.PIPE,
361+
stderr=subprocess.STDOUT,
362+
text=True,
363+
)
364+
for _ in range(count)
365+
]
366+
outputs = [
367+
proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)[0] for proc in processes
368+
]
369+
for output, proc in zip(outputs, processes, strict=True):
370+
# 2 is tflint's own lint findings on the minimal fixture, not ours.
371+
assert proc.returncode in {0, 2}, output
372+
return outputs
373+
374+
337375
def test_cache_hit_uses_cached_binary( # pragma: win32 no cover
338376
tmp_repo: Path,
339377
cache_dir: Path,
@@ -1015,3 +1053,52 @@ def test_real_download_on_cache_miss( # pragma: win32 no cover
10151053

10161054
# 2 is tflint's own lint findings on the minimal fixture, not ours.
10171055
assert hook_run.returncode in {0, 2}, combined
1056+
1057+
1058+
@pytest.mark.network
1059+
def test_concurrent_cache_miss_is_race_free( # pragma: win32 no cover
1060+
tmp_repo: Path,
1061+
cache_dir: Path,
1062+
) -> None:
1063+
"""Check N processes racing the same cache miss don't corrupt it.
1064+
1065+
Regression test for a race in `common::populate_tool_cache`:
1066+
before it staged each download in a private, per-process directory
1067+
and atomically published only the resulting binary, N processes
1068+
hitting the same uncached (tool, version) at once shared one
1069+
`curl`/`unzip` working directory - so one process' cleanup
1070+
(`rm "$PKG"`) could delete the archive out from under another's
1071+
still-running `unzip` ("cannot find or open ... .zip"), or `unzip`
1072+
could meet a binary a sibling had already extracted and block on
1073+
an interactive overwrite prompt, hanging (then failing) under
1074+
pre-commit's non-interactive stdin.
1075+
"""
1076+
# 2 is the minimal N that can reproduce a race at all; every
1077+
# process beyond that adds real-network exposure (see this
1078+
# function's own `@pytest.mark.network`) without proving anything
1079+
# a race between 2 doesn't already prove.
1080+
outputs = _run_concurrent_hooks(
1081+
2,
1082+
'terraform_tflint.sh',
1083+
[f'--hook-config=--tool-version={PINNED_TFLINT_VERSION}'],
1084+
cwd=tmp_repo,
1085+
env=_hook_env(_pct_cache_env(cache_dir), os.environ['PATH']),
1086+
)
1087+
1088+
cached_bin = cache_dir / 'tflint' / PINNED_TFLINT_VERSION / 'tflint'
1089+
assert os.access(cached_bin, os.X_OK), outputs
1090+
1091+
version_check = subprocess.run( # noqa: S603
1092+
(str(cached_bin), '--version'),
1093+
capture_output=True,
1094+
text=True,
1095+
check=False,
1096+
timeout=VERSION_CHECK_TIMEOUT_SECONDS,
1097+
)
1098+
assert version_check.returncode == 0, version_check.stderr
1099+
assert PINNED_TFLINT_VERSION in version_check.stdout
1100+
1101+
# Every staging dir is cleaned up whether its process won the race
1102+
# or lost it - none left behind regardless of outcome.
1103+
leftovers = list((cache_dir / 'tflint').glob(f'{PINNED_TFLINT_VERSION}.*'))
1104+
assert not leftovers, outputs

0 commit comments

Comments
 (0)