Skip to content

fix(update): verify download completeness and retry truncated downloads - #275

Merged
maui1911 merged 2 commits into
mainfrom
fix/update-truncated-download
Jun 12, 2026
Merged

fix(update): verify download completeness and retry truncated downloads#275
maui1911 merged 2 commits into
mainfrom
fix/update-truncated-download

Conversation

@maui1911

Copy link
Copy Markdown
Owner

Symptom

Updating from the in-app toast failed for a user (twice) with:

Update failed — Extract failed: extract archive: ZipError: invalid Zip archive: Could not find EOCD

Root cause

EOCD (End Of Central Directory) is the trailer at the end of every valid zip. "Could not find EOCD" means the archive on disk was truncated — the download never finished, but the failure only surfaced two stages later at extract time.

The installer's download loop broke on the first Read::readOk(0) (EOF) and then flushed + extracted without ever checking received == Content-Length. A TLS-inspecting proxy / antivirus middlebox cutting a large HTTPS download short ends the body with a clean EOF, so a short zip was silently accepted.

Verified the published v0.5.0 artifact is fine — full download (200, application/octet-stream, all 5,717,213 bytes, valid PK\x03\x04 header) extracts cleanly with the exact same code + dep pins. So this is a downloader-robustness bug, not a release or extraction-code bug.

Fix

  • verify_complete(received, total) — pure, unit-tested truncation gate (None total = no Content-Length → can't verify → accept).
  • download_once — one attempt that truncates the target file, streams with byte-progress, then verifies the full length arrived.
  • download_archive — up to DOWNLOAD_ATTEMPTS (3) attempts with linear backoff, since middlebox truncation is typically transient.

A short download now fails as an honest "Download incomplete: received N of M bytes (connection truncated — check a VPN/proxy or antivirus that inspects HTTPS, then retry)" and self-heals on retry.

Tests

4 new unit tests on verify_complete (full / truncated / unknown-total / over-read). cargo test -p codescope green; clippy clean on the changed file.

Note on validation gap

RELEASE-VALIDATION §6 serves the archive from localhost, which never truncates — so it proves extraction works but structurally cannot catch a truncating network. This fix makes that failure mode honest + self-healing regardless.

🤖 Generated with Claude Code

The installer streamed the release archive and broke its read loop on
the first EOF without ever checking received == Content-Length. A
TLS-inspecting proxy / antivirus middlebox cutting a large HTTPS
download short ends the body with a clean EOF, so a truncated zip was
silently accepted and handed to extraction, which failed with the
baffling 'ZipError: invalid Zip archive: Could not find EOCD'.

- verify_complete(received, total): pure, unit-tested truncation gate
- download_once: one attempt that truncates the file, streams, and
  verifies the full Content-Length arrived
- download_archive: up to DOWNLOAD_ATTEMPTS (3) attempts with linear
  backoff, since middlebox truncation is typically transient

A short download now fails as an honest 'Download incomplete: received
N of M bytes' (with a hint to check VPN/proxy/AV) and self-heals on
retry, instead of surfacing later as an unexplained extract error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 12, 2026 07:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the in-app updater’s robustness by ensuring downloaded update archives are complete before extraction, preventing truncated downloads from surfacing later as confusing zip extraction failures.

Changes:

  • Added a verify_complete(received, total) gate to detect truncated (or otherwise size-mismatched) downloads when Content-Length is known.
  • Refactored archive download into download_once and wrapped it with download_archive retries + linear backoff.
  • Added unit tests covering the download completeness rules.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/update.rs
Address Copilot review: received > total is not a truncation, so it
no longer borrows the 'incomplete/connection truncated' wording —
it reports an honest 'Download size mismatch' instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@maui1911
maui1911 merged commit 10b57b5 into main Jun 12, 2026
1 check passed
@maui1911
maui1911 deleted the fix/update-truncated-download branch June 12, 2026 07:31
maui1911 added a commit that referenced this pull request Jun 12, 2026
* test(release): add truncated-download resilience check (§6b)

§6 serves the update archive from localhost, which never truncates —
it proves extraction works but cannot catch a download cut short on a
real network (the v0.5.0 'Could not find EOCD' field failure, fixed in
#275). Close that validation gap:

- dist/truncating_server.py: serves a file but advertises the full
  Content-Length while cutting the body short on the first N requests,
  then serves it in full — a stand-in for a TLS-inspecting proxy
  clipping a large HTTPS download.
- RELEASE-VALIDATION §6b: two runs — truncate-first 2 (the install
  self-heals on attempt 3) and truncate-first 999 (ends on the honest
  'Download incomplete', not EOCD), with the server's attempt count as
  the proof the verify+retry path ran.

Verified end-to-end against the real updater with the driver build:
2 truncated + 1 full -> 'Update installed'; always-truncate -> exactly
3 attempts then give up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(release): make §6/§6b staging self-contained, drop dangling slow_server.py

Address Copilot review: §6b no longer inherits §6's reference to the
missing dist/slow_server.py. §6b spells out the build + Compress-Archive
staging, and §6 now serves via the in-repo truncating_server.py
(--truncate-first 0 = serve full) instead of the retired script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(release): fix §6b staleness + sign-off numbering

Address Copilot round 2: drop the now-stale 'do not use §6's
slow_server.py' warning (§6 no longer has that step), and fix the
sign-off range to '1-6b' since 6b sits outside 1-6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(release): make byte-progress tick optional in §6 checklist

Address Copilot round 3: §6 now recommends a plain localhost server,
so the checklist item no longer hard-requires a 'visibly advancing'
byte count (that only happens with a throttled server) — it accepts an
instant jump to total. The extraction check remains the load-bearing
assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
maui1911 added a commit that referenced this pull request Jun 12, 2026
Patch release: in-app updater now verifies download completeness and
retries truncated downloads (#275), so a TLS-inspecting proxy / AV
middlebox clipping the archive no longer surfaces as a baffling
'Could not find EOCD' extract failure. Adds the truncated-download
resilience check to RELEASE-VALIDATION §6b (#276).

Tag v0.5.1 after this merges to trigger the release pipeline.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants