fix: clean up orphaned and partial files after cancelled downloads

Treat inactive download contexts as cancellation, delete the partial file, and
rethrow the cancellation so the pipeline does not retain an incomplete payload.
Also remove orphaned .dsl/.dz files left behind by cancelled downloads.
Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from
already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before
executing the block and silently leaves the files behind.
This commit is contained in:
OneWay authored and Evgenii committed 2026-09-14 07:50:30 +08:00
1 parent 8319973161
commit edf6a6074e
4 files changed
+67 -9

No files matched your search

@@ -61,7 +61,7 @@ class DictionaryPipelineCoordinator(
private fun runPipeline(isImport: Boolean) {
pipelineJob?.cancel()
pipelineJob = scope.launch(launchDispatcher) {
val dir = if (isImport) File(dictionaryPath()) else null
val dir = File(dictionaryPath())
val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(isImport)
@@ -83,6 +83,7 @@ class DictionaryPipelineCoordinator(
}
cleanupNewFiles(dir, filesBeforeReindex)
}
cleanupOrphanedFiles(dir)
throw e
} catch (e: Exception) {
ui.showError()
@@ -115,6 +116,25 @@ class DictionaryPipelineCoordinator(
}
}
private fun cleanupOrphanedFiles(dir: File?) {
try {
dir?.listFiles()?.forEach { file ->
val n = file.name.lowercase()
val isRawDsl = n.endsWith(".dsl") && !n.endsWith(".dsl.dz") && !n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(".dsl.dz")
val orphaned = when {
isBareDz -> true
isRawDsl -> !File(file.parent, file.name + ".dz").exists()
else -> false
}
if (orphaned) {
try { file.delete() } catch (_: Exception) {}
}
}
} catch (_: Exception) {
}
}
private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set<String>) {
try {
dir?.listFiles()?.forEach { file ->
@@ -390,12 +390,15 @@ class LocalDictionaryRepository(
} ?: emptyList()
}
private fun isDictionaryPayloadFile(file: File): Boolean =
file.isFile && (
file.name.endsWith(".dsl") ||
file.name.endsWith(".dsl.dz") ||
file.name.endsWith(".dsl.gz")
)
private fun isDictionaryPayloadFile(file: File): Boolean {
if (!file.isFile) return false
val n = file.name.lowercase()
if (n.endsWith(".dsl.dz") || n.endsWith(".dsl.gz")) return true
if (n.endsWith(".dsl")) {
return File(file.parent, file.name + ".dz").exists()
}
return false
}
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
@@ -15,8 +15,11 @@ import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
@@ -58,6 +61,7 @@ class DictionaryDownloader(
) {
if (fileUri == null) return
withContext(NonCancellable) {
runCatching {
val fileToDelete = fileStorageManager.getDocumentFile(fileUri)
if (fileToDelete != null && fileToDelete.exists()) {
@@ -75,6 +79,7 @@ class DictionaryDownloader(
}
}
}
}
suspend fun downloadDictionaries(
folderUri: Uri,
sources: List<DictionarySource>,
@@ -169,7 +174,7 @@ class DictionaryDownloader(
}
private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) {
withContext(Dispatchers.IO) {
withContext(Dispatchers.IO + NonCancellable) {
runCatching {
val existingFile = fileStorageManager.findFile(folderUri, fileName)
if (existingFile != null && existingFile.exists()) {
@@ -313,7 +318,7 @@ class DictionaryDownloader(
call?.cancel()
response?.close()
outputStream?.close()
if (e is CancellationException) {
if (e is CancellationException || !currentCoroutineContext().isActive) {
deletePartialFileIfExists(folderUri, fileName)
deletePartialFileSafely(fileUri, folderUri, fileName)
throw e
@@ -9,7 +9,9 @@ import com.example.research.common.util.FileStorageManager
import com.example.research.data.dictzip.DictZipRandomAccessFile
import com.example.research.data.dictzip.DictZipWriter
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class DictionaryExtractor(
@@ -138,11 +140,39 @@ class DictionaryExtractor(
onProgress?.invoke(1f)
Result.success(Unit)
} catch (e: CancellationException) {
deleteOrphanedFiles(folderUri)
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun deleteOrphanedFiles(folderUri: Uri) {
try {
withContext(Dispatchers.IO + NonCancellable) {
val files = fileStorageManager.listFilesInFolder(folderUri)
for (file in files) {
val n = file.name.lowercase()
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
when {
isBareDz -> ArchiveUtils.deleteFile(context, file.uri)
isRawDsl -> {
val pair = fileStorageManager.findFile(folderUri, file.name + ".dz")
if (pair == null || !pair.exists()) {
ArchiveUtils.deleteFile(context, file.uri)
}
}
}
}
}
} catch (_: Exception) {
}
}
private suspend fun ensureDslDictZip(
dslUri: Uri,
dslFileName: String,