perf(release): rank download sources by speed, resume transfers, and sign Linux archives#1754
Merged
Merged
Conversation
A slow link, not an unreachable one, was the failure mode. Both download paths only failed over on error, and both used a wall-clock ceiling as their sole give-up condition, so success required speed > size / ceiling: - Relay deploy used `--max-time 900` with no resume, and re-`rm`ed the partial file on every retry. A ~20MB archive needed >23 KB/s; below that each attempt timed out mid-transfer and restarted from zero, so it could never finish. With `--retry 3` and no `--retry-max-time` (curl resets --max-time per retry) that burned up to 60 minutes per source while showing no progress, because `-s` swallowed the output. - CLI self-update set reqwest's whole-request `.timeout(120)`, which includes the body, so a ~30MB archive needed >250 KB/s. Both sources shared the cap, so a slow link failed both rather than degrading. Now, on both paths: candidates are probed with a 10s ranged request and ranked by measured throughput (>=128 KB/s counts as healthy; if nothing clears the bar the fastest is still used, since a slow download beats a 20-minute source rebuild); transfers resume rather than restart, including across a source switch, with only a checksum mismatch discarding the partial; and a throughput floor abandons a dead link in 30s instead of waiting out a ceiling. Relay deploy also emits a progress heartbeat so a slow transfer is distinguishable from a hang. Sync-window fixes: - Relay deploy takes the mirror URL from the mirror's own manifest instead of a pinned /<version>/ path, which 404ed for every Desktop build outside the two releases the mirror retained. KEEP_VERSIONS 2 -> 6. - The mirror now publishes the four small Linux archives before the ~700MB of Desktop packages, so the window where it advertises a release it cannot serve is minutes rather than however long the Desktop mirror takes. - CLI self-update reads both manifests concurrently and installs whichever advertises the newer version, instead of silently reporting "up to date" when it fell back to a mirror that still had the previous release. - Optional post-publish webhook lets the mirror sync immediately; cron remains the source of truth and an unset secret keeps today's behaviour. Startup no longer downloads: the automatic check fetches only the manifest (10s budget) and hands the archive to a detached child, serialised by an update.lock. Removes the AUTO_UPDATE_BUDGET workaround. Also fixes two defects found by executing the generated script rather than parsing it: `kill` on a shell blocked in a long `sleep` is not delivered until that sleep returns, which stalled every download a full tick past completion; and an unreachable mirror manifest made a command substitution fail, which only avoided aborting the caller because `set -e` happens to be suspended inside an `if` condition. scripts/relay/release-download-harness.sh drives the generated bash against a stubbed curl; self_update.rs tests run against real throttled sockets.
Follow-up implementing every finding from the review of the two flows. Desktop updater had the same defect the CLI and relay just had, on the largest artifact. Tauri walks `endpoints`, stops at the first usable manifest, then downloads from the URL inside it. latest.json is ~2KB, so a reachable-but- crawling GitHub always won the race to answer and then pinned an 80-160MB download to itself; the mirror was reached only when GitHub errored outright. Endpoints are now ordered by probing the package each origin actually advertises (probing the manifest would measure latency, not throughput). Deliberately still routed through Update::download: minisign verification lives inside it, so hand-rolling the fetch and calling Update::install would silently skip signature checking. Supply chain: CLI and relay archives were verified only against a .sha256 from the same host that served them, which proves the transfer was intact but nothing about the source — and the CN relay path prefers a third-party GitHub proxy by design. Two changes: checksums are now fetched from the canonical GitHub URL whatever origin the bytes came from (derivable from any candidate, including the mirror's versioned path), and the four Linux archives are signed in CI with the same minisign key the Desktop updater trusts. Official CLI builds embed that public key and refuse an unsigned release; builds without it fall back to checksum-only, so forks still work. The signing step self-verifies before publishing, because a signature nobody checked is worse than none. One implementation, two callers: the ranked/resumable download plus runtime image moved to src/apps/relay-server/release-download.sh, sourced by deploy.sh and embedded by relay_deploy.rs the way mirror.sh already is. The manual path consequently stops compiling Rust on the user's VPS for ~20 minutes; a new --build-from-source restores the old behaviour. Also: preflight reports free disk under $HOME and on Docker's data root, and the source-build fallback refuses to start without ~6GB rather than dying as an opaque compiler error; CLI downloads stage to disk so a killed background installer resumes instead of restarting. Verified: the harness gained a scenario proving a mirror download is checked against GitHub's checksum (the stub gives every non-GitHub origin a wrong one); signature tests run against a fixture produced with the real minisign CLI, and the sign/verify invocation used in CI was validated locally against a generated key. Raised the CLI crate's recursion_limit — the added async layers tipped trait resolution over the default depth.
Three things, the first from a good catch about the timeouts. Removing the residual wall-clock cliff. --max-time 3600 was the original bug moved one order of magnitude out: at the 8 KB/s floor a 30 MB archive needs ~3750 s, so the cap would have killed a transfer that was progressing fine, and --retry-max-time 300 had already stopped allowing retries 55 minutes earlier. The throughput floor is the give-up condition — it aborts a dead or hung link within --speed-time, and --connect-timeout covers setup — so a ceiling on the transfer adds nothing but a cliff for slow users. Removed; the harness now asserts the archive fetch carries no --max-time while still requiring a floor (scoped to the resumable transfer, since the probe must stay time-boxed). Signing every published asset. Only the five updater artifacts were signed, by the Tauri bundler during `tauri build`. The nine installers people actually download by hand from the release page — dmg, deb, rpm, the Windows installer and the direct AppImages — shipped with no signature at all. All of them are now signed with the same key via a shared scripts/sign-release-assets.sh, which skips anything the bundler already signed so updater signatures stay intact, and self-verifies before publishing. minisign.pub ships with every release, because a signature nobody can fetch a key for is not verifiable. This is not OS-level code signing; Gatekeeper and SmartScreen still need Apple/Authenticode certs the project does not hold, and the docs say so. Verifying relay archives without minisign on the server. A relay host is an arbitrary user machine with no trust root, so it cannot check a signature — and the signature covers the archive, which only the server downloads. Resolved by signing the .sha256 files too: Desktop verifies that signature locally (a couple of hundred bytes), then exports the proven hash into the generated script as BITFUN_EXPECTED_SHA256_<TARGET>. The remote needs nothing but sha256sum, and no origin can override the hash. Falls back to the cross-origin checksum when the Desktop build carries no pubkey or GitHub is unreachable. The sign/verify mechanics were validated locally against a generated key, including the exact command the relay README now documents.
Core boundary check failed: services-integrations keeps every optional runtime dependency owned by an explicit integration feature, and one-click relay deploy started using reqwest to fetch the signed release checksum without registering that edge. Declared rather than worked around. The alternative was to move the fetch into the desktop crate (its only caller) and pass hashes into start_task, but that puts security-critical verification in an API-surface file, away from the contract it protects, and leaks the env-var naming out of the module that consumes it. aes-gcm and rand already list remote-ssh-concrete as an owner in this same rule, so this is the registration path the file establishes, not an exception invented for this change.
bobleer
force-pushed
the
bob/release-download-speed-ranking
branch
from
July 25, 2026 04:44
4c1e088 to
d5a6a59
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A slow link, not an unreachable one, was the failure mode. Every download path only failed over on error, and used a wall-clock ceiling as its sole give-up condition, so success required
speed > size / ceiling:--max-time 900with no resume, and re-rmed the partial file on every retry. A ~20 MB archive needed >23 KB/s; below that each attempt timed out mid-transfer and restarted from zero, so it could never finish. With--retry 3and no--retry-max-time(curl resets--max-timeper retry) that burned up to 60 minutes per source, silently —-sswallowed the output..timeout(120), which includes the body, so a ~30 MB archive needed >250 KB/s. Both sources shared the cap, so a slow link failed both rather than degrading.endpoints, stops at the first usable manifest, then downloads from the URL inside it.latest.jsonis ~2 KB, so a crawling-but-reachable GitHub always won the race to answer and pinned an 80–160 MB download to itself; the mirror was reached only when GitHub errored outright.Change
All three paths probe candidates with a ranged request and order them by measured throughput (≥128 KB/s counts as healthy; if nothing clears the bar the fastest is still used, since a slow download beats a 20-minute source rebuild). Relay and CLI additionally resume rather than restart — across a source switch, and for the CLI across runs.
There is deliberately no wall-clock ceiling on a transfer. Any ceiling reintroduces the original bug further out: at the 8 KB/s floor a 30 MB archive needs ~3750 s, so even a generous 3600 s cap would kill a download that was progressing fine. The throughput floor is the give-up condition — it aborts a dead or hung link within
--speed-time, and--connect-timeoutcovers setup. The harness asserts the archive fetch carries no--max-timewhile still requiring a floor.Desktop is still routed through
Update::download: minisign verification lives inside it, so hand-rolling the fetch and callingUpdate::installwould silently skip signature checking. Its probe targets the package each manifest advertises, not the manifest itself, which would measure latency rather than throughput.Signing
Before this PR only the five updater artifacts were signed, by the Tauri bundler during
tauri build. The nine installers people download by hand from the release page — dmg, deb, rpm, the Windows installer, the direct AppImages — had no signature at all. The CLI and relay archives had none either, only a.sha256fetched from the same host that served the archive, which proves the transfer was intact and nothing about the source. The CN relay path prefers a third-party GitHub proxy (ghfast.top) by design, so that mattered.scripts/sign-release-assets.sh. It skips anything the bundler already signed so updater signatures stay intact, and self-verifies before publishing — a signature nobody checked is worse than none.minisign.pubships with every release, because a signature nobody can fetch a key for is not verifiable..sha256files too: Desktop verifies that signature locally (a couple hundred bytes) and exports the proven hash into the deploy script asBITFUN_EXPECTED_SHA256_<TARGET>. The remote needs nothing butsha256sum, and no origin can override that hash..sha256comes from the canonical GitHub URL whatever origin served the bytes, derivable from any candidate URL including the mirror's versioned path. Same-origin fallback only when GitHub is wholly unreachable, and it says so.Not OS-level code signing — Gatekeeper and SmartScreen still need Apple/Authenticode certificates the project doesn't hold. Documented as such.
One implementation, two callers
The ranked/resumable download and runtime image moved to
src/apps/relay-server/release-download.sh, sourced bydeploy.shand embedded byrelay_deploy.rswithinclude_str!, exactly asmirror.shalready is. The documented manual path consequently stops compiling Rust on the user's VPS for ~20 minutes;--build-from-sourcerestores the old behaviour.Sync window
/<version>/path, which 404ed for every Desktop build outside the two releases the mirror retained.KEEP_VERSIONS2 → 6.Other robustness fixes
update.lock.$HOMEand on Docker's data root; the source-build fallback refuses to start without ~6 GB rather than dying as an opaque compiler error.+in stored asset filenames, which would have made every nightly URL inlinux-binaries.jsona 404.linux-binaries.ymlis the sole publisher of the Linux CLI. It andcli-package.ymlboth ran onrelease: publishedand uploaded identical asset names, andsoftprops/action-gh-releasedeletes a same-named asset before writing.Defects found by executing rather than reading
killon a shell blocked in a longsleepis not delivered until that sleep returns, which stalled every relay download a full tick past completion (test wall-clock 120 s → 14 s).set -ehappens to be suspended inside anifcondition.Verification
scripts/relay/release-download-harness.shdrives the generated bash against a stubbed curl — 9 checks: slow-GitHub-vs-fast-mirror, all-sources-slow, mid-transfer stall, checksum mismatch, unreachable mirror manifest, all-dead fallback, mirror bytes verified against GitHub's checksum (the stub gives every non-GitHub origin a wrong one), no wall-clock ceiling, floor present.self_update.rstests run against real throttled sockets (ranking, slow-but-alive completion, cross-source resume, cross-run staging).minisignCLI. The signing script, the CI invocation (password on stdin, Tauri's base64 wrapper, skip-already-signed) and the verification command the relay README documents were all validated locally against a generated key.cargo test: bitfun-services-integrations 57, bitfun-cli 10, bitfun-desktop 1.node --test11.check:github-config,check:repo-hygiene. clippy clean on every changed file.Notes for review
TAURI_UPDATER_PUBKEY,TAURI_SIGNING_PRIVATE_KEY,TAURI_SIGNING_PRIVATE_KEY_PASSWORD; wired at every call site, includingBITFUN_RELEASE_PUBKEYinto the Desktop and CLI builds as their trust root.size / floor, worth measuring against a real release to confirm the 128 KB/s bar. All tuning constants are named and colocated.recursion_limit = "256"— the added async layers tipped trait resolution over the default depth.