Skip to content

Commit 43bf759

Browse files
authored
fix(hooks): Make cache-miss download race-safe (#1012)
Fix race condition during concurrent (pre-commit usually proceeds with 4 bunches simultaneously), which end in over9999 errors like this: ```bash 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'. ```
1 parent 6078e48 commit 43bf759

2 files changed

Lines changed: 167 additions & 16 deletions

File tree

hooks/_common.sh

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,78 @@ 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+
# temp directory, then atomically publish the resulting binary into
550+
# the shared cache - concurrency-safe against other processes
551+
# populating the same (tool, version) entry at the same time.
552+
# Globals:
553+
# GITHUB_TOKEN - forwarded automatically; read directly by the
554+
# invoked installer script
555+
# Arguments:
556+
# tool_name (string) tool name, matching a `tools/install/<tool>.sh`
557+
# file and its expected `${TOOL^^}_VERSION` environment variable
558+
# version (string) exact version to install
559+
# installer_script (string) absolute path to the installer to invoke
560+
# env_var_name (string) env var the installer reads its version from
561+
# cache_dir (string) final, shared cache dir for this (tool, version).
562+
# Must already exist.
563+
# cached_bin (string) expected absolute path to the resolved binary
564+
# Outputs:
565+
# Returns 0 once `cached_bin` exists, ours or a race winner's.
566+
# Returns 1 with an error message if the install itself failed.
567+
#######################################################################
568+
function common::populate_tool_cache {
569+
local -r tool_name="$1"
570+
local -r version="$2"
571+
local -r installer_script="$3"
572+
local -r env_var_name="$4"
573+
local -r cache_dir="$5"
574+
local -r cached_bin="$6"
575+
576+
if [[ -x $cached_bin ]]; then
577+
return 0
578+
fi
579+
580+
local tmp_dir
581+
tmp_dir=$(mktemp -d "${cache_dir}.XXXXXXXXXX") || {
582+
common::colorify "red" "ERROR: Failed to create a temp directory for '$tool_name' version '$version'."
583+
return 1
584+
}
585+
586+
# Redirect the installer's own stdout to stderr: this is a plain
587+
# function call, not a "$(...)" capture, so anything printed here
588+
# would flow straight through to `common::resolve_tool_path`'s own
589+
# stdout - the resolved path, captured via "$(...)" by every caller
590+
# of *that* function - and installers like terraform.sh/tflint.sh
591+
# call bare `unzip` (no `-q`), which prints "Archive: ... inflating:
592+
# ..." to stdout by default.
593+
if ! (
594+
cd "$tmp_dir" || exit 1
595+
export "$env_var_name=$version"
596+
"$installer_script" 1>&2
597+
); then
598+
common::colorify "red" "ERROR: Failed to download '$tool_name' version '$version' via '$installer_script'."
599+
rm -rf "$tmp_dir"
600+
return 1
601+
fi
602+
603+
# `ln` (hard link, no `-f`) fails with EEXIST instead of silently
604+
# replacing an existing destination - unlike `mv`, which would
605+
# clobber a binary a sibling process may already be running.
606+
# `tmp_dir` is a sibling of `cache_dir` (both under the same parent),
607+
# so this is guaranteed to stay on one filesystem.
608+
if ! ln "$tmp_dir/$(basename "$cached_bin")" "$cached_bin" 2> /dev/null; then
609+
rm -rf "$tmp_dir"
610+
# Lost the race - the winner's copy is equally valid.
611+
[[ -x $cached_bin ]] && return 0
612+
common::colorify "red" "ERROR: Failed to update '$cached_bin' with '$tool_name' version '$version'."
613+
return 1
614+
fi
615+
616+
rm -rf "$tmp_dir"
617+
}
618+
547619
#######################################################################
548620
# Resolve a specific version of a wrapped tool's binary, downloading
549621
# and caching it on demand if it isn't already cached.
@@ -639,7 +711,7 @@ function common::resolve_tool_path {
639711
fi
640712

641713
common::colorify "green" \
642-
"NOTE: The requested '$tool_name' version '$version' will be downloaded/used instead of whatever is on \$PATH."
714+
"NOTE: The requested '$tool_name' version '$version' will be used instead of whatever is on \$PATH."
643715
fi
644716

645717
#
@@ -682,20 +754,7 @@ function common::resolve_tool_path {
682754

683755
mkdir -p "$cache_dir"
684756

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
757+
common::populate_tool_cache "$tool_name" "$version" "$installer_script" "$env_var_name" "$cache_dir" "$cached_bin" || exit $?
699758

700759
if [[ ! -x $cached_bin ]]; then
701760
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: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
# `common::colorify` writes to stderr, hence the stderr/stdout merge in
4949
# `_run_hook` below.
5050
DOWNLOAD_MSG = "Downloading '" # hooks/_common.sh:662
51-
STRICT_OVERRIDE_MSG = 'downloaded/used instead of whatever is on $PATH' # :629
51+
STRICT_OVERRIDE_MSG = 'used instead of whatever is on $PATH' # :629
5252
PREFER_LOCAL_MSG = "'--tool-version-mode=prefer-local'" # :622
5353
NO_INSTALLER_MSG = 'no installer found' # :658
5454
MODE_INVALID_MSG = "'--tool-version-mode=prefer_local' is not a valid value"
@@ -334,6 +334,49 @@ 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+
if not hook_path.is_file(): # pragma: no cover
356+
# `hooks/` is not part of the wheel, only of the sdist, so a
357+
# packaging regression must fail with a pointed message here
358+
# instead of as a confusing assertion mismatch further down.
359+
pytest.fail(f'Hook script not found: {hook_path}')
360+
processes = [
361+
subprocess.Popen( # noqa: S603
362+
(BASH, str(hook_path), *args, '--', 'a.tf'),
363+
cwd=cwd,
364+
env=env,
365+
stdout=subprocess.PIPE,
366+
stderr=subprocess.STDOUT,
367+
text=True,
368+
)
369+
for _ in range(count)
370+
]
371+
outputs = [
372+
proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)[0] for proc in processes
373+
]
374+
for output, proc in zip(outputs, processes, strict=True):
375+
# 2 is tflint's own lint findings on the minimal fixture, not ours.
376+
assert proc.returncode in {0, 2}, output
377+
return outputs
378+
379+
337380
def test_cache_hit_uses_cached_binary( # pragma: win32 no cover
338381
tmp_repo: Path,
339382
cache_dir: Path,
@@ -1015,3 +1058,52 @@ def test_real_download_on_cache_miss( # pragma: win32 no cover
10151058

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

0 commit comments

Comments
 (0)