Skip to content
This repository was archived by the owner on Jul 2, 2026. It is now read-only.

Commit b91ac1f

Browse files
committed
fix(download): stage+rename archive extracts to avoid ETXTBSY races
Concurrent prek invocations of the same hook (oxlint, shellcheck, hadolint, etc.) used to share a single extraction target inside the cache dir. While one wrapper was still writing the binary via tar -xzf, another's exec on the same path returned 'Text file busy' (ETXTBSY) and the hook failed with exit 126 on aarch64 CI runners. Extract into mktemp -d staging instead, chmod there, and atomic-rename the single binary into bin_path. The rename is a directory-entry swap, so any process mid-exec on the old inode is unaffected and no writer ever holds bin_path open.
1 parent 7b6c61b commit b91ac1f

2 files changed

Lines changed: 23 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
matching files across parallel `uv run mypy` invocations. The parallel mode
1818
raced on the local-package install/build step and surfaced as
1919
`INTERNAL ERROR -- Please try using mypy master on GitHub` in CI.
20+
- `download_tool_from_archive`: extract archives to a per-invocation staging
21+
directory and atomically rename the binary into place. Direct extraction
22+
into the cache directory let concurrent prek invocations of the same hook
23+
race on the binary, surfacing as `Text file busy` (ETXTBSY) when one
24+
wrapper tried to exec a partially-written file. Affects every archive-based
25+
hook (oxlint, shellcheck, hadolint, golangci-lint, kubeconform, helm-lint,
26+
air-format/check, etc.).
2027

2128
### Added
2229

lib/download.sh

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,23 +167,35 @@ download_tool_from_archive() {
167167
return 1
168168
fi
169169

170-
# Extract the named binary from the archive.
170+
# Extract to a per-invocation staging dir so the bin_path is published via
171+
# atomic rename. Concurrent invocations of the same hook (e.g. when prek
172+
# splits files across parallel processes) used to race on the extracted
173+
# binary and surface as ETXTBSY ("Text file busy") when one wrapper tried
174+
# to exec a partially-written file. Staging + rename eliminates the race.
175+
local stage
176+
stage="$(mktemp -d "${dest_dir}/.stage.XXXXXX")"
171177
case "${asset_name}" in
172178
*.tar.gz | *.tgz)
173-
tar -xzf "${tmp}" -C "${dest_dir}" "${binary_in_archive}"
179+
tar -xzf "${tmp}" -C "${stage}" "${binary_in_archive}"
174180
;;
175181
*.zip)
176-
unzip -q -o "${tmp}" "${binary_in_archive}" -d "${dest_dir}"
182+
unzip -q -o "${tmp}" "${binary_in_archive}" -d "${stage}"
177183
;;
178184
*)
185+
rm -rf "${stage}"
179186
rm -f "${tmp}"
180187
printf 'unsupported archive format for %s (expected .tar.gz/.tgz/.zip)\n' "${asset_name}" >&2
181188
return 1
182189
;;
183190
esac
184191

185192
rm -f "${tmp}"
186-
chmod 0755 "${bin_path}"
193+
chmod 0755 "${stage}/${binary_in_archive}"
194+
# Atomic publish. If a concurrent process beat us to it, the rename still
195+
# succeeds (overwriting an identical, fully-written file) — bin_path keeps
196+
# the same inode contents either way.
197+
mv "${stage}/${binary_in_archive}" "${bin_path}"
198+
rm -rf "${stage}"
187199
: >"${sentinel}"
188200
printf '%s\n' "${bin_path}"
189201
}

0 commit comments

Comments
 (0)