Skip to content

fix(plugin): atomic rename on plugin binary download — prevents ETXTBSY race - #576

Open
smecsia wants to merge 2 commits into
mainfrom
fix/fetcher-atomic-rename
Open

smecsia wants to merge 2 commits into
mainfrom
fix/fetcher-atomic-rename

Conversation

@smecsia

@smecsia smecsia commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #575. The timeout bump moved the failure window from
"download takes too long" to "parallel downloaders race on the same
cache file" — live-caught on #568's post-#575 CI with:

fork/exec /home/runner/.sc/plugins/.../sc-plugin-gcp-linux-amd64:
  text file busy

(Linux ETXTBSY — a process tried to fork/exec a file another
process had opened for write.)

What

Download to <cached>.tmp-<random> via os.CreateTemp, verify SHA +
chmod on the temp path, then os.Rename into place. Rename within one
filesystem is atomic; any observer sees either the old/absent file or
the complete new one, never a torn write. Deferred os.Remove(tmp)
cleans up either way.

Two parallel downloaders both land on their own temp, finish, rename.
Last rename wins by content, which is fine — identical bytes by
content hash (the SHA check on the temp gates this).

Why not flock / sync.Mutex

  • flock() works but adds a dependency and the "wait for the other
    downloader" semantics are unnecessary — both downloaders fetch the
    same bytes.
  • sync.Mutex only helps within one process. go test ./pkg/A/... ./pkg/B/... runs two separate test binaries; a mutex would miss
    the cross-process race.

Why #575's tests didn't catch it

Fetcher unit tests use a mock HTTP server (deterministic) or
path:-mode (no download). The race fires only against the real
GitHub releases CDN under parallel go test ./... load, which only
CI reproduces.

Test plan

…SY race

Follow-up to #575. #575's timeout bump moved the failure window from
"download takes too long" to "parallel downloaders race on the same
cache file". Live-caught on #568's CI post-#575 (2026-11-08):

    fork/exec /home/runner/.sc/plugins/.../sc-plugin-gcp-linux-amd64:
      text file busy

`go test ./...` runs packages in parallel (controlled by `-p`). Two
parallel test binaries that both load a server.yaml with
`plugins.gcp:` both see a cache-miss on the per-version plugin binary
path, and both call `downloadFile` writing to the SAME `cached` path.
Linux's ETXTBSY fires when a goroutine tries to fork/exec the file
while another has it opened for write.

## Fix

Download to `<cached>.tmp-<random>` via `os.CreateTemp`, verify SHA +
chmod on the temp path, then `os.Rename` to the final `cached` path.

- `os.Rename` within one filesystem is atomic. Any observer sees
  either the old/absent file or the complete new one — never a torn
  write.
- Two parallel downloaders each land on their own temp, each finishes,
  each renames. Last-renamer wins by content — both downloaders fetched
  the same release artifact, so content is identical by content hash
  (the SHA check on the temp path gates this).
- `defer os.Remove(tmpPath)` cleans up after a successful rename
  (no-op) or an error mid-flight (removes partial download).

## Why not a file lock

- `flock()` works but adds a dependency and the semantics of "wait for
  the other downloader" are unnecessary — both downloaders fetch the
  same bytes. Letting them each finish in their own tmp and rename
  wins is simpler and equivalent.
- A sync.Mutex only helps for a single process. `go test ./pkg/A/...
  ./pkg/B/...` runs two separate binaries; sync.Mutex wouldn't catch
  the cross-process race.

## Test plan

- [x] `go build ./...`
- [x] `go test ./pkg/plugin/... -count=1` — green (2.6s/0.07s). The
  fetcher's own tests use a mock server + path:-mode binaries, so
  they don't exercise the race directly; the real proof is #568's
  Run tests under parallel load, which this PR unblocks.
- [x] gofumpt + gci applied

## Why this wasn't caught by #575's tests

#575 was a timeout bump — fetcher tests use path:-mode (no actual
download) or a mock HTTP server (deterministic timing). The race
only fires against the real GitHub releases CDN under concurrent
load, which only CI reproduces.

Signed-off-by: Ilya Sadykov <smecsia@gmail.com>
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Semgrep Scan Results

Repository: api | Commit: d8758a2

Check Status Details
⚠️ Semgrep Warning 11 warning(s), 15 total

Scanned at 2026-11-08 20:06 UTC

@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Security Scan Results

Repository: api | Commit: d8758a2

Check Status Details
✅ Secret Scan Pass No secrets detected
✅ Dependencies (Trivy) Pass 1 total (no critical/high)
✅ Dependencies (Grype) Pass 1 total (no critical/high)
📦 SBOM Generated 517 components (CycloneDX)

Scanned at 2026-11-08 20:07 UTC

@universe-ops
universe-ops enabled auto-merge (squash) November 8, 2026 20:06
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