Improve package manager handling of edge cases on Dart side

This commit is contained in:
franzap
2026-02-04 18:53:50 -03:00
parent 2d7d7c7266
commit 914e47529c
4 changed files with 360 additions and 110 deletions
@@ -74,6 +74,10 @@ final class AndroidPackageManager extends PackageManager {
int _syncGeneration = 0;
StreamSubscription<dynamic>? _eventSubscription;
/// Tracks appIds where we've already attempted to abort orphaned sessions.
/// Prevents spamming abort calls when native keeps sending events.
final Set<String> _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<String, InstallOperation>.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;
}
@@ -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,
+152 -40
View File
@@ -108,6 +108,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
late final FileDownloader _downloader;
late final Future<void> _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<PackageManagerState> {
],
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<PackageManagerState> {
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<PackageManagerState> {
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<void> resumeDownload(String appId) async {
@@ -346,8 +409,29 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<void> cancelDownload(String appId) async {
@@ -378,10 +462,15 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
// 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<void> 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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
);
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<BatchProgress?>((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,
+104 -1
View File
@@ -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<String>` 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 |