diff --git a/packages/komodo_defi_framework/assets/web/event_streaming_worker.js b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js index f80d791b3..8ee3e82a9 100644 --- a/packages/komodo_defi_framework/assets/web/event_streaming_worker.js +++ b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js @@ -8,12 +8,25 @@ onconnect = function (e) { connections.push(port); port.start(); + port.addEventListener('close', () => { + const index = connections.indexOf(port); + if (index > -1) { + connections.splice(index, 1); + } + }); + port.onmessage = function (msgEvent) { try { const data = msgEvent.data; for (const p of connections) { - try { p.postMessage(data); } catch (_) { } + try { + p.postMessage(data); + } catch (err) { + console.error('[SharedWorker] Failed to forward message:', err); + } } - } catch (_) { } + } catch (err) { + console.error('[SharedWorker] Message handling error:', err); + } }; }; diff --git a/packages/komodo_defi_framework/ios/Classes/KdfRestartHandler.swift b/packages/komodo_defi_framework/ios/Classes/KdfRestartHandler.swift new file mode 100644 index 000000000..89528e7ab --- /dev/null +++ b/packages/komodo_defi_framework/ios/Classes/KdfRestartHandler.swift @@ -0,0 +1,69 @@ +import Foundation +import Flutter +import UIKit + +/// Handles automatic app restart on iOS when KDF encounters fatal errors or shutdown signals. +/// +/// Since iOS doesn't allow true programmatic restarts, this implementation: +/// 1. Gracefully exits the app via exit(0) +/// 2. User manually reopens the app +/// +/// This is a simple approach that doesn't require notification permissions. +@objc public class KdfRestartHandler: NSObject, FlutterPlugin { + private static let channelName = "com.komodoplatform.kdf/restart" + private static var channel: FlutterMethodChannel? + + /// Registers the plugin with the Flutter engine + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: registrar.messenger() + ) + let instance = KdfRestartHandler() + registrar.addMethodCallDelegate(instance, channel: channel) + KdfRestartHandler.channel = channel + } + + /// Handles method calls from Dart + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "requestAppRestart": + guard let args = call.arguments as? [String: Any], + let reason = args["reason"] as? String else { + result(FlutterError( + code: "INVALID_ARGUMENTS", + message: "Missing reason argument", + details: nil + )) + return + } + + requestAppRestart(reason: reason, result: result) + + default: + result(FlutterMethodNotImplemented) + } + } + + /// Initiates the app restart process by gracefully exiting + /// + /// - Parameters: + /// - reason: The reason for the restart (e.g., "broken_pipe", "shutdown_signal") + /// - result: Flutter result callback + private func requestAppRestart( + reason: String, + result: @escaping FlutterResult + ) { + NSLog("[KDF] App restart requested due to: \(reason)") + NSLog("[KDF] Exiting app - user will need to manually reopen") + + result(true) + + // Give a brief moment for Flutter to receive the result before exiting + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSLog("[KDF] Exiting app for restart...") + // exit(0) is discouraged by Apple but necessary here for error recovery + exit(0) + } + } +} diff --git a/packages/komodo_defi_framework/ios/Classes/KomodoDefiFrameworkPlugin.swift b/packages/komodo_defi_framework/ios/Classes/KomodoDefiFrameworkPlugin.swift new file mode 100644 index 000000000..357936c08 --- /dev/null +++ b/packages/komodo_defi_framework/ios/Classes/KomodoDefiFrameworkPlugin.swift @@ -0,0 +1,11 @@ +import Flutter +import UIKit + +/// Main plugin class for the Komodo DeFi Framework iOS platform integration +public class KomodoDefiFrameworkPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // Register the restart handler + KdfRestartHandler.register(with: registrar) + } +} + diff --git a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index 42e436251..1b710d09c 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; @@ -6,7 +7,9 @@ import 'package:komodo_defi_framework/src/config/kdf_logging_config.dart'; import 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; import 'package:komodo_defi_framework/src/operations/kdf_operations_factory.dart'; import 'package:komodo_defi_framework/src/operations/kdf_operations_interface.dart'; +import 'package:komodo_defi_framework/src/platform/ios_restart_handler.dart'; import 'package:komodo_defi_framework/src/streaming/event_streaming_service.dart'; +import 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; @@ -15,6 +18,7 @@ export 'package:komodo_defi_framework/src/client/kdf_api_client.dart'; export 'package:komodo_defi_framework/src/config/event_streaming_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; +export 'package:komodo_defi_framework/src/platform/ios_restart_handler.dart'; export 'package:komodo_defi_framework/src/services/seed_node_service.dart'; export 'package:komodo_defi_framework/src/streaming/event_streaming_service.dart'; export 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; @@ -185,15 +189,21 @@ class KomodoDefiFramework implements ApiClient { Future version() async { final stopwatch = Stopwatch()..start(); - _log('version(): Starting version RPC call via ${_kdfOperations.operationsName}'); + _log( + 'version(): Starting version RPC call via ${_kdfOperations.operationsName}', + ); try { final version = await _kdfOperations.version(); stopwatch.stop(); - _log('version(): Completed in ${stopwatch.elapsedMilliseconds}ms, result=$version'); + _log( + 'version(): Completed in ${stopwatch.elapsedMilliseconds}ms, result=$version', + ); return version; } catch (e) { stopwatch.stop(); - _log('version(): Failed after ${stopwatch.elapsedMilliseconds}ms with error: $e'); + _log( + 'version(): Failed after ${stopwatch.elapsedMilliseconds}ms with error: $e', + ); rethrow; } } @@ -202,7 +212,7 @@ class KomodoDefiFramework implements ApiClient { /// Returns true if KDF is running and responsive, false otherwise. /// This is useful for detecting when KDF has become unavailable, especially /// on mobile platforms after app backgrounding. - /// + /// /// IMPORTANT: This method ONLY relies on actual RPC verification (version() call) /// to avoid false positives where native status reports "running" but HTTP listener /// is not accepting connections (common after iOS backgrounding). @@ -214,7 +224,7 @@ class KomodoDefiFramework implements ApiClient { _log('KDF health check failed: version call returned null'); return false; } - + _log('KDF health check passed: version=$versionCheck'); return true; } catch (e) { @@ -276,7 +286,7 @@ class KomodoDefiFramework implements ApiClient { return response; } catch (e) { stopwatch.stop(); - + // Detect transport-fatal SocketExceptions that indicate KDF is down/dying // errno 32 (EPIPE): Broken pipe - writing to socket whose peer closed // errno 54 (ECONNRESET): Connection reset by peer @@ -284,24 +294,44 @@ class KomodoDefiFramework implements ApiClient { // errno 61 (ECONNREFUSED): Connection refused - no listener on port final errorString = e.toString().toLowerCase(); final isSocketException = errorString.contains('socketexception'); - final isFatalTransportError = isSocketException && ( - errorString.contains('broken pipe') || errorString.contains('errno = 32') || - errorString.contains('connection reset') || errorString.contains('errno = 54') || - errorString.contains('operation timed out') || errorString.contains('errno = 60') || - errorString.contains('connection refused') || errorString.contains('errno = 61') - ); + final isFatalTransportError = + isSocketException && + (errorString.contains('broken pipe') || + errorString.contains('errno = 32') || + errorString.contains('connection reset') || + errorString.contains('errno = 54') || + errorString.contains('operation timed out') || + errorString.contains('errno = 60') || + errorString.contains('connection refused') || + errorString.contains('errno = 61')); if (isFatalTransportError) { - final errorType = errorString.contains('errno = 32') || errorString.contains('broken pipe') ? 'EPIPE (32)' : - errorString.contains('errno = 54') || errorString.contains('connection reset') ? 'ECONNRESET (54)' : - errorString.contains('errno = 60') || errorString.contains('operation timed out') ? 'ETIMEDOUT (60)' : - 'ECONNREFUSED (61)'; + final errorType = + errorString.contains('errno = 32') || + errorString.contains('broken pipe') + ? 'EPIPE (32)' + : errorString.contains('errno = 54') || + errorString.contains('connection reset') + ? 'ECONNRESET (54)' + : errorString.contains('errno = 60') || + errorString.contains('operation timed out') + ? 'ETIMEDOUT (60)' + : 'ECONNREFUSED (61)'; _logger.severe( '[RPC] ${method ?? 'unknown'} failed: KDF transport error $errorType. ' 'Resetting HTTP client to drop stale connections.', ); // Reset HTTP client immediately to drop stale keep-alive connections resetHttpClient(); + + // On iOS, trigger app restart for broken pipe errors (errno 32) + // This handles cases where KDF has terminated unexpectedly + final isBrokenPipe = + errorString.contains('errno = 32') || + errorString.contains('broken pipe'); + if (isBrokenPipe) { + _handleBrokenPipeError(); + } } else { _logger.warning( '[RPC] ${method ?? 'unknown'} failed after ${stopwatch.elapsedMilliseconds}ms: $e', @@ -451,6 +481,64 @@ class KomodoDefiFramework implements ApiClient { } } + /// Handles broken pipe errors by triggering an app restart on iOS. + /// + /// Broken pipe errors (errno 32) indicate that KDF has terminated unexpectedly + /// or the connection has been severed. On iOS, we trigger an app restart to + /// recover from this state. + void _handleBrokenPipeError() { + // Only handle on iOS + if (kIsWeb || !Platform.isIOS) { + return; + } + + _logger.severe('[iOS] Broken pipe detected - requesting app restart'); + + // Request app restart asynchronously (fire and forget) + // The app will exit shortly after this is called + IosRestartHandler.instance + .requestRestartForBrokenPipe() + .then((success) { + if (!success) { + _logger.severe( + '[iOS] Failed to request app restart for broken pipe error', + ); + } + }) + .catchError((Object error) { + _logger.severe('[iOS] Error requesting app restart: $error'); + }); + } + + /// Handles shutdown signals by triggering an app restart on iOS. + /// + /// Called by the auth service when a shutdown signal is received from KDF. + /// On iOS, this triggers an app restart to recover from KDF shutdown. + void handleShutdownSignalForRestart(ShutdownSignalEvent event) { + // Only handle on iOS + if (kIsWeb || !Platform.isIOS) { + return; + } + + _logger.severe( + '[iOS] Shutdown signal (${event.signalName}) detected - requesting app restart', + ); + + // Request app restart asynchronously (fire and forget) + IosRestartHandler.instance + .requestRestartForShutdownSignal(event.signalName) + .then((success) { + if (!success) { + _logger.severe( + '[iOS] Failed to request app restart for shutdown signal', + ); + } + }) + .catchError((Object error) { + _logger.severe('[iOS] Error requesting app restart: $error'); + }); + } + /// Closes the log stream and cancels the logger subscription. /// /// NB! This does not stop the KDF operations or the KDF process. @@ -464,6 +552,13 @@ class KomodoDefiFramework implements ApiClient { await _logStream.close(); } + // Dispose streaming service (SSE/SharedWorker) if initialized + final svc = _streamingService; + if (svc != null) { + await svc.dispose(); + _streamingService = null; + } + // Dispose of KDF operations to free native resources final operations = _kdfOperations; operations.dispose(); diff --git a/packages/komodo_defi_framework/lib/src/platform/ios_restart_handler.dart b/packages/komodo_defi_framework/lib/src/platform/ios_restart_handler.dart new file mode 100644 index 000000000..d42d0ed86 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/platform/ios_restart_handler.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +/// Handles iOS app restart requests when KDF encounters fatal errors. +/// +/// This class provides a way to trigger an iOS app restart by gracefully +/// exiting the app. The user will need to manually reopen the app. +/// +/// This is a simple approach that works on iOS without requiring any +/// notification permissions or additional setup. +class IosRestartHandler { + IosRestartHandler._(); + + static final IosRestartHandler _instance = IosRestartHandler._(); + static IosRestartHandler get instance => _instance; + + static const MethodChannel _channel = MethodChannel( + 'com.komodoplatform.kdf/restart', + ); + + final Logger _logger = Logger('IosRestartHandler'); + + /// Whether the platform supports app restart (iOS only) + bool get isSupported => !kIsWeb && Platform.isIOS; + + /// Requests an app restart by gracefully exiting the app. + /// + /// The app will exit cleanly and the user will need to manually reopen it. + /// + /// [reason] is used for logging purposes to track why the restart was triggered. + /// + /// Returns `true` if the restart process was initiated successfully. + /// Note: The app will exit shortly after this returns true. + Future requestAppRestart({required String reason}) async { + if (!isSupported) { + _logger.warning( + 'iOS restart not supported on this platform (reason: $reason)', + ); + return false; + } + + _logger.severe('Requesting iOS app restart due to: $reason'); + + try { + final result = await _channel.invokeMethod( + 'requestAppRestart', + {'reason': reason}, + ); + + final success = result ?? false; + if (success) { + _logger.info('iOS app restart initiated successfully'); + } else { + _logger.warning('iOS app restart request returned false'); + } + + return success; + } on PlatformException catch (e) { + _logger.severe('Failed to request app restart: ${e.message}', e); + return false; + } catch (e) { + _logger.severe('Unexpected error requesting app restart', e); + return false; + } + } + + /// Convenience method to request restart due to broken pipe error + Future requestRestartForBrokenPipe() async { + return requestAppRestart(reason: 'broken_pipe'); + } + + /// Convenience method to request restart due to shutdown signal + Future requestRestartForShutdownSignal(String signalName) async { + return requestAppRestart(reason: 'shutdown_signal_$signalName'); + } +} diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart index d488b5cde..259237420 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter_client_sse/constants/sse_request_type_enum.dart' as sset; import 'package:flutter_client_sse/flutter_client_sse.dart' as sse; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:logging/logging.dart'; typedef EventStreamUnsubscribe = void Function(); @@ -33,16 +33,12 @@ EventStreamUnsubscribe connectEventStream({ final Uri url = _buildEventsUrl(cfg); bool isClosed = false; StreamSubscription? sub; - - void log(String msg) { - if (kDebugMode) { - // TODO: Move to central logging system - print('[EventStream][IO] $msg'); - } - } + final Logger logger = Logger('KdfEventStreamingService[IO]'); + final Stopwatch connectionTimer = Stopwatch()..start(); Future start() async { try { + logger.info('[EventStream][IO] Starting SSE connection to $url'); // Some servers accept rpc_pass in headers, but KDF exposes `?userpass=` // as query for SSE. We still set Accept header to ensure SSE content type. sub = @@ -58,6 +54,9 @@ EventStreamUnsubscribe connectEventStream({ }, ).listen( (event) { + logger.info( + '[EventStream][IO] Event: (${event.event}:${event.id}) ${event.data}', + ); final String? raw = event.data; if (raw == null) return; final String data = raw.trim(); @@ -66,16 +65,24 @@ EventStreamUnsubscribe connectEventStream({ final decoded = jsonFromString(data); onMessage(decoded); } catch (e) { - log('Failed to decode event data: $e'); + logger.warning( + '[EventStream][IO] Failed to decode event data: $e', + ); } }, onError: (Object error) { - log('SSE error: $error'); + logger.warning('[EventStream][IO] SSE error: $error'); }, ); - log('Connected to $url'); + connectionTimer.stop(); + logger.info( + '[EventStream][IO] Connected to $url in ${connectionTimer.elapsedMilliseconds}ms', + ); } catch (e) { - log('Failed to start SSE: $e'); + connectionTimer.stop(); + logger.severe( + '[EventStream][IO] Failed to start SSE after ${connectionTimer.elapsedMilliseconds}ms: $e', + ); } } @@ -87,6 +94,9 @@ EventStreamUnsubscribe connectEventStream({ isClosed = true; try { await sub?.cancel(); - } catch (_) {} + logger.info('[EventStream][IO] Disconnected from $url'); + } catch (e) { + logger.warning('[EventStream][IO] Error during disconnect: $e'); + } }; } diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart index 1fe5d09f9..0428bbbf1 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -3,8 +3,8 @@ // ignore: avoid_web_libraries_in_flutter import 'dart:html' as html show Event; -import 'package:flutter/foundation.dart'; import 'package:js/js_util.dart' as jsu; +import 'package:logging/logging.dart'; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; @@ -29,34 +29,48 @@ EventStreamUnsubscribe connectEventStream({ IKdfHostConfig? hostConfig, required void Function(Object? data) onMessage, }) { + final Logger logger = Logger('KdfEventStreamingService[Web]'); + final Stopwatch connectionTimer = Stopwatch()..start(); + try { final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); final Object worker = _callConstructor(sharedWorkerCtor, [ 'assets/packages/komodo_defi_framework/assets/web/event_streaming_worker.js', ]); final Object? portMaybe = _getProperty(worker, 'port'); - if (portMaybe == null) return () {}; + if (portMaybe == null) { + logger.warning('[EventStream][Web] SharedWorker port is null'); + return () {}; + } final Object port = portMaybe; _callMethod(port, 'start', const []); void handler(html.Event e) { final Object? data = _getProperty(e, 'data'); - - if (kDebugMode) { - print('EventStream: Received message: $data'); - } onMessage(data); } _setProperty(port, 'onmessage', jsu.allowInterop(handler)); + connectionTimer.stop(); + logger.info( + '[EventStream][Web] Connected to SharedWorker in ${connectionTimer.elapsedMilliseconds}ms', + ); + return () { try { _setProperty(port, 'onmessage', null); _callMethod(port, 'close', const []); - } catch (_) {} + logger.info('[EventStream][Web] Disconnected from SharedWorker'); + } catch (e) { + logger.warning('[EventStream][Web] Error during disconnect: $e'); + } }; - } catch (_) { + } catch (e) { + connectionTimer.stop(); + logger.severe( + '[EventStream][Web] Failed to connect to SharedWorker after ${connectionTimer.elapsedMilliseconds}ms: $e', + ); return () {}; } } diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart index b51179391..3a95680f5 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -4,21 +4,29 @@ import 'dart:async'; import 'dart:convert' as convert; -import 'package:flutter/foundation.dart'; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; +import 'package:komodo_defi_framework/src/config/kdf_logging_config.dart'; import 'package:komodo_defi_framework/src/streaming/event_streaming_platform_stub.dart' if (dart.library.io) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_io.dart' if (dart.library.html) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_web.dart'; import 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:logging/logging.dart'; typedef EventPredicate = bool Function(KdfEvent event); class KdfEventStreamingService { KdfEventStreamingService({IKdfHostConfig? hostConfig}) - : _hostConfig = hostConfig; + : _hostConfig = hostConfig { + _logger = Logger('KdfEventStreamingService'); + } final IKdfHostConfig? _hostConfig; + late final Logger _logger; + + /// Enable debug logging for streaming events (event types, durations, errors) + /// This can be controlled via app configuration + static bool enableDebugLogging = true; final StreamController _events = StreamController.broadcast(); @@ -35,6 +43,25 @@ class KdfEventStreamingService { } void _onIncomingData(Object? data) { + if (!enableDebugLogging) { + _processEventData(data); + return; + } + + final stopwatch = Stopwatch()..start(); + + try { + _processEventData(data); + stopwatch.stop(); + } catch (e) { + stopwatch.stop(); + _logger.warning( + '[EventStream] Failed to process event after ${stopwatch.elapsedMilliseconds}ms: $e', + ); + } + } + + void _processEventData(Object? data) { try { if (data == null) return; JsonMap? map; @@ -63,15 +90,24 @@ class KdfEventStreamingService { } else { throw ArgumentError('Unsupported event data type: ${data.runtimeType}'); } + final event = KdfEvent.fromJson(map); - if (kDebugMode) { + + if (enableDebugLogging) { final summary = _summarizeEvent(event); - print('[EventStream] Received ${event.typeEnum.value}: $summary'); + _logger.info( + '[EventStream] Received ${event.typeEnum.value}: $summary', + ); + + if (KdfLoggingConfig.verboseLogging) { + _logger.fine('[EventStream] Event payload: ${map.toJsonString()}'); + } } + _events.add(event); } catch (e) { - if (kDebugMode) { - print('Failed to parse stream event: $e'); + if (enableDebugLogging) { + _logger.warning('[EventStream] Failed to parse event: $e'); } } } diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart index 6a1b47303..246f419e4 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:decimal/decimal.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:logging/logging.dart'; part 'balance_event.dart'; part 'heartbeat_event.dart'; @@ -14,6 +15,8 @@ part 'task_event.dart'; part 'tx_history_event.dart'; part 'unknown_event.dart'; +final Logger _kdfEventLogger = Logger('KdfEvent'); + /// Private enum for internal event type string mapping enum EventTypeString { balance('BALANCE'), @@ -173,7 +176,7 @@ sealed class KdfEvent { /// Handles unknown event types by logging and returning an UnknownEvent static UnknownEvent _handleUnknownEvent(String typeString, JsonMap message) { if (kDebugMode) { - print('[EventStream] Unknown event type: $typeString'); + _kdfEventLogger.warning('[EventStream] Unknown event type: $typeString'); } return UnknownEvent(typeString: typeString, rawData: message); } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart index 8a745108c..ad267e047 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart @@ -110,7 +110,8 @@ abstract interface class IAuthService { } class KdfAuthService implements IAuthService { - KdfAuthService(this._kdfFramework, this._hostConfig) : _sessionId = const Uuid().v4() { + KdfAuthService(this._kdfFramework, this._hostConfig) + : _sessionId = const Uuid().v4() { _logger.info('[$_sessionId] KdfAuthService initialized'); _startHealthCheck(); _subscribeToShutdownSignals(); @@ -148,37 +149,45 @@ class KdfAuthService implements IAuthService { required String password, required AuthOptions options, }) async { - _logger.info('[$_sessionId] signIn: Starting login for wallet: $walletName'); - + _logger.info( + '[$_sessionId] signIn: Starting login for wallet: $walletName', + ); + // Proactively ensure KDF is healthy before attempting login // This prevents login attempts while KDF is down or restarting final isHealthy = await ensureKdfHealthy().timeout( const Duration(seconds: 3), onTimeout: () { - _logger.warning('[$_sessionId] signIn: Health check timed out after 3s'); + _logger.warning( + '[$_sessionId] signIn: Health check timed out after 3s', + ); return false; }, ); - + if (!isHealthy) { - _logger.warning('[$_sessionId] signIn: KDF not healthy, retrying after 1s'); + _logger.warning( + '[$_sessionId] signIn: KDF not healthy, retrying after 1s', + ); // Wait and retry once - await Future.delayed(const Duration(milliseconds: 1000)); + await Future.delayed(const Duration(milliseconds: 1000)); final retryHealthy = await ensureKdfHealthy().timeout( const Duration(seconds: 3), onTimeout: () => false, ); if (!retryHealthy) { - _logger.severe('[$_sessionId] signIn: KDF still not healthy after retry'); + _logger.severe( + '[$_sessionId] signIn: KDF still not healthy after retry', + ); throw AuthException( 'KDF is not available. Please try again.', type: AuthExceptionType.apiConnectionError, ); } } - + _logger.info('[$_sessionId] signIn: KDF healthy, proceeding with login'); - + // [getActiveUser] performs a read lock, which should happen outside of // the write lock to prevent deadlocks. If kdf is not running, null is // returned, so we can safely call it here without any checks. @@ -587,7 +596,9 @@ class KdfAuthService implements IAuthService { Future ensureKdfHealthy() async { // Single-flight guard: if a health check is already in progress, return that future if (_ongoingHealthCheck != null) { - _logger.info('[$_sessionId] ensureKdfHealthy: Health check already in progress, awaiting result'); + _logger.info( + '[$_sessionId] ensureKdfHealthy: Health check already in progress, awaiting result', + ); return _ongoingHealthCheck!; } @@ -597,7 +608,9 @@ class KdfAuthService implements IAuthService { if (_lastHealthCheckCompleted != null) { final timeSinceLastCheck = now.difference(_lastHealthCheckCompleted!); if (timeSinceLastCheck.inSeconds < 2) { - _logger.info('[$_sessionId] ensureKdfHealthy: In cooldown period (${timeSinceLastCheck.inSeconds}s since last check)'); + _logger.info( + '[$_sessionId] ensureKdfHealthy: In cooldown period (${timeSinceLastCheck.inSeconds}s since last check)', + ); return false; } } @@ -609,8 +622,12 @@ class KdfAuthService implements IAuthService { try { final result = await _ongoingHealthCheck!; _lastHealthCheckCompleted = DateTime.now(); - final elapsed = _lastHealthCheckCompleted!.difference(_lastHealthCheckAttempt!); - _logger.info('[$_sessionId] ensureKdfHealthy: Completed in ${elapsed.inMilliseconds}ms, result=$result'); + final elapsed = _lastHealthCheckCompleted!.difference( + _lastHealthCheckAttempt!, + ); + _logger.info( + '[$_sessionId] ensureKdfHealthy: Completed in ${elapsed.inMilliseconds}ms, result=$result', + ); return result; } finally { // Clear the ongoing check flag when done @@ -621,60 +638,80 @@ class KdfAuthService implements IAuthService { Future _performHealthCheck() async { _logger.info('[$_sessionId] _performHealthCheck: Starting health check'); final stopwatch = Stopwatch()..start(); - + try { // First check if KDF is healthy with a short timeout final isHealthy = await _kdfFramework.isHealthy().timeout( const Duration(seconds: 2), onTimeout: () { - _logger.warning('[$_sessionId] _performHealthCheck: isHealthy() timed out after 2s'); + _logger.warning( + '[$_sessionId] _performHealthCheck: isHealthy() timed out after 2s', + ); return false; }, ); - + if (isHealthy) { // Double verification: even if isHealthy() returns true, verify with version() RPC // This prevents false positives where native status reports "running" but HTTP is down - _logger.info('[$_sessionId] _performHealthCheck: Initial check passed, performing double verification'); + _logger.info( + '[$_sessionId] _performHealthCheck: Initial check passed, performing double verification', + ); final doubleCheck = await _verifyKdfHealthy().timeout( const Duration(seconds: 2), onTimeout: () { - _logger.warning('[$_sessionId] _performHealthCheck: Double verification timed out'); + _logger.warning( + '[$_sessionId] _performHealthCheck: Double verification timed out', + ); return false; }, ); - + if (doubleCheck) { stopwatch.stop(); - _logger.info('[$_sessionId] _performHealthCheck: KDF is healthy (double verified) in ${stopwatch.elapsedMilliseconds}ms'); + _logger.info( + '[$_sessionId] _performHealthCheck: KDF is healthy (double verified) in ${stopwatch.elapsedMilliseconds}ms', + ); return true; } - - _logger.warning('[$_sessionId] _performHealthCheck: Double verification failed, KDF not actually healthy'); + + _logger.warning( + '[$_sessionId] _performHealthCheck: Double verification failed, KDF not actually healthy', + ); } - _logger.warning('[$_sessionId] _performHealthCheck: KDF is not healthy, forcing full restart'); + _logger.warning( + '[$_sessionId] _performHealthCheck: KDF is not healthy, forcing full restart', + ); // Use _lastEmittedUser instead of calling _getActiveUser() RPC when KDF is down // This avoids blocking on a dead KDF final hadAuthenticatedUser = _lastEmittedUser != null; - _logger.info('[$_sessionId] _performHealthCheck: hadAuthenticatedUser=$hadAuthenticatedUser'); + _logger.info( + '[$_sessionId] _performHealthCheck: hadAuthenticatedUser=$hadAuthenticatedUser', + ); // FORCE a full stop->start cycle when we've determined KDF is unhealthy // Don't trust isRunning() as it can be stale after iOS backgrounding - _logger.info('[$_sessionId] _performHealthCheck: Forcing clean shutdown (ignoring isRunning status)'); + _logger.info( + '[$_sessionId] _performHealthCheck: Forcing clean shutdown (ignoring isRunning status)', + ); try { await _stopKdf().timeout( const Duration(seconds: 2), onTimeout: () { - _logger.warning('[$_sessionId] _performHealthCheck: kdfStop() timed out'); + _logger.warning( + '[$_sessionId] _performHealthCheck: kdfStop() timed out', + ); }, ); } catch (e) { - _logger.warning('[$_sessionId] _performHealthCheck: Error during shutdown: $e (continuing with restart)'); + _logger.warning( + '[$_sessionId] _performHealthCheck: Error during shutdown: $e (continuing with restart)', + ); // KDF might already be dead, continue with restart } - + // Reset HTTP client unconditionally to drop stale keep-alive connections _logger.info('[$_sessionId] _performHealthCheck: Resetting HTTP client'); _kdfFramework.resetHttpClient(); @@ -685,46 +722,66 @@ class KdfAuthService implements IAuthService { final restartStopwatch = Stopwatch()..start(); await _forceStartKdf(); restartStopwatch.stop(); - _logger.info('[$_sessionId] _performHealthCheck: KDF force start completed in ${restartStopwatch.elapsedMilliseconds}ms'); + _logger.info( + '[$_sessionId] _performHealthCheck: KDF force start completed in ${restartStopwatch.elapsedMilliseconds}ms', + ); // Reset HTTP client again after restart to ensure no stale sockets - _logger.info('[$_sessionId] _performHealthCheck: Resetting HTTP client again after restart'); + _logger.info( + '[$_sessionId] _performHealthCheck: Resetting HTTP client again after restart', + ); _kdfFramework.resetHttpClient(); // Add 200ms delay after restart before verification to avoid race where // native status reports "up" but HTTP listener hasn't bound yet - _logger.info('[$_sessionId] _performHealthCheck: Waiting 200ms for HTTP listener to bind'); + _logger.info( + '[$_sessionId] _performHealthCheck: Waiting 200ms for HTTP listener to bind', + ); await Future.delayed(const Duration(milliseconds: 200)); // Check if restart was successful with a strong health check (version RPC) - _logger.info('[$_sessionId] _performHealthCheck: Verifying KDF health with version check'); + _logger.info( + '[$_sessionId] _performHealthCheck: Verifying KDF health with version check', + ); final verifyStopwatch = Stopwatch()..start(); final isHealthyAfterRestart = await _verifyKdfHealthy().timeout( const Duration(seconds: 2), onTimeout: () { - _logger.warning('[$_sessionId] _performHealthCheck: Health verification timed out'); + _logger.warning( + '[$_sessionId] _performHealthCheck: Health verification timed out', + ); return false; }, ); verifyStopwatch.stop(); - _logger.info('[$_sessionId] _performHealthCheck: Health verification took ${verifyStopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart'); + _logger.info( + '[$_sessionId] _performHealthCheck: Health verification took ${verifyStopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart', + ); // If we had an authenticated user, emit logged-out state // This will trigger the UI to show re-authentication prompt if (hadAuthenticatedUser && _lastEmittedUser != null) { - _logger.info('[$_sessionId] _performHealthCheck: Emitting logged-out state'); + _logger.info( + '[$_sessionId] _performHealthCheck: Emitting logged-out state', + ); _emitAuthStateChange(null); } stopwatch.stop(); - _logger.info('[$_sessionId] _performHealthCheck: Health check completed in ${stopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart'); + _logger.info( + '[$_sessionId] _performHealthCheck: Health check completed in ${stopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart', + ); return isHealthyAfterRestart; } catch (e) { stopwatch.stop(); - _logger.severe('[$_sessionId] _performHealthCheck: Error during health check after ${stopwatch.elapsedMilliseconds}ms: $e'); + _logger.severe( + '[$_sessionId] _performHealthCheck: Error during health check after ${stopwatch.elapsedMilliseconds}ms: $e', + ); // If we can't restart KDF and had an authenticated user, emit logged-out state if (_lastEmittedUser != null) { - _logger.info('[$_sessionId] _performHealthCheck: Emitting logged-out state due to error'); + _logger.info( + '[$_sessionId] _performHealthCheck: Emitting logged-out state due to error', + ); _emitAuthStateChange(null); } // Log the error but don't throw - return false to indicate failure @@ -735,23 +792,35 @@ class KdfAuthService implements IAuthService { /// Force starts KDF without checking isRunning() status /// This is needed when we've determined KDF is unhealthy but isRunning() returns stale true Future _forceStartKdf() async { - _logger.info('[$_sessionId] _forceStartKdf: Starting KDF (bypassing isRunning check)'); + _logger.info( + '[$_sessionId] _forceStartKdf: Starting KDF (bypassing isRunning check)', + ); await _lockWriteOperation(() async { final startStopwatch = Stopwatch()..start(); final result = await _kdfFramework.startKdf(await _noAuthConfig); startStopwatch.stop(); - _logger.info('[$_sessionId] _forceStartKdf: startKdf() returned ${result.name} in ${startStopwatch.elapsedMilliseconds}ms'); - + _logger.info( + '[$_sessionId] _forceStartKdf: startKdf() returned ${result.name} in ${startStopwatch.elapsedMilliseconds}ms', + ); + if (!result.isStartingOrAlreadyRunning()) { - _logger.severe('[$_sessionId] _forceStartKdf: Failed to start KDF: ${result.name}'); + _logger.severe( + '[$_sessionId] _forceStartKdf: Failed to start KDF: ${result.name}', + ); throw KdfExtensions._mapStartupErrorToAuthException(result); } - + _logger.info('[$_sessionId] _forceStartKdf: Waiting for RPC to be up'); final waitStopwatch = Stopwatch()..start(); await _waitUntilKdfRpcIsUp(); waitStopwatch.stop(); - _logger.info('[$_sessionId] _forceStartKdf: RPC is up after ${waitStopwatch.elapsedMilliseconds}ms'); + _logger.info( + '[$_sessionId] _forceStartKdf: RPC is up after ${waitStopwatch.elapsedMilliseconds}ms', + ); + + // Retry enabling shutdown stream now that KDF is running + _logger.info('[$_sessionId] _forceStartKdf: Enabling shutdown stream'); + await _enableShutdownStream(); }); } @@ -763,7 +832,9 @@ class KdfAuthService implements IAuthService { await _kdfFramework.version(); return true; } catch (e) { - _logger.warning('[$_sessionId] _verifyKdfHealthy: Version check failed: $e'); + _logger.warning( + '[$_sessionId] _verifyKdfHealthy: Version check failed: $e', + ); return false; } } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart index 8687f42cc..3c3a6c767 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart @@ -70,6 +70,8 @@ extension KdfExtensions on KdfAuthService { await _lockWriteOperation(() async { await _kdfFramework.startKdf(await _noAuthConfig); await _waitUntilKdfRpcIsUp(); + // Retry enabling shutdown stream now that KDF is running + await _enableShutdownStream(); }); } } @@ -84,6 +86,8 @@ extension KdfExtensions on KdfAuthService { } await _waitUntilKdfRpcIsUp(); + // Retry enabling shutdown stream now that KDF is running + await _enableShutdownStream(); } static AuthException _mapStartupErrorToAuthException( diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index 36dcbbff6..05add7038 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -26,6 +26,9 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { /// /// This provides near-instant detection of KDF shutdown (< 1 second) compared /// to the periodic health check (up to 30 minutes delay). + /// + /// Note: This is called once during initialization. If KDF is not running at + /// that time, [_enableShutdownStream] is retried after KDF successfully starts. void _subscribeToShutdownSignals() { _shutdownSubscription?.cancel(); @@ -45,6 +48,7 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { // Enable the shutdown signal stream on KDF // Note: This is fire-and-forget; if it fails, we'll rely on health checks + // and retry when KDF starts _enableShutdownStream().catchError((Object error) { _logger.warning( 'Failed to enable shutdown signal stream, ' @@ -54,6 +58,10 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { } /// Enables the shutdown signal stream on KDF. + /// + /// This is called once during service initialization and retried after KDF + /// successfully starts to ensure the stream is enabled even if KDF was not + /// running during initialization. Future _enableShutdownStream() async { // TODO: Remove if/when shutdown signal stream is supported on Web // and Windows @@ -87,6 +95,17 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { if (_lastEmittedUser != null) { _emitAuthStateChange(null); } + + // On iOS, trigger app restart for shutdown signals + // This handles cases where KDF receives an OS shutdown signal + _handleShutdownSignalRestart(event); + } + + /// Triggers an iOS app restart when a shutdown signal is received. + void _handleShutdownSignalRestart(ShutdownSignalEvent event) { + // The actual implementation is in KomodoDefiFramework + // to avoid circular dependencies + _kdfFramework.handleShutdownSignalForRestart(event); } Future _checkKdfHealth() async {