From ce813bc216065a8a6e09282afe85902651936dfc Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 06:05:48 +0700 Subject: [PATCH] feat(extension-health): cache results, force refresh, treat lookup errors as transient Cache health results after each check, add a force flag to bypass the cache when the download picker opens or a service is selected, guard stale concurrent results via a per-extension request serial, and treat DNS lookup failures as indeterminate/transient. --- go_backend/extension_health.go | 21 ++++++-- .../extension_health_misc_supplement_test.go | 4 +- lib/providers/extension_provider.dart | 52 ++++++++++++------- lib/widgets/download_service_picker.dart | 19 +++++-- 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go index c1f60d02..749a0b8e 100644 --- a/go_backend/extension_health.go +++ b/go_backend/extension_health.go @@ -59,6 +59,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) { } result := CheckExtensionHealth(ext) + cacheExtensionHealthResult(ext, result) bytes, err := json.Marshal(result) if err != nil { return "", err @@ -86,6 +87,20 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult { extensionHealthCacheMu.Unlock() result := CheckExtensionHealth(ext) + cacheExtensionHealthResult(ext, result) + return result +} + +func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return + } + + cacheKey := strings.TrimSpace(ext.ID) + if cacheKey == "" { + return + } + ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth) if result.Status == "unknown" && ttl > extensionHealthUnknownCache { ttl = extensionHealthUnknownCache @@ -94,11 +109,9 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult { extensionHealthCacheMu.Lock() extensionHealthCache[cacheKey] = cachedExtensionHealthResult{ result: result, - expiresAt: now.Add(ttl), + expiresAt: time.Now().Add(ttl), } extensionHealthCacheMu.Unlock() - - return result } func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult { @@ -271,7 +284,7 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC } func isTransientExtensionHealthError(err error) bool { - return isTransientNetworkError(err) + return isTransientNetworkError(err) || isConnectivityFailure(err) } func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) { diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go index e3c3a4a8..067b9d14 100644 --- a/go_backend/extension_health_misc_supplement_test.go +++ b/go_backend/extension_health_misc_supplement_test.go @@ -32,8 +32,8 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) { if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) { t.Fatal("expected timeout health errors to be transient") } - if isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) { - t.Fatal("expected non-timeout DNS errors to be non-transient") + if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) { + t.Fatal("expected health transport lookup errors to be indeterminate") } if result := CheckExtensionHealth(nil); result.Status != "offline" { diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 120b2689..92727cb9 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -814,6 +814,7 @@ class ExtensionNotifier extends Notifier { Completer? _initializationCompleter; final Map _healthExpiresAt = {}; final Map> _healthInFlight = {}; + final Map _healthRequestSerial = {}; @override ExtensionState build() { @@ -825,6 +826,7 @@ class ExtensionNotifier extends Notifier { _appLifecycleListener = null; _healthExpiresAt.clear(); _healthInFlight.clear(); + _healthRequestSerial.clear(); }); return const ExtensionState(); } @@ -954,15 +956,18 @@ class ExtensionNotifier extends Notifier { } } - void _scheduleExtensionHealthRefresh(List extensions) { + void _scheduleExtensionHealthRefresh( + List extensions, { + bool force = false, + }) { for (final ext in extensions) { if (!ext.enabled || !ext.hasServiceHealth) continue; - unawaited(checkExtensionHealth(ext.id)); + unawaited(checkExtensionHealth(ext.id, force: force)); } } - void refreshEnabledExtensionHealth() { - _scheduleExtensionHealthRefresh(state.extensions); + void refreshEnabledExtensionHealth({bool force = false}) { + _scheduleExtensionHealthRefresh(state.extensions, force: force); } Future checkExtensionHealth( @@ -990,17 +995,22 @@ class ExtensionNotifier extends Notifier { return inFlight; } + final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1; + _healthRequestSerial[extensionId] = requestSerial; + final future = () async { try { final result = await PlatformBridge.checkExtensionHealth(extensionId); final status = ExtensionHealthStatus.fromJson(result); - final updated = Map.of( - state.healthStatuses, - )..[extensionId] = status; - _healthExpiresAt[extensionId] = DateTime.now().add( - _extensionHealthCacheTtl, - ); - state = state.copyWith(healthStatuses: updated); + if (_healthRequestSerial[extensionId] == requestSerial) { + final updated = Map.of( + state.healthStatuses, + )..[extensionId] = status; + _healthExpiresAt[extensionId] = DateTime.now().add( + _extensionHealthCacheTtl, + ); + state = state.copyWith(healthStatuses: updated); + } return status; } catch (e) { _log.w('Failed to check extension health for $extensionId: $e'); @@ -1010,16 +1020,20 @@ class ExtensionNotifier extends Notifier { checkedAt: DateTime.now(), checks: const [], ); - final updated = Map.of( - state.healthStatuses, - )..[extensionId] = status; - _healthExpiresAt[extensionId] = DateTime.now().add( - const Duration(seconds: 20), - ); - state = state.copyWith(healthStatuses: updated); + if (_healthRequestSerial[extensionId] == requestSerial) { + final updated = Map.of( + state.healthStatuses, + )..[extensionId] = status; + _healthExpiresAt[extensionId] = DateTime.now().add( + const Duration(seconds: 20), + ); + state = state.copyWith(healthStatuses: updated); + } return status; } finally { - _healthInFlight.remove(extensionId); + if (_healthRequestSerial[extensionId] == requestSerial) { + _healthInFlight.remove(extensionId); + } } }(); diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index c9b8931a..dd710508 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -74,7 +75,9 @@ class _DownloadServicePickerState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth(); + ref + .read(extensionProvider.notifier) + .refreshEnabledExtensionHealth(force: true); }); final downloadExtensions = _downloadExtensions(); final recommended = widget.recommendedService; @@ -102,6 +105,17 @@ class _DownloadServicePickerState extends ConsumerState { return const []; } + void _selectService(Extension extension) { + setState(() => _selectedService = extension.id); + if (extension.hasServiceHealth) { + unawaited( + ref + .read(extensionProvider.notifier) + .checkExtensionHealth(extension.id, force: true), + ); + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -166,8 +180,7 @@ class _DownloadServicePickerState extends ConsumerState { ? extensionState.healthStatuses[ext.id]?.status : null, isSelected: _selectedService == ext.id, - onTap: () => - setState(() => _selectedService = ext.id), + onTap: () => _selectService(ext), iconPath: ext.iconPath, ), ],