diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 5cbfc19..a3b2d41 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -6,8 +6,10 @@ 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: 1); +/// Dart-side watchdog timeout (fallback if native events stop arriving). +/// For downloads, this is measured from the last progress update (activity-based). +/// For other phases, this is measured from when the phase started. +const watchdogTimeout = Duration(minutes: 2); /// How often the watchdog timer checks for stale operations const watchdogCheckInterval = Duration(seconds: 30); @@ -42,12 +44,18 @@ class Downloading extends InstallOperation { final String taskId; final DateTime startedAt; + /// Last time progress was received. The watchdog uses this (not [startedAt]) + /// so that slow-but-active downloads are not killed prematurely. + final DateTime lastProgressAt; + Downloading({ required super.target, required this.progress, required this.taskId, DateTime? startedAt, - }) : startedAt = startedAt ?? DateTime.now(); + DateTime? lastProgressAt, + }) : startedAt = startedAt ?? DateTime.now(), + lastProgressAt = lastProgressAt ?? startedAt ?? DateTime.now(); Downloading copyWith({double? progress}) { return Downloading( @@ -55,6 +63,8 @@ class Downloading extends InstallOperation { progress: progress ?? this.progress, taskId: taskId, startedAt: startedAt, + // Reset activity timestamp when progress changes + lastProgressAt: progress != null ? DateTime.now() : lastProgressAt, ); } } @@ -259,9 +269,11 @@ 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) { - Downloading(:final startedAt) => startedAt, + /// Get the relevant timestamp for watchdog monitoring. + /// For downloads: last progress update (activity-based, so slow downloads survive). + /// For other phases: when the phase started. + DateTime? get watchdogTimestamp => switch (this) { + Downloading(:final lastProgressAt) => lastProgressAt, Verifying(:final startedAt) => startedAt, Installing(:final startedAt) => startedAt, SystemProcessing(:final startedAt) => startedAt, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index ebf1ab1..f208334 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -217,9 +217,9 @@ abstract class PackageManager extends StateNotifier { for (final entry in state.operations.entries) { final appId = entry.key; final op = entry.value; - final startedAt = op.startedAt; + final timestamp = op.watchdogTimestamp; - if (startedAt == null || now.difference(startedAt) <= watchdogTimeout) { + if (timestamp == null || now.difference(timestamp) <= watchdogTimeout) { continue; } diff --git a/work/WORK-001-package-manager.md b/work/WORK-001-package-manager.md index 5d8be49..732e523 100644 --- a/work/WORK-001-package-manager.md +++ b/work/WORK-001-package-manager.md @@ -135,8 +135,10 @@ Code audit found several potential hanging state bugs not covered by existing ti - [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 + - Added `needsWatchdog` and `watchdogTimestamp` getters to extension for uniform access + - Activity-based watchdog for downloads: tracks `lastProgressAt` (reset on each progress update) so slow-but-active downloads are not killed + - Phase-start-based watchdog for Verifying/Installing/SystemProcessing (uses `startedAt`) + - Single 2-minute timeout for all watchdog-monitored states - Timer runs every 30s, only when there are operations needing watchdog - Transitions stale operations to `OperationFailed` state @@ -271,10 +273,16 @@ Every state MUST resolve to a terminal state. No exceptions. ### 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. +2-minute timeout for all watchdog-monitored states. Timer runs every 30s, only when monitored operations exist. Defense-in-depth for cases where EventChannel breaks or native crashes before sending events. +| Phase | Measures from | Rationale | +|-------|---------------|-----------| +| Downloading | Last progress update (`lastProgressAt`) | Slow-but-active downloads must not be killed | +| Verifying | Phase start (`startedAt`) | Short operation, elapsed time is appropriate | +| Installing | Phase start (`startedAt`) | Short operation, elapsed time is appropriate | +| SystemProcessing | Phase start (`startedAt`) | Committed session, elapsed time is appropriate | + ### Session Tracking Maps | Map | Purpose |