diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 2316861..fc5912b 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -74,6 +74,10 @@ final class AndroidPackageManager extends PackageManager { int _syncGeneration = 0; StreamSubscription? _eventSubscription; + /// Tracks appIds where we've already attempted to abort orphaned sessions. + /// Prevents spamming abort calls when native keeps sending events. + final Set _abortedOrphans = {}; + @override String get platform => 'android-arm64-v8a'; @@ -87,13 +91,40 @@ final class AndroidPackageManager extends PackageManager { // EVENT STREAM HANDLING // ═══════════════════════════════════════════════════════════════════════════ + /// Whether we're currently attempting to reconnect the event stream + bool _isReconnecting = false; + void _setupEventStream() { + _eventSubscription?.cancel(); _eventSubscription = _eventChannel.receiveBroadcastStream().listen( _handleInstallEvent, - onError: (_) {}, // Events will resume when stream reconnects + onError: (e) { + debugPrint('[PackageManager] EventChannel error: $e'); + _attemptEventStreamReconnect(); + }, + onDone: () { + debugPrint('[PackageManager] EventChannel closed unexpectedly'); + _attemptEventStreamReconnect(); + }, ); } + /// Attempt to reconnect the event stream after a failure. + /// Uses exponential backoff to avoid hammering the system. + void _attemptEventStreamReconnect() { + if (_isReconnecting) return; + _isReconnecting = true; + + // Reconnect after a short delay + Future.delayed(const Duration(seconds: 2), () { + _isReconnecting = false; + if (mounted) { + debugPrint('[PackageManager] Attempting EventChannel reconnect'); + _setupEventStream(); + } + }); + } + /// Handle install status events from native side. /// Events arrive sequentially (Dart is single-threaded), no lock needed. void _handleInstallEvent(dynamic event) { @@ -110,10 +141,6 @@ final class AndroidPackageManager extends PackageManager { final progress = event['progress'] as double?; final status = InstallStatusX.tryParse(statusRaw); - debugPrint( - '[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode, desc=$description', - ); - if (appId == null || statusRaw == null) { debugPrint('[PackageManager] Ignoring event with null appId or status'); return; @@ -129,18 +156,32 @@ final class AndroidPackageManager extends PackageManager { // Get target from existing operation state - no separate tracking needed final existingOp = getOperation(appId); if (existingOp == null) { - debugPrint( - '[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event', - ); - debugPrint( - '[PackageManager] Current operations: ${state.operations.keys.toList()}', - ); + // INVARIANT: No hanging states (FEAT-001). + // Orphaned native sessions can occur when the app is killed during install. + // Abort the native session to clean up and allow user to retry from clean state. + // Only attempt abort once per appId to prevent spam when native keeps sending events. + if (_abortedOrphans.add(appId)) { + debugPrint( + '[PackageManager] No tracked operation for appId=$appId, aborting orphaned native session', + ); + unawaited(abortInstall(appId)); + } return; } - debugPrint( - '[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})', - ); + // INVARIANT: Don't process terminal events for already terminal operations. + // This prevents stale or duplicate events from corrupting state. + // For example, a stale 'cancelled' event arriving after 'success' would + // clear the Completed operation (since Completed has no filePath). + if (existingOp.isTerminal && + (status == InstallStatus.success || + status == InstallStatus.failed || + status == InstallStatus.cancelled)) { + debugPrint( + '[PackageManager] Ignoring terminal event $status for already terminal operation ${existingOp.runtimeType}', + ); + return; + } final target = existingOp.target; final filePath = existingOp.filePath; @@ -225,9 +266,8 @@ final class AndroidPackageManager extends PackageManager { // Sync in background to get accurate info (signature hash, etc.) // but our state machine doesn't depend on it. unawaited(syncInstalledPackages()); - // Advance to next queued install and check if batch is complete - _onInstallComplete(appId); - _checkBatchComplete(); + // Advance to next queued install + clearInstallSlot(appId); break; case InstallStatus.failed: @@ -242,7 +282,7 @@ final class AndroidPackageManager extends PackageManager { ), ); // Advance to next queued install - _onInstallComplete(appId); + clearInstallSlot(appId); break; case InstallStatus.cancelled: @@ -255,58 +295,25 @@ final class AndroidPackageManager extends PackageManager { clearOperation(appId); } // Advance to next queued install - _onInstallComplete(appId); + clearInstallSlot(appId); break; } } + @override + void setOperation(String appId, InstallOperation op) { + // Clear from aborted orphans when a new operation is set, + // so we can abort again if it becomes orphaned in a future session. + _abortedOrphans.remove(appId); + super.setOperation(appId, op); + } + @override void onInstallReady(String appId) { // Use base class queue processing super.onInstallReady(appId); } - /// Clear active install tracking and advance queue. - void _onInstallComplete(String appId) { - if (activeInstall == appId) { - activeInstall = null; - } - installQueue.remove(appId); - scheduleProcessQueue(); - } - - /// Check if all operations are complete (terminal states). - /// If so, schedule clearing of completed operations after a delay. - void _checkBatchComplete() { - final ops = state.operations.values; - if (ops.isEmpty) return; - - // Check if any operation is still in progress - final hasInProgress = ops.any((op) => op.isInProgress); - if (hasInProgress) return; - - // All operations are terminal (Completed or Failed) - // Wait a moment to show "X of X updated", then clear completed ones - Future.delayed(const Duration(seconds: 3), _clearCompletedOperations); - } - - /// Clear all Completed operations from the map. - /// Failed operations stay so user can see/dismiss them. - void _clearCompletedOperations() { - final completedIds = state.operations.entries - .where((e) => e.value is Completed) - .map((e) => e.key) - .toList(); - - if (completedIds.isEmpty) return; - - final newOps = Map.from(state.operations); - for (final id in completedIds) { - newOps.remove(id); - } - state = state.copyWith(operations: newOps); - } - /// Convert native error code to FailureType. /// Uses structured error code when available, falls back to message parsing. FailureType _errorCodeToFailureType(String? errorCode, String? message) { @@ -441,7 +448,9 @@ final class AndroidPackageManager extends PackageManager { filePath: filePath, ), ); - // Don't auto-advance after failure + // IMPORTANT: Clear activeInstall and advance queue on fail-to-start. + // Otherwise the install queue is stuck indefinitely (hanging state). + clearInstallSlot(appId); } // If started, wait for events via EventChannel } catch (e) { @@ -454,7 +463,9 @@ final class AndroidPackageManager extends PackageManager { filePath: filePath, ), ); - // Don't auto-advance after failure + // IMPORTANT: Clear activeInstall and advance queue on exception. + // Otherwise the install queue is stuck indefinitely (hanging state). + clearInstallSlot(appId); } } @@ -680,9 +691,7 @@ final class AndroidPackageManager extends PackageManager { for (final appId in packages.keys) { final op = getOperation(appId); if (op == null) continue; - if (op is! Installing && - op is! Verifying && - op is! InstallCancelled) { + if (op is! Installing && op is! Verifying && op is! InstallCancelled) { continue; } diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 6ad2768..5e09527 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -6,6 +6,12 @@ const staleOperationThreshold = Duration(days: 7); /// Delay between queueing operations to prevent UI flood const batchQueueDelayMs = 50; +/// Dart-side watchdog timeout (fallback if native events stop arriving) +const watchdogTimeout = Duration(minutes: 5); + +/// How often the watchdog timer checks for stale operations +const watchdogCheckInterval = Duration(seconds: 30); + // ═══════════════════════════════════════════════════════════════════════════════ // INSTALL OPERATION STATE MACHINE // ═══════════════════════════════════════════════════════════════════════════════ @@ -70,18 +76,21 @@ class DownloadPaused extends InstallOperation { class Verifying extends InstallOperation { final String filePath; final double progress; + final DateTime startedAt; - const Verifying({ + Verifying({ required super.target, required this.filePath, this.progress = 0.0, - }); + DateTime? startedAt, + }) : startedAt = startedAt ?? DateTime.now(); Verifying copyWith({double? progress}) { return Verifying( target: target, filePath: filePath, progress: progress ?? this.progress, + startedAt: startedAt, ); } } @@ -142,8 +151,13 @@ class Uninstalling extends InstallOperation { /// and is taking longer than expected. The system will eventually complete or fail. class SystemProcessing extends InstallOperation { final String filePath; + final DateTime startedAt; - const SystemProcessing({required super.target, required this.filePath}); + SystemProcessing({ + required super.target, + required this.filePath, + DateTime? startedAt, + }) : startedAt = startedAt ?? DateTime.now(); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -156,7 +170,7 @@ class Completed extends InstallOperation { final DateTime completedAt; Completed({required super.target, DateTime? completedAt}) - : completedAt = completedAt ?? DateTime.now(); + : completedAt = completedAt ?? DateTime.now(); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -226,6 +240,10 @@ extension InstallOperationX on InstallOperation { this is SystemProcessing || this is Uninstalling; + /// Whether this operation needs watchdog monitoring (waiting for native events) + bool get needsWatchdog => + this is Verifying || this is Installing || this is SystemProcessing; + /// Whether this operation is in the verification phase bool get isVerifying => this is Verifying; @@ -235,6 +253,14 @@ extension InstallOperationX on InstallOperation { /// Whether this operation is still in progress (not terminal) bool get isInProgress => !isTerminal; + /// Get start time for watchdog-monitored operations + DateTime? get startedAt => switch (this) { + Verifying(:final startedAt) => startedAt, + Installing(:final startedAt) => startedAt, + SystemProcessing(:final startedAt) => startedAt, + _ => null, + }; + /// Get file path if available String? get filePath => switch (this) { Verifying(:final filePath) => filePath, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index bd65f52..f86d720 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -108,6 +108,9 @@ abstract class PackageManager extends StateNotifier { late final FileDownloader _downloader; late final Future _downloaderInit; + /// Watchdog timer for detecting stale operations (Dart-side fallback) + Timer? _watchdogTimer; + // ═══════════════════════════════════════════════════════════════════════════ // EXPLICIT QUEUE TRACKING // Queues are the source of truth for order; operations map is for UI state. @@ -164,36 +167,82 @@ abstract class PackageManager extends StateNotifier { ], androidConfig: [(Config.useCacheDir, false)], ); - } catch (_) {} + } catch (e) { + debugPrint('FileDownloader configure failed: $e'); + } - _downloader.configureNotificationForGroup( - FileDownloader.defaultGroup, - running: const TaskNotification( - 'Downloading {displayName}', - '{progress}', - ), - // No 'complete' notification - download completion immediately triggers install - // so showing "Download complete" is redundant and creates notification clutter - complete: null, - error: const TaskNotification('Download failed', '{displayName}'), - paused: const TaskNotification('Download paused', '{displayName}'), - progressBar: true, - ); + // Note: We don't call configureNotificationForGroup because we handle + // our own UI for download progress. The package defaults to no notifications. - _downloader.registerCallbacks( - taskStatusCallback: _handleDownloadUpdate, - taskProgressCallback: _handleDownloadUpdate, - ); + try { + _downloader.registerCallbacks( + taskStatusCallback: _handleDownloadUpdate, + taskProgressCallback: _handleDownloadUpdate, + ); + } catch (e) { + debugPrint('FileDownloader registerCallbacks failed: $e'); + } await _restoreOperations(); } @override void dispose() { + _watchdogTimer?.cancel(); _downloader.unregisterCallbacks(); super.dispose(); } + // ═══════════════════════════════════════════════════════════════════════════ + // WATCHDOG TIMER (Dart-side fallback for stale operations) + // ═══════════════════════════════════════════════════════════════════════════ + + /// Start or stop watchdog timer based on whether there are operations to monitor. + void _updateWatchdogTimer() { + final needsWatchdog = state.operations.values.any((op) => op.needsWatchdog); + + if (needsWatchdog && _watchdogTimer == null) { + _watchdogTimer = Timer.periodic(watchdogCheckInterval, (_) { + _checkForStaleOperations(); + }); + } else if (!needsWatchdog && _watchdogTimer != null) { + _watchdogTimer?.cancel(); + _watchdogTimer = null; + } + } + + /// Check for operations stuck too long (Dart-side fallback if native events stop). + void _checkForStaleOperations() { + final now = DateTime.now(); + + for (final entry in state.operations.entries) { + final appId = entry.key; + final op = entry.value; + final startedAt = op.startedAt; + + if (startedAt == null || now.difference(startedAt) <= watchdogTimeout) { + continue; + } + + debugPrint( + '[PackageManager] Watchdog: $appId stuck in ${op.runtimeType}, ' + 'transitioning to error', + ); + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.installFailed, + message: 'Operation timed out (no response from system)', + filePath: op.filePath, + ), + ); + if (op is Installing || op is SystemProcessing) { + clearInstallSlot(appId); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ // QUERIES // ═══════════════════════════════════════════════════════════════════════════ @@ -225,12 +274,23 @@ abstract class PackageManager extends StateNotifier { void setOperation(String appId, InstallOperation op) { state = state.copyWith(operations: {...state.operations, appId: op}); + _updateWatchdogTimer(); } void clearOperation(String appId) { state = state.copyWith( operations: Map.from(state.operations)..remove(appId), ); + _updateWatchdogTimer(); + } + + /// Clear all completed and failed operations from the map. + /// Called after the batch completion display timeout. + void clearCompletedOperations() { + final remaining = Map.of(state.operations) + ..removeWhere((_, op) => op is Completed || op is OperationFailed); + state = state.copyWith(operations: remaining); + _updateWatchdogTimer(); } // ═══════════════════════════════════════════════════════════════════════════ @@ -326,7 +386,10 @@ abstract class PackageManager extends StateNotifier { if (task is DownloadTask) { await _downloader.pause(task); } - } catch (_) {} + } catch (e) { + debugPrint('[PackageManager] Failed to pause download for $appId: $e'); + // Download library will handle the state via callbacks + } } Future resumeDownload(String appId) async { @@ -346,8 +409,29 @@ abstract class PackageManager extends StateNotifier { taskId: op.taskId, ), ); + } else { + // Task not found - transition to error + debugPrint('[PackageManager] Resume failed: task not found for $appId'); + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Download task not found. Please try again.', + ), + ); } - } catch (_) {} + } catch (e) { + debugPrint('[PackageManager] Failed to resume download for $appId: $e'); + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Failed to resume download: $e', + ), + ); + } } Future cancelDownload(String appId) async { @@ -378,10 +462,15 @@ abstract class PackageManager extends StateNotifier { // INSTALL OPERATIONS // ═══════════════════════════════════════════════════════════════════════════ - /// Trigger install from ReadyToInstall state + /// Trigger install from ReadyToInstall state. + /// CRITICAL: Must clear install slot on any early return to prevent queue stall. Future triggerInstall(String appId) async { final op = getOperation(appId); - if (op is! ReadyToInstall) return; + if (op is! ReadyToInstall) { + // Operation changed (e.g., cancelled) - clear slot and advance queue + clearInstallSlot(appId); + return; + } if (!await File(op.filePath).exists()) { setOperation( @@ -392,6 +481,8 @@ abstract class PackageManager extends StateNotifier { message: 'Downloaded file not found. Please download again.', ), ); + // CRITICAL: Clear install slot so queue can advance to next app + clearInstallSlot(appId); return; } @@ -834,6 +925,17 @@ abstract class PackageManager extends StateNotifier { Future.delayed(const Duration(milliseconds: 500), processQueue); } + /// Clear the active install slot and advance the queue. + /// Call this when an install fails to start or completes. + @protected + void clearInstallSlot(String appId) { + if (activeInstall == appId) { + activeInstall = null; + } + installQueue.remove(appId); + scheduleProcessQueue(); + } + // ═══════════════════════════════════════════════════════════════════════════ // INSTALL FLOW // ═══════════════════════════════════════════════════════════════════════════ @@ -908,6 +1010,9 @@ abstract class PackageManager extends StateNotifier { appId, InstallCancelled(target: target, filePath: filePath), ); + // CRITICAL: Clear install slot so queue can advance to next app. + // This handles exceptions that escape install()'s internal error handling. + clearInstallSlot(appId); return; } @@ -936,6 +1041,9 @@ abstract class PackageManager extends StateNotifier { filePath: filePath, ), ); + // CRITICAL: Clear install slot so queue can advance to next app. + // This handles exceptions that escape install()'s internal error handling. + clearInstallSlot(appId); } } @@ -1006,6 +1114,8 @@ abstract class PackageManager extends StateNotifier { case TaskStatus.running: case TaskStatus.enqueued: case TaskStatus.waitingToRetry: + // Track as active download to respect maxConcurrentDownloads limit + activeDownloads.add(appId); setOperation( appId, Downloading( @@ -1016,7 +1126,18 @@ abstract class PackageManager extends StateNotifier { ); try { await _downloader.resume(task); - } catch (_) {} + } catch (e) { + // Resume failed - transition to error state to avoid hang + activeDownloads.remove(appId); + setOperation( + appId, + OperationFailed( + target: fileMetadata, + type: FailureType.downloadFailed, + message: 'Failed to resume download: $e', + ), + ); + } break; case TaskStatus.paused: @@ -1282,19 +1403,10 @@ class BatchProgress { /// Whether all operations are complete (all terminal) bool get isAllComplete => !hasInProgress && total > 0; - /// Status text for display + /// Status text for display - simple "X of Y completed" format String get statusText { if (total == 0) return ''; - - final completedText = '$completed of $total updated'; - - return switch (phase) { - BatchPhase.installing => '$completedText • Installing...', - BatchPhase.verifying => '$completedText • Verifying...', - BatchPhase.downloading => '$completedText • Downloading...', - BatchPhase.completed => '$completedText ✓', - BatchPhase.idle => completedText, - }; + return '$completed of $total completed'; } } @@ -1344,12 +1456,12 @@ final batchProgressProvider = Provider((ref) { final phase = installing > 0 ? BatchPhase.installing : verifying > 0 - ? BatchPhase.verifying - : downloading > 0 - ? BatchPhase.downloading - : (completed > 0 && queued == 0) - ? BatchPhase.completed - : BatchPhase.idle; + ? BatchPhase.verifying + : downloading > 0 + ? BatchPhase.downloading + : (completed > 0 && queued == 0) + ? BatchPhase.completed + : BatchPhase.idle; return BatchProgress( total: total, diff --git a/work/WORK-001-package-manager.md b/work/WORK-001-package-manager.md index 85e8822..5d8be49 100644 --- a/work/WORK-001-package-manager.md +++ b/work/WORK-001-package-manager.md @@ -68,8 +68,105 @@ Root causes identified and fixed: - Defined `batchQueueDelayMs` constant in `install_operation.dart` - Prevents Riverpod rebuild flood on "Update All" with many apps +## Problem Solved (Phase 3 - Complete) + +User report: Apps stuck in "Queued for update" / "Requesting update" state indefinitely after app restart. + +Root cause: When the app is killed during install phase, native Android retains pending install sessions but Dart's operations map starts fresh. On restart, native sends `pendingUserAction` events for sessions that Dart doesn't track. Previously these were ignored, leaving orphaned sessions that blocked the PackageInstaller. + +## Tasks Completed (Phase 3) + +- [x] 1. Abort orphaned native sessions on event receipt + - Modified `_handleInstallEvent()` to call `abortInstall(appId)` when receiving events for untracked appIds + - Satisfies FEAT-001 requirement: "If the app is killed during install, reset UI and clean up temp files on next launch" + - Allows user to retry from clean state + +- [x] 2. Prevent abort spam when native keeps sending events + - Added `_abortedOrphans: Set` to track appIds already aborted + - Only call `abortInstall()` once per appId per session + - Clear from set in `setOperation()` override so future orphans can be aborted + +## Problem Solved (Phase 4 - Complete) + +User report: Multiple apps stuck in "Queued for update" state indefinitely, with no app actively installing. + +Root cause: In `processQueue()`, `activeInstall` is set BEFORE calling `triggerInstall()`. If `triggerInstall()` returns early (operation changed, or downloaded file not found), `activeInstall` is never cleared. This permanently blocks the install queue since `activeInstall != null` prevents any new installs from starting. + +## Tasks Completed (Phase 4) + +- [x] 1. Add `clearInstallSlot()` helper method in base class + - Clears `activeInstall` if it matches the appId + - Removes appId from `installQueue` + - Calls `scheduleProcessQueue()` to advance to next app + - Replaces duplicate `_onInstallComplete()` in android subclass + +- [x] 2. Fix `triggerInstall()` early return paths + - Call `clearInstallSlot(appId)` when operation is not ReadyToInstall + - Call `clearInstallSlot(appId)` when downloaded file is missing + - Prevents install queue from getting permanently stuck + +## Problem Solved (Phase 5 - Complete) + +Code audit found several potential hanging state bugs not covered by existing timeout mechanisms: + +1. `_performInstall` catch block didn't call `clearInstallSlot`, causing queue hang if exception escapes +2. Restored downloads weren't added to `activeDownloads`, potentially exceeding concurrent limits +3. Download resume failures during restoration were silently swallowed, causing permanent hang +4. EventChannel disconnect was silently ignored with no reconnection +5. No Dart-side watchdog as fallback if native events stop arriving + +## Tasks Completed (Phase 5) + +- [x] 1. Fix `_performInstall` catch block to call `clearInstallSlot` + - Ensures install queue advances even if exception escapes `install()` internal handling + +- [x] 2. Track restored downloads in `activeDownloads` + - Added `activeDownloads.add(appId)` in `_restoreOperation` for running downloads + - Prevents exceeding `maxConcurrentDownloads` limit after app restart + +- [x] 3. Handle download resume failures during restoration + - On resume failure, transition to `OperationFailed` instead of silent failure + - Prevents operations stuck in `Downloading` state with no active task + +- [x] 4. Add EventChannel reconnection logic + - Added `onError` and `onDone` handlers to event stream + - Automatic reconnection with 2-second delay on disconnect + - Prevents installs hanging forever if EventChannel breaks + +- [x] 5. Add Dart-side watchdog timer as fallback + - Added `startedAt` field to `Verifying` and `SystemProcessing` states + - Added `needsWatchdog` and `startedAt` getters to extension for uniform access + - Single 5-minute timeout for all watchdog-monitored states + - Timer runs every 30s, only when there are operations needing watchdog + - Transitions stale operations to `OperationFailed` state + +- [x] 6. Improve pause/resume error handling + - Added debug logging for pause failures + - Resume failure now transitions to `OperationFailed` instead of silent failure + ## Decisions +### 2026-02-04 — Dart-side watchdog timer + +**Context:** Native Kotlin has timeouts but if EventChannel breaks or native crashes before sending events, Dart operations hang forever. +**Options:** A) Trust native completely, B) Add Dart watchdog as fallback, C) Duplicate all timeout logic in Dart. +**Decision:** Option B - Dart-side watchdog as defense-in-depth. +**Rationale:** Minimal complexity (~50 lines), only runs when needed, uses generous timeouts (2-10min) so it doesn't interfere with native handling but catches catastrophic failures. + +### 2026-02-04 — Install slot clearing on early return + +**Context:** `triggerInstall()` can bail early if operation changed or file is missing, but `activeInstall` was set before the call, blocking the queue forever. +**Options:** A) Move `activeInstall` assignment inside `triggerInstall()` after validation, B) Have `triggerInstall()` clear slot on early returns, C) Return success boolean and only set slot if true. +**Decision:** Option B - Clear install slot on early returns. +**Rationale:** Minimal change, maintains existing flow, easy to audit. Added `clearInstallSlot()` to base class to consolidate the pattern. + +### 2026-02-04 — Orphaned session cleanup + +**Context:** Native install sessions persist across app restarts, but Dart state doesn't. Events arrive for untracked appIds. +**Options:** A) Ignore events (existing), B) Abort orphaned sessions, C) Attempt to restore operations from native state. +**Decision:** Option B - Abort orphaned sessions immediately. +**Rationale:** Simplest fix that satisfies "no hanging states" invariant. User can retry from clean state. Option C adds complexity and may restore stale/invalid state. + ### 2026-02-03 — Committed session handling **Context:** `abandonSession()` is ineffective after commit. @@ -164,7 +261,7 @@ Every state MUST resolve to a terminal state. No exceptions. | `blocked` | Device policy | | `installTimeout` | Watchdog timeout (uncommitted only) | -### Watchdog Timeouts +### Watchdog Timeouts (Kotlin Native) | Phase | Initial | Max | Behavior | |-------|---------|-----|----------| @@ -172,6 +269,12 @@ Every state MUST resolve to a terminal state. No exceptions. | Install (uncommitted) | 10s | 120s | Abandon session, emit FAILED | | Install (committed) | 10s | ∞ | Cannot abandon; extend deadline, show "processing" | +### Dart-side Watchdog (Fallback) + +Single 5-minute timeout for all watchdog-monitored states (Verifying, Installing, SystemProcessing). +Timer runs every 30s, only when monitored operations exist. +Defense-in-depth for cases where EventChannel breaks or native crashes before sending events. + ### Session Tracking Maps | Map | Purpose |