fix(download): recover queue after worker timeout (#524)

This commit is contained in:
zarzet
2026-08-11 17:23:29 +07:00
parent 1d363d5166
commit 95f2879110
6 changed files with 125 additions and 4 deletions
@@ -467,8 +467,58 @@ class DownloadService : Service() {
*/ */
override fun onTimeout(startId: Int, fgsType: Int) { override fun onTimeout(startId: Int, fgsType: Int) {
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.") android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
stopForegroundService() stopNativeWorkerForSystemTimeout()
}
private fun stopNativeWorkerForSystemTimeout() {
if (!hasNativeWorkerState()) {
stopForegroundService()
return
}
nativeWorkerCancelRequested = true
// Supersede the coroutine before cancelling it. Its catch/finally
// blocks must not publish a skipped/finished state over the recovery
// snapshot written below.
nativeWorkerGeneration++
val activeItemIds = synchronized(nativeWorkerItems) {
nativeWorkerItems
.filter { NativeWorkerPolicy.statusAfterWorkerStop(it.status) != it.status }
.map { it.itemId }
}
for (itemId in activeItemIds) {
try {
Gobackend.cancelDownload(itemId)
} catch (_: Exception) {
}
}
NativeDownloadFinalizer.cancelActiveWork()
nativeWorkerJob?.cancel(
CancellationException("Native queue stopped by Android timeout")
)
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
val recoveredStatus = NativeWorkerPolicy.statusAfterWorkerStop(item.status)
if (recoveredStatus == item.status) continue
item.status = recoveredStatus
item.progress = 0.0
item.bytesReceived = 0L
item.bytesTotal = 0L
item.error = ""
item.resultJson = null
}
}
nativeWorkerCurrentItemId = ""
writeNativeWorkerSnapshot(
isRunning = false,
isPaused = false,
currentItemId = "",
message = "Android background time limit reached",
includeItems = true,
)
// Cancellation and the final recovery snapshot were handled above.
// Only tear down the foreground-service resources here.
stopForegroundService(cancelNativeWorker = false)
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
@@ -981,7 +1031,9 @@ class DownloadService : Service() {
) )
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
if (nativeWorkerCancelRequested) { if (nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
updateNativeWorkerItem(request.itemId) { updateNativeWorkerItem(request.itemId) {
it.status = "skipped" it.status = "skipped"
it.error = "Cancelled" it.error = "Cancelled"
@@ -68,4 +68,9 @@ internal object NativeWorkerPolicy {
completed: Int, completed: Int,
failed: Int, failed: Int,
): Boolean = !cancelRequested && completed + failed > 0 ): Boolean = !cancelRequested && completed + failed > 0
fun statusAfterWorkerStop(status: String): String = when (status) {
"preparing", "downloading", "finalizing" -> "queued"
else -> status
}
} }
@@ -149,4 +149,15 @@ class NativeWorkerPolicyTest {
), ),
) )
} }
@Test
fun stoppedWorkerRequeuesOnlyInFlightItems() {
listOf("preparing", "downloading", "finalizing").forEach { status ->
assertEquals("queued", NativeWorkerPolicy.statusAfterWorkerStop(status))
}
assertEquals("queued", NativeWorkerPolicy.statusAfterWorkerStop("queued"))
assertEquals("completed", NativeWorkerPolicy.statusAfterWorkerStop("completed"))
assertEquals("failed", NativeWorkerPolicy.statusAfterWorkerStop("failed"))
assertEquals("skipped", NativeWorkerPolicy.statusAfterWorkerStop("skipped"))
}
} }
@@ -75,6 +75,17 @@ bool canStartForegroundDownloadForLifecycle(AppLifecycleState? lifecycleState) {
return lifecycleState == AppLifecycleState.resumed; return lifecycleState == AppLifecycleState.resumed;
} }
String nativeWorkerStatusAfterSnapshotStop({
required bool workerRunning,
required String status,
}) {
if (workerRunning) return status;
return switch (status) {
'preparing' || 'downloading' || 'finalizing' => 'queued',
_ => status,
};
}
/// Keeps a download in its finalizing state until its durable Library record /// Keeps a download in its finalizing state until its durable Library record
/// has been written. If persistence fails, completion is deliberately not /// has been written. If persistence fails, completion is deliberately not
/// published so the queue can surface the error instead of losing the file /// published so the queue can surface the error instead of losing the file
@@ -863,6 +863,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (itemSnapshots.isEmpty) { if (itemSnapshots.isEmpty) {
return; return;
} }
final workerRunning = snapshot['is_running'] == true;
for (final itemSnapshot in itemSnapshots) { for (final itemSnapshot in itemSnapshots) {
final itemId = itemSnapshot['item_id']?.toString() ?? ''; final itemId = itemSnapshot['item_id']?.toString() ?? '';
@@ -872,7 +873,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
final context = contexts[itemId]; final context = contexts[itemId];
if (context == null) continue; if (context == null) continue;
final status = itemSnapshot['status']?.toString() ?? 'queued'; final status = nativeWorkerStatusAfterSnapshotStop(
workerRunning: workerRunning,
status: itemSnapshot['status']?.toString() ?? 'queued',
);
final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0) final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0)
.clamp(0.0, 1.0) .clamp(0.0, 1.0)
.toDouble(); .toDouble();
+38
View File
@@ -51,4 +51,42 @@ void main() {
); );
}); });
}); });
group('native worker stop recovery', () {
test('requeues every in-flight snapshot state after the worker stops', () {
for (final status in const ['preparing', 'downloading', 'finalizing']) {
expect(
nativeWorkerStatusAfterSnapshotStop(
workerRunning: false,
status: status,
),
'queued',
);
}
});
test('preserves terminal states and live worker progress', () {
expect(
nativeWorkerStatusAfterSnapshotStop(
workerRunning: false,
status: 'completed',
),
'completed',
);
expect(
nativeWorkerStatusAfterSnapshotStop(
workerRunning: false,
status: 'failed',
),
'failed',
);
expect(
nativeWorkerStatusAfterSnapshotStop(
workerRunning: true,
status: 'downloading',
),
'downloading',
);
});
});
} }