Skip to content

fix(android): segment-file concurrent downloader + harden kill→restart resume (EROFS/ENOSPC)#66

Merged
huhuanming merged 6 commits into
mainfrom
fix/android-apk-download-dir-erofs
Jun 12, 2026
Merged

fix(android): segment-file concurrent downloader + harden kill→restart resume (EROFS/ENOSPC)#66
huhuanming merged 6 commits into
mainfrom
fix/android-apk-download-dir-erofs

Conversation

@huhuanming

@huhuanming huhuanming commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Background

On near-full f2fs devices, the old concurrent downloader pre-allocated the whole file via RandomAccessFile(partial, "rw").setLength(total), which fails with EROFS/ENOSPC before a single byte is fetched, aborting the entire download. This branch moves the download to a segment-file model and systematically hardens kill → cold-start resume.

Core changes

Segment-file model (fixes EROFS/ENOSPC)

  • Each range streams into its own <partial>.segN (plain O_WRONLY append, no whole-file pre-allocation); once all segments are present they are concatenated in order into .partial, then renamed. Only sequential append + sequential read throughout; peak footprint ~1x + one segment.
  • APK download dir moved from cacheDir to filesDir (not system-reclaimable); the partial uses an absolute path (avoids CWD=/ resolving to the read-only root).

Resume hardening (kill → cold start)

  • Drop ETag-based object-identity pinning: integrity is backed by the final external SHA256 + GPG verify (APK verifyASC/verifyAPK, bundle verifyBundleSHA256). Resume is now unconditional — existing .segN are always reused, never wiped for a missing/changed ETag. A mid-flight object swap is caught by the final SHA256 → clean full re-download (a successful concat always deletes the .segN, so retries are clean and cannot loop).
  • committed-cursor concat: the .partial's current length is the committed cursor; after a kill mid-concat, prefix segments already merged into .partial are not re-downloaded even if their .segN was deleted, restoring the 1x + one-segment footprint.
  • Per-segment 206 validation: parse Content-Range, require start/end to exactly match the requested window and the written length to not exceed the segment length, rejecting mis-aligned/over-long 206 responses.
  • Retained: a 200 (full body) to a Range request → FallbackException, so a from-zero body is never appended onto a half-filled segment.

Consumer / wrapper cleanup

  • Every "untrustworthy → restart" branch (final hash mismatch, partial > expected, 416, 206 start mismatch, 200-on-resume rewrite, etc.) now uniformly wipes .partial + .segN; resume branches (RenameFailed / Range-resume / Deferred) deliberately keep the segments.
  • On a 206 start mismatch the bad slice body is no longer consumed — after cleanup it throws a retryable error.
  • wrapper cancel/discard now glob-deletes all .partial.seg* (supports a custom segmentCount); bundle gains the hasConcurrentSegments guard and the 206 start check; concurrent progress switched to AtomicInteger CAS; downloadASC/downloadAscFile Response wrapped in .use{} to fix leaks; magic number 8 extracted to a constant.

Verification

  • compileDebugKotlin BUILD SUCCESSFUL for all three modules.
  • Emulator test: concurrent downloads completed repeatedly (APK 228MB / bundle 66MB); killing the process mid-download and reopening resumed from the existing .segN (Phase-2 segment detection hit), final verify passed and the APK installed.

Scope

  • Android only; iOS unchanged.
  • Known follow-ups (post-release): iOS per-segment content validation, reconcile If-Range, segment manifest.

🤖 Generated with Claude Code

Replace module-local ConcurrentRangeDownloader with a single shared implementation and adapt consumers. Added a dependency on :onekeyfe_react-native-range-downloader in app-update's build.gradle and removed the duplicate ConcurrentRangeDownloader source from app-update. ReactNativeAppUpdate now imports the shared downloader and detects concurrent segment files when resuming; ReactNativeBundleUpdate cleans up legacy segment files and accounts for ".segN" in stem parsing. The range-downloader implementation was rewritten to stream into per-segment files (<partial>.segN), avoid full-file pre-allocation (fixes EROFS/ENOSPC on near-full devices), resume/concat segments into the final .partial, and adjust ETag/If-Range, cancellation and error handling accordingly.
Switch APK download location from context.cacheDir/apks to context.filesDir/apks to avoid system-reclaimable cache issues (EROFS/ENOSPC) during downloads. Rename getApkCacheDir to getApkDownloadDir, update callers and cleanup to match, and add a doc comment explaining the rationale and FileProvider exposure. Update res/xml/app_update_file_paths.xml to expose the new files-path (apks_files) while retaining the legacy cache-path for transition compatibility.
Pass partialFile.absolutePath to ConcurrentRangeDownloader.download instead of the filename (partialFilePath). The downloader resolves relative paths against the process CWD ("/"), which could cause segment files to be written to the read-only root filesystem (EROFS). Using the absolute path ensures segments go into the app's files dir (e.g. filesDir/apks), matching react-native-bundle-update behavior; added an inline comment explaining this.
Several fixes to make multi-segment downloads more robust and race-safe:

- Use OkHttp .use() to ensure responses are closed and avoid resource leaks.
- Treat segment files (<dest>.partial.segN) as first-class artifacts: sweep/delete sibling .segN on various error/restart paths in app-update and bundle-update so stale segment bytes can't be mistaken for valid resume data.
- Make progress reporting thread-safe by using AtomicInteger + CAS to emit monotonic progress only when it increases.
- Add defensive Content-Range handling: validate 206 Content-Range start matches expected resume offset (drop partial and restart if not), and in ConcurrentRangeDownloader verify per-segment Content-Range bounds and reject overlong segments.
- Change resume semantics: object identity is no longer pinned by ETag/If-Range. Resume is unconditional; the caller's whole-file SHA256/GPG verify remains the final correctness backstop. Removed ETag plumbing and If-Range logic.
- Correct progress accounting to include committed bytes already concatenated into .partial and avoid re-fetching segments whose extent is already covered by .partial.
- Improve segment selection: pending segments exclude segments already committed into .partial; keep incomplete segment files for later resume instead of wiping them.
- Add parseContentRangeBounds helper and related checks to ensure fetched slices exactly match the requested ranges.
- Add DEFAULT_SEGMENT_COUNT and sweep segmented artifacts in ReactNativeRangeDownloader discard/cancel paths.

These changes reduce corruption risk from misaligned 206s, stale sibling segments, and concurrent progress races, while keeping resume behavior predictable and recoverable via the final file verification.
Improve robustness of range/partial handling across native modules:

- AppUpdate & BundleUpdate: ensure leftover concurrent segment files (<partial>.segN) are removed when promoting/discarding partials, and treat 206 Content-Range start mismatches as retryable errors (close response, delete partial+segments, and throw) to avoid writing mis-aligned bytes. BundleUpdate adds a CONCURRENT_SEGMENT_COUNT constant and defers promotion if concurrent segments exist.
- RangeDownloader: replace hardcoded default segment sweep with sweepPartialArtifacts (glob by prefix) to clear any custom segment counts, and make progress reporting thread-safe using an AtomicInteger + CAS to dedupe/monotonize progress events.
- AppUpdate: read ASC files with response.use to always close connections, enforce a max ASC size, and avoid leaking response bodies.

These changes prevent mixing segmented and single-stream artifacts, avoid corrupted downloads from CDN/proxy 206 bugs, and fix leaked connections and duplicate progress events.
@huhuanming huhuanming merged commit 7b8edb9 into main Jun 12, 2026
3 of 4 checks passed

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

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