fix: stop direct/mirror download race from clobbering destination file#487
Conversation
WalkthroughBoth Android and Desktop downloader implementations now support concurrent downloads with atomic file writes. Download attempts write to temporary per-download files, validate non-empty results, and atomically move to final destinations. Concurrent tracking changed from single-ID per filename to per-name ID sets, and cancellation now terminates all active downloads for a given filename. ChangesConcurrent Download Support with Atomic Writes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt (1)
204-207: 💤 Low valueConsider: pre-download deletion creates a brief unavailability window.
saveToFiledeletes the existing file before starting the download, thendownload()writes to a temp file and atomically renames. This creates a window where the file doesn't exist at all. If another component checks for the file during download, it won't find it.If this is intentional (ensuring a completely fresh download), the current behavior is fine. If availability during re-download matters, consider letting the atomic rename handle replacement instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt` around lines 204 - 207, The current pre-download deletion in saveToFile (the file.delete() block) creates a brief absence window before download() writes to a temp file and atomically renames; remove that pre-delete and instead rely on the temp-file->atomic rename to replace the target (or, if you need explicit replacement, perform the final move with an atomic REPLACE_EXISTING/ATOMIC_MOVE operation) so the file remains available until the new file is ready; update saveToFile to stop calling file.delete() and ensure the final rename/move used by download() overwrites atomically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt`:
- Around line 204-207: The current pre-download deletion in saveToFile (the
file.delete() block) creates a brief absence window before download() writes to
a temp file and atomically renames; remove that pre-delete and instead rely on
the temp-file->atomic rename to replace the target (or, if you need explicit
replacement, perform the final move with an atomic REPLACE_EXISTING/ATOMIC_MOVE
operation) so the file remains available until the new file is ready; update
saveToFile to stop calling file.delete() and ensure the final rename/move used
by download() overwrites atomically.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 109ffaa3-3d46-49d9-bb23-eeb269bce748
📒 Files selected for processing (2)
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.ktcore/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt
Summary
Users with a custom download mirror configured were hitting
Error: file not ready after download: <path>on installs.MultiSourceDownloaderImplraces adirectdownload against amirrordownload, both callingDownloader.download(url, suggestedFileName)with the samesuggestedFileName. BothAndroidDownloaderandDesktopDownloaderresolved that name to the same destinationFileand wrote to it directly. Two consequences:containsKey+putguard inAndroidDownloaderlet both jobs proceed concurrently and write to the same path.catchblock randestination.delete()— which removed the winner's just-written file before the winner's post-writedestination.exists() && length() > 0check, throwing"File not ready after download".This change makes each attempt write to its own per-
downloadIdtemp file (<safeName>.part-<downloadId>), then atomic-rename to the final destination on success. Loser cleanup deletes only its own temp; the winner's destination is never touched by the other job.While here:
"already in progress"guard in both downloaders —MultiSourceDownloaderlegitimately runs two parallel attempts under the same logical name; the temp-file uniqueness now makes the guard unnecessary.downloadIds.cancelDownload(fileName)now cancels every active call for that name (it previously cancelled only one of the two race participants).cancelDownloadfrom blindly deleting the destination file — with atomic-rename, the destination only exists when a download has already succeeded; deleting it would wipe a perfectly valid prior download.contentLengthso future zero-byte responses are easier to diagnose.Test plan
"File not ready after download"; should now succeed.*.part-*) is removed and a prior successful download with the same filename is retained.Summary by CodeRabbit
Bug Fixes