From 67f6e5034f088c234065d4f2ff7c7feccb05793f Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:59:55 +0100 Subject: [PATCH 1/2] feat(ios): add automatic app restart handling for KDF fatal errors Implement automatic app restart mechanism for iOS when KDF encounters fatal transport errors or shutdown signals. This resolves issues where the app becomes unresponsive after KDF terminates unexpectedly. Changes: - Add KdfRestartHandler Swift plugin for iOS app restart via exit(0) - Add IosRestartHandler Dart wrapper with platform channel communication - Detect broken pipe errors (errno 32) and trigger iOS restart in KDF framework - Add shutdown signal handling to trigger restart via auth service - Improve event streaming service with configurable debug logging - Add retry logic for enabling shutdown stream after KDF startup - Enhance HTTP client error detection for fatal transport errors Technical Details: - iOS restart uses exit(0) requiring manual app reopen (iOS limitation) - Broken pipe detection covers errno 32, 54, 60, 61 socket errors - Single-flight health check pattern prevents concurrent checks - Event streaming service now logs event types and processing times Fixes app hangs after backgrounding and KDF termination on iOS. --- .../assets/web/event_streaming_worker.js | 17 +- .../ios/Classes/KdfRestartHandler.swift | 69 ++++++++ .../Classes/KomodoDefiFrameworkPlugin.swift | 11 ++ .../lib/komodo_defi_framework.dart | 127 ++++++++++++-- .../lib/src/platform/ios_restart_handler.dart | 79 +++++++++ .../event_streaming_platform_io.dart | 32 ++-- .../event_streaming_platform_web.dart | 30 +++- .../streaming/event_streaming_service.dart | 48 +++++- .../lib/src/auth/auth_service.dart | 163 +++++++++++++----- .../src/auth/auth_service_kdf_extension.dart | 4 + .../auth_service_operations_extension.dart | 19 ++ 11 files changed, 508 insertions(+), 91 deletions(-) create mode 100644 packages/komodo_defi_framework/ios/Classes/KdfRestartHandler.swift create mode 100644 packages/komodo_defi_framework/ios/Classes/KomodoDefiFrameworkPlugin.swift create mode 100644 packages/komodo_defi_framework/lib/src/platform/ios_restart_handler.dart 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..aac35fcd8 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,13 +33,8 @@ 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 { @@ -66,16 +61,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 +90,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..badb7268c 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.info('[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_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 { From c36de3af37108f72e10536449124cbfaaf97c2af Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:46:22 +0100 Subject: [PATCH 2/2] fix: move streaming logging to main logger Move streaming logging to the main logger so that it shows in the log storage. --- .../lib/src/streaming/event_streaming_platform_io.dart | 4 ++++ .../lib/src/streaming/event_streaming_service.dart | 2 +- .../lib/src/streaming/events/kdf_event.dart | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) 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 aac35fcd8..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 @@ -38,6 +38,7 @@ EventStreamUnsubscribe connectEventStream({ 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 = @@ -53,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(); 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 badb7268c..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 @@ -100,7 +100,7 @@ class KdfEventStreamingService { ); if (KdfLoggingConfig.verboseLogging) { - _logger.info('[EventStream] Event payload: ${map.toJsonString()}'); + _logger.fine('[EventStream] Event payload: ${map.toJsonString()}'); } } 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); }