Skip to content

fix(wrapper): make engine download crash-safe and resumable - #55

Merged
5uck1ess merged 2 commits into
mainfrom
fix/wrapper-resume-safe
Apr 10, 2026
Merged

fix(wrapper): make engine download crash-safe and resumable#55
5uck1ess merged 2 commits into
mainfrom
fix/wrapper-resume-safe

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Summary

The bin/devkit wrapper that bootstraps the engine binary on first run was not crash-safe. Users could end up in an infinite resume-from-corrupt-state loop whenever Claude Code's MCP launcher killed the wrapper mid-download — which is exactly what happens on lifecycle races at plugin update time and on slow connections under the MCP initialize timeout.

Root cause

/bin/sh's default SIGTERM handler exits the shell without propagating the signal to its curl child. When the wrapper was killed mid-download:

  1. The shell process exited.
  2. curl was orphaned and kept writing to the tmp path.
  3. The next wrapper invocation started its own curl on the same file.
  4. Two concurrent writers at different byte offsets corrupted the partial.
  5. Checksum failed → wrapper died → repeat forever.

The symptom looked like "first-run download timing out," but the real failure was concurrent writers on the same file.

Fix

Four changes in bin/devkit:

  1. Explicit child-PID tracking + INT/TERM trap. DL_PID + cleanup_downloader() + trap 'cleanup_downloader; exit 143' INT TERM. Kills the downloader by PID (not kill 0, which would nuke shared process groups in test harnesses and other invocation contexts).

  2. run_downloader helper backgrounds the downloader, parks its PID in DL_PID, waits, and clears DL_PID on return. Both download paths route through it so the trap always has a target.

  3. Persistent, resumable staging. Replaced mktemp -d + EXIT/INT/TERM rm trap with persistent files alongside the final engine path:

    • ${engine_name}.partial — downloaded with curl -fsSL -C - (or wget -c), resumes from current byte count on next invocation. Survives SIGTERM because nothing removes it.
    • devkit-checksums-v${VERSION}.txt — refetched fresh each run (tiny file, resume has no value).

    Split the single download() function into download_resume and download_fresh for clarity.

  4. Corrupt-partial recovery. On checksum mismatch the partial is deleted before die. Without this, a bad partial (disk corruption, bad resume offset, MITM) would cause an infinite resume-from-bad-bytes loop.

Also extended the stale-file cleanup to match partials and checksum files from old versions, while preserving the current version's in-progress files.

Test plan

Verified end-to-end against the real v2.1.2 GitHub release:

  • Clean first run: download → verify → install → MCP ready. Stale v2.0.0 artifacts cleaned. No leftover partials/checksums. SHA256 matches expected.
  • Killed mid-download → resumed: partial (~1.19 MB) survives SIGTERM, no orphaned curl process remains, second run logs "resuming download…" and completes via curl -C -, final SHA256 matches.
  • Corrupt partial seeded on disk: checksum mismatch triggers purge, wrapper exits non-zero (no infinite loop), next run would start fresh.
  • Stale old-version engines, partials, and checksums are removed when the current-version engine is missing.
  • shellcheck -s sh bin/devkit — clean.
  • sh -n bin/devkit — clean.

Net: +95 / -27. No engine code touched.

Root cause: the wrapper assumed /bin/sh would propagate SIGTERM to its
curl child. It doesn't. When Claude Code's MCP launcher killed the
wrapper mid-download (e.g. on startup timeout), curl was orphaned and
kept writing to the tmp path. The next wrapper invocation spawned its
own curl on the same file, the two writers clobbered each other, and
the partial failed checksum. Users on slow connections or those hit by
lifecycle races at plugin update time saw every restart fail the same
way — a permanent loop rather than a transient "try again" moment.

Fix:

- Track the downloader PID explicitly (DL_PID) and install an INT/TERM
  trap that kills it by PID before the wrapper exits. Avoids `kill 0`
  which would nuke shared process groups like test harnesses.
- Replace the mktemp tmp_dir + EXIT/INT/TERM rm trap with a persistent
  partial file (${engine_name}.partial) alongside the final engine
  path. Same filesystem so mv(2) is atomic, and nothing removes it on
  signal — so a subsequent run can resume via `curl -C -` instead of
  restarting from byte 0.
- Split download() into download_resume (curl -C - / wget -c) and
  download_fresh (for the tiny checksums file where resume offers no
  value). Both route through a run_downloader helper that backgrounds
  the process, parks its PID, waits, and clears DL_PID on return.
- On checksum mismatch, purge the partial so a bad resume offset can't
  loop forever on corrupt bytes.
- Stale cleanup now matches engines, partials, and checksum files for
  old versions, preserving all three current-version files.

Verified end-to-end against the real v2.1.2 GitHub release: clean
first run, killed-then-resumed run, and corrupt-partial recovery all
produce the expected SHA256 (or fail cleanly without looping).
Eight fixes on top of the initial crash-safe download work, covering
the blocker and major issues from the mega-pr review:

- **Blocker: `set -eu` + `wait` race in `run_downloader`.** A bare
  `wait "$DL_PID"` aborted the script on non-zero child exit before
  `rc=$?` could capture the code, silencing every `|| die "..."` in
  the call site. Switched to `wait "$DL_PID" || rc=$?` which is
  POSIX-exempt from `set -e` and propagates the real failure.

- **sha256 tool detection moved to startup.** The old `sha256_of`
  called `die` inside a `$(...)` substitution — which only exits the
  subshell, leaving `$actual=""`, which the checksum branch then
  misreported as a mismatch and deleted the (valid) partial. Detect
  the tool once at script top into `SHA256_CMD`, so `die` runs in the
  parent shell if neither `sha256sum` nor `shasum` is present.

- **Pre-complete partial fast path.** If a previous run finished the
  download but crashed between checksum verification and `mv`, the
  next run would ask curl to resume a complete file and get a 416
  error (false "download failed"). Now we fetch the checksums file
  first, and if the existing partial already matches the expected
  SHA256, we skip `download_resume` and install directly.

- **`chmod` / `mv` failures must be loud.** Under `set -e` these
  aborted silently, leaving a verified-but-unlinked partial on disk.
  Added explicit `|| die "..."` on each so the user sees why the
  install failed.

- **EXIT trap** for defense-in-depth. Catches orphaned downloaders if
  any future `die` fires mid-download before `cleanup_downloader`
  clears `DL_PID`. The happy-path `exec` replaces the shell so the
  EXIT trap never fires on success.

- **`cleanup_downloader`** now tolerates `kill`/`wait` failures with
  `|| true` so the trap itself cannot fall afoul of `set -e`.

- **`rm -f` cleanup paths** use `|| true` where a failure is
  inconsequential (best-effort removal of a just-consumed sums file
  or a corrupt partial we're already abandoning).

- **Comment cleanup.** Dropped PR-history narration ("(vs `kill 0`)",
  speculation about Claude Code's process group isolation, the "falls
  back automatically" claim about `curl -C -` which is not actually
  true when a server rejects Range). Rewrote the top-of-file block to
  describe the actual invariant rather than the old behaviour.

Verified end-to-end against the v2.1.2 release:
- Clean first run: installs correctly, SHA matches.
- Killed mid-download → second run resumes (or restarts fresh if the
  kill landed before any bytes hit disk), both paths converge to a
  matching SHA.
- Corrupt partial seeded: purged on mismatch, no loop.
- Pre-complete partial seeded (crash between verify and mv): fast
  path takes over, installs without a re-download, SHA matches.
- `shellcheck -s sh` and `dash -n` both clean.
@5uck1ess
5uck1ess merged commit 6b6690c into main Apr 10, 2026
4 checks passed
@5uck1ess
5uck1ess deleted the fix/wrapper-resume-safe branch April 10, 2026 15:29
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.

1 participant