diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index e51e7a86..cfe1d60e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -263,6 +263,12 @@ class DownloadService : Service() { private var latestCommittedProgressSnapshotSerial = 0L @Volatile private var nativeWorkerPaused = false @Volatile private var nativeWorkerCancelRequested = false + // Bumped every time a new native queue replaces the current one. A worker + // coroutine that observes a different generation than its own must stop + // without touching the snapshot or the service lifecycle: cancel() alone + // cannot interrupt the blocking gomobile call it may be sitting in, and + // the shared pause/cancel flags get reset for the new run. + @Volatile private var nativeWorkerGeneration = 0L override fun onCreate() { super.onCreate() @@ -473,17 +479,38 @@ class DownloadService : Service() { val requests = try { parseNativeDownloadRequests(requestsJson) } catch (e: Exception) { - writeNativeWorkerSnapshot( - isRunning = false, - isPaused = false, - currentItemId = "", - message = "Invalid native queue payload: ${e.message}", - settingsJson = settingsJson, - includeItems = true - ) - stopForegroundService(cancelNativeWorker = false) + if (nativeWorkerJob?.isActive != true) { + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Invalid native queue payload: ${e.message}", + settingsJson = settingsJson, + includeItems = true + ) + stopForegroundService(cancelNativeWorker = false) + } return } + // Abort the previous run's in-flight work before the shared flags are + // reset for the new run: the coroutine cancel below cannot interrupt a + // blocking gomobile download by itself. + synchronized(nativeWorkerItems) { + for (item in nativeWorkerItems) { + if (item.status == "preparing" || + item.status == "downloading" || + item.status == "finalizing" + ) { + try { + Gobackend.cancelDownload(item.itemId) + } catch (_: Exception) { + } + } + } + } + NativeDownloadFinalizer.cancelActiveWork() + nativeWorkerGeneration++ + val generation = nativeWorkerGeneration nativeWorkerJob?.cancel(CancellationException("Native queue replaced")) nativeWorkerPaused = false nativeWorkerCancelRequested = false @@ -536,7 +563,7 @@ class DownloadService : Service() { ) nativeWorkerJob = serviceScope.launch { - runNativeWorker(requests, settingsJson) + runNativeWorker(requests, settingsJson, generation) } } @@ -602,14 +629,21 @@ class DownloadService : Service() { } } - private suspend fun runNativeWorker(requests: List, settingsJson: String) { + private suspend fun runNativeWorker( + requests: List, + settingsJson: String, + generation: Long + ) { var completed = 0 var failed = 0 try { var requestIndex = 0 while (requestIndex < requests.size) { val request = requests[requestIndex] - while (nativeWorkerPaused && !nativeWorkerCancelRequested) { + while (nativeWorkerPaused && + !nativeWorkerCancelRequested && + generation == nativeWorkerGeneration + ) { writeNativeWorkerSnapshot( isRunning = true, isPaused = true, @@ -620,7 +654,7 @@ class DownloadService : Service() { ) delay(500) } - if (nativeWorkerCancelRequested) { + if (nativeWorkerCancelRequested || generation != nativeWorkerGeneration) { break } @@ -670,6 +704,11 @@ class DownloadService : Service() { } progressJob.cancel() progressJob = null + if (generation != nativeWorkerGeneration) { + // Superseded while blocked in the download call; the + // new run owns the shared state now. + break + } var result = JSONObject(response) if (result.optBoolean("success", false)) { currentStatus = "finalizing" @@ -695,6 +734,7 @@ class DownloadService : Service() { ) { nativeWorkerCancelRequested || nativeWorkerPaused || + generation != nativeWorkerGeneration || nativeWorkerJob?.isActive == false } } @@ -797,19 +837,21 @@ class DownloadService : Service() { } } } finally { - if (!nativeWorkerCancelRequested) { - flushNativeAlbumReplayGainJournalIfComplete() + if (generation == nativeWorkerGeneration) { + if (!nativeWorkerCancelRequested) { + flushNativeAlbumReplayGainJournalIfComplete() + } + currentStatus = "finalizing" + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished", + settingsJson = settingsJson, + includeItems = true + ) + stopForegroundService(cancelNativeWorker = false) } - currentStatus = "finalizing" - writeNativeWorkerSnapshot( - isRunning = false, - isPaused = false, - currentItemId = "", - message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished", - settingsJson = settingsJson, - includeItems = true - ) - stopForegroundService(cancelNativeWorker = false) } } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 6b5acd33..a5d94ef7 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3833,7 +3833,12 @@ class DownloadQueueNotifier extends Notifier { List restoredItems, ) async { final settings = ref.read(settingsProvider); - if (!_canUseAndroidNativeWorker(settings)) { + // Adoption deliberately skips _canUseAndroidNativeWorker: that gate reads + // extension state that loads asynchronously after startup, and toggles the + // user may have flipped mid-run. If a native batch is already running we + // must reconcile with it regardless, or the Dart queue would download the + // same items a second time alongside it. + if (!Platform.isAndroid) { return false; } @@ -3872,20 +3877,47 @@ class DownloadQueueNotifier extends Notifier { } final contexts = {}; + final pendingContextIds = {}; for (final item in restoredItems) { if (!snapshotIds.contains(item.id)) continue; final context = await _buildAndroidNativeWorkerRequest(item, settings); if (context != null) { contexts[item.id] = context; + } else { + // Extensions may still be loading this early in startup; remember the + // item and retry building its context on later reconcile polls. + pendingContextIds.add(item.id); } } - if (contexts.isEmpty) { + + final snapshotClaimsRunning = snapshot['is_running'] == true; + if (snapshotClaimsRunning && !await _isNativeWorkerServiceAlive()) { + // The service died mid-batch (process kill/reboot): the snapshot is + // frozen at is_running=true and nothing will ever update it. Reconcile + // what the worker managed to finish, requeue the rest, and let the + // caller fall back to the Dart queue. + _log.w( + 'Native worker snapshot claims running but the service is dead; reclaiming run $runId', + ); + if (contexts.isNotEmpty) { + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + {}, + settings, + ); + } + _requeueInFlightNativeWorkerItems(snapshotIds); + await _clearNativeWorkerRunId(runId); + return false; + } + if (contexts.isEmpty && pendingContextIds.isEmpty) { return false; } _log.i('Adopting Android native worker snapshot'); final reconciledIds = {}; - _totalQueuedAtStart = contexts.length; + _totalQueuedAtStart = contexts.length + pendingContextIds.length; _completedInSession = 0; _failedInSession = 0; state = state.copyWith( @@ -3903,6 +3935,7 @@ class DownloadQueueNotifier extends Notifier { unawaited( _continueAndroidNativeWorkerAdoption( contexts, + pendingContextIds, reconciledIds, settings, runId, @@ -3920,19 +3953,100 @@ class DownloadQueueNotifier extends Notifier { return true; } + Future _isNativeWorkerServiceAlive() async { + try { + return await PlatformBridge.isDownloadServiceRunning(); + } catch (_) { + return false; + } + } + + /// Puts items the dead native worker left mid-flight back into the queue so + /// the Dart queue can pick them up. + void _requeueInFlightNativeWorkerItems(Set itemIds) { + for (final id in itemIds) { + final current = _findItemById(id); + if (current == null) continue; + if (current.status == DownloadStatus.downloading || + current.status == DownloadStatus.finalizing) { + updateItemStatus(id, DownloadStatus.queued, progress: 0.0); + } + } + } + + Future _rebuildPendingNativeWorkerContexts( + Map contexts, + Set pendingContextIds, + AppSettings settings, + ) async { + if (pendingContextIds.isEmpty) return; + final resolved = {}; + for (final id in pendingContextIds) { + final item = _findItemById(id); + if (item == null) { + resolved.add(id); + continue; + } + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context != null) { + contexts[id] = context; + resolved.add(id); + } + } + pendingContextIds.removeAll(resolved); + } + Future _continueAndroidNativeWorkerAdoption( Map contexts, + Set pendingContextIds, Set reconciledIds, AppSettings settings, String runId, ) async { + var deadServicePolls = 0; try { while (true) { final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); - if (!_isNativeWorkerSnapshotForRun(snapshot, runId)) { + final matchesRun = _isNativeWorkerSnapshotForRun(snapshot, runId); + if (!matchesRun || snapshot['is_running'] == true) { + if (await _isNativeWorkerServiceAlive()) { + deadServicePolls = 0; + } else { + deadServicePolls++; + if (deadServicePolls >= 3) { + _log.w( + 'Native worker run $runId died without a final snapshot; reclaiming queue', + ); + if (matchesRun) { + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + } + _requeueInFlightNativeWorkerItems( + {...contexts.keys, ...pendingContextIds}, + ); + await _clearNativeWorkerRunId(runId); + if (state.items.any( + (item) => item.status == DownloadStatus.queued, + )) { + Future.microtask(() => _processQueue()); + } + return; + } + } + } + if (!matchesRun) { await Future.delayed(const Duration(seconds: 1)); continue; } + await _rebuildPendingNativeWorkerContexts( + contexts, + pendingContextIds, + settings, + ); await _applyAndroidNativeWorkerSnapshot( snapshot, contexts,