Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
};
69 changes: 69 additions & 0 deletions packages/komodo_defi_framework/ios/Classes/KdfRestartHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

127 changes: 111 additions & 16 deletions packages/komodo_defi_framework/lib/komodo_defi_framework.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:io' show Platform;

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/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';
Expand All @@ -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';
Expand Down Expand Up @@ -185,15 +189,21 @@ class KomodoDefiFramework implements ApiClient {

Future<String?> 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;
}
}
Expand All @@ -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).
Expand All @@ -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) {
Expand Down Expand Up @@ -276,32 +286,52 @@ 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
// errno 60 (ETIMEDOUT): Operation timed out
// 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',
Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<bool> 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<bool>(
'requestAppRestart',
<String, dynamic>{'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<bool> requestRestartForBrokenPipe() async {
return requestAppRestart(reason: 'broken_pipe');
}

/// Convenience method to request restart due to shutdown signal
Future<bool> requestRestartForShutdownSignal(String signalName) async {
return requestAppRestart(reason: 'shutdown_signal_$signalName');
}
}
Loading
Loading