Preserve Epic game files when storage checks reject updates

This commit is contained in:
Juan Marti Mercado committed 2026-10-13 19:15:35 -04:00
1 parent 93cd54c93b
commit 8ff1f5dd20
3 files changed
+53 -8

No files matched your search

@@ -205,15 +205,11 @@ public final class EpicDownloadManager {
if (!chunkCacheDir.isDirectory() && !chunkCacheDir.mkdirs()) throw new InstallException("Could not create Epic chunk cache");
Map<String, Long> cachedChunkBytes = prepareBoundedCache(chunkCacheDir, needed);
long pendingBytes = 0;
long reclaimableBytes = 0;
for (FileInfo f : pending) {
pendingBytes += f.fileSize();
File output = new File(installDir, f.filename.replace("\\", "/"));
File parent = output.getParentFile();
if (parent != null) parent.mkdirs();
// This file failed the delta check, so it cannot be resumed as a valid install.
// Remove it before estimating/writing its replacement to avoid requiring space
// for both the corrupt file and its verified replacement.
if (output.isFile() && !output.delete() && output.exists()) throw new InstallException("Could not replace incomplete file " + output.getName());
if (output.isFile()) reclaimableBytes += output.length();
}
boolean cacheOnInstallVolume = cachePath.isEmpty();
long peakGameAdditional = cacheOnInstallVolume
@@ -221,13 +217,23 @@ public final class EpicDownloadManager {
: pendingBytes;
long peakCacheAdditional = cacheOnInstallVolume
? 0 : estimatePeakAdditionalBytes(pending, chunksByGuid, cachedChunkBytes, false);
peakGameAdditional = Math.max(0, peakGameAdditional - reclaimableBytes);
long freeGame = installDir.getUsableSpace();
if (cacheOnInstallVolume) {
if (freeGame > 0 && peakGameAdditional > freeGame) throw new InstallException("Not enough free space for the bounded Epic install: need up to " + fmt(peakGameAdditional) + " additional, only " + fmt(freeGame) + " free");
} else {
long freeCache = chunkCacheDir.getUsableSpace();
if (freeCache > 0 && peakCacheAdditional > freeCache) throw new InstallException("Not enough internal space for the bounded Epic download cache: need up to " + fmt(peakCacheAdditional) + " additional, only " + fmt(freeCache) + " free");
if (freeGame > 0 && pendingBytes > freeGame) throw new InstallException("Not enough free space: need " + fmt(pendingBytes) + ", only " + fmt(freeGame) + " free");
if (freeGame > 0 && peakGameAdditional > freeGame) throw new InstallException("Not enough free space: need " + fmt(peakGameAdditional) + ", only " + fmt(freeGame) + " free");
}
if (cancel.get()) return null;
// Reclaim invalid/old outputs only after both volume checks accept the update.
// The peak estimate assumes these bytes are released before replacement writes.
for (FileInfo f : pending) {
File output = new File(installDir, f.filename.replace("\\", "/"));
File parent = output.getParentFile();
if (parent != null) parent.mkdirs();
if (output.isFile() && !output.delete() && output.exists()) throw new InstallException("Could not replace incomplete file " + output.getName());
}
cb.onLog("epic: cache " + (cacheOnInstallVolume ? "beside the game" : "scratch") + ", peak game additional=" + fmt(peakGameAdditional) + (cacheOnInstallVolume ? "" : ", peak cache additional=" + fmt(peakCacheAdditional)) + " for " + fmt(pendingBytes) + " of files");
final long fTotalBytes = pendingBytes;
@@ -101,6 +101,43 @@ class EpicBoundedInstallTest {
assertEquals("Verified files must not be downloaded again", 1, requests.get())
}
private fun oversizedUpdate() {
val file = manifest.files[0]
val part = file.parts[0]
file.parts.clear()
repeat(100_000) {
file.parts.add(EpicDownloadManager.ChunkPart().apply {
guid = part.guid
size = Int.MAX_VALUE
})
}
}
@Test fun insufficientInstallSpacePreservesExistingFilesOnBothCacheLayouts() {
val oldGame = byteArrayOf(9, 8, 7)
val oldPart = byteArrayOf(6, 5, 4)
File(root, "game.bin").writeBytes(oldGame)
File(root, "game.bin.part").writeBytes(oldPart)
oversizedUpdate()
assertTrue(manifest.files[0].fileSize() > root.usableSpace)
for (cache in listOf("", temp.newFolder("scratch-rejected").path)) {
val error = assertThrows(EpicDownloadManager.InstallException::class.java) { install(cache = cache) }
assertTrue(error.message.orEmpty().contains("Not enough free space"))
assertArrayEquals(oldGame, File(root, "game.bin").readBytes())
assertArrayEquals(oldPart, File(root, "game.bin.part").readBytes())
}
assertEquals(0, requests.get())
}
@Test fun acceptedUpdateReplacesOutdatedFilesWithVerifiedOutputs() {
File(root, "game.bin").writeBytes(byteArrayOf(9, 8, 7))
File(root, "game.bin.part").writeBytes(byteArrayOf(6, 5, 4))
assertNotNull(install())
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
assertArrayEquals(bytes.copyOfRange(bytes.size / 2, bytes.size), File(root, "game.bin.part").readBytes())
}
@Test fun pauseKeepsCompletedFilesAndResumeReusesSharedCache() {
val cancel = AtomicBoolean()
val callback = object : EpicDownloadManager.Callback {
+3 -1
View File
@@ -51,7 +51,9 @@ Streaming stays in the Download stage until all files are written and verified,
Install for registration; it does not bounce between stages for each file.
The space check follows the manifest delta pass and simulates file writes, chunk fetches,
and evictions, rather than adding the entire cache to the entire game. Completed files and
and evictions, rather than adding the entire cache to the entire game. It accounts for space
reclaimable from outdated outputs without deleting them until both game and cache volume
checks pass. An insufficient-space rejection preserves existing game files. Completed files and
verified cached chunks are accounted for on resume. On a card, game and cache requirements
are checked separately. File SHA-1 hashes are checked where supplied by the manifest; failed
or cancelled output files are removed, and an interrupted process is repaired by the next