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