diff --git a/miner-app/lib/features/miner/miner_balance_card.dart b/miner-app/lib/features/miner/miner_balance_card.dart index 36d8203e..f152360b 100644 --- a/miner-app/lib/features/miner/miner_balance_card.dart +++ b/miner-app/lib/features/miner/miner_balance_card.dart @@ -2,13 +2,23 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:polkadart/polkadart.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/miner_settings_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; import 'package:quantus_miner/src/shared/miner_app_constants.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/generated/schrodinger/schrodinger.dart'; + +final _log = log.withTag('BalanceCard'); class MinerBalanceCard extends StatefulWidget { - const MinerBalanceCard({super.key}); + /// Current block number - when this changes, balance is refreshed + final int currentBlock; + + const MinerBalanceCard({super.key, this.currentBlock = 0}); @override State createState() => _MinerBalanceCardState(); @@ -17,28 +27,48 @@ class MinerBalanceCard extends StatefulWidget { class _MinerBalanceCardState extends State { String _walletBalance = 'Loading...'; String? _walletAddress; + String _chainId = MinerConfig.defaultChainId; Timer? _balanceTimer; + final _settingsService = MinerSettingsService(); + int _lastRefreshedBlock = 0; @override void initState() { super.initState(); - _fetchWalletBalance(); - // Start automatic polling every 30 seconds - _balanceTimer = Timer.periodic(const Duration(seconds: 30), (_) { - _fetchWalletBalance(); + _loadChainAndFetchBalance(); + // Start automatic polling as backup + _balanceTimer = Timer.periodic(MinerConfig.balancePollingInterval, (_) { + _loadChainAndFetchBalance(); }); } + @override + void didUpdateWidget(MinerBalanceCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Refresh balance when block number increases (new block found) + if (widget.currentBlock > _lastRefreshedBlock && widget.currentBlock > 0) { + _lastRefreshedBlock = widget.currentBlock; + _loadChainAndFetchBalance(); + } + } + @override void dispose() { _balanceTimer?.cancel(); super.dispose(); } + Future _loadChainAndFetchBalance() async { + final chainId = await _settingsService.getChainId(); + if (mounted) { + setState(() => _chainId = chainId); + } + await _fetchWalletBalance(); + } + Future _fetchWalletBalance() async { - // Implement actual wallet balance fetching using quantus_sdk - print('fetching wallet balance'); + _log.d('Fetching wallet balance for chain: $_chainId'); try { final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); final rewardsFile = File('$quantusHome/rewards-address.txt'); @@ -47,42 +77,75 @@ class _MinerBalanceCardState extends State { final address = (await rewardsFile.readAsString()).trim(); if (address.isNotEmpty) { - print('address: $address'); + final chainConfig = MinerConfig.getChainById(_chainId); + _log.d('Chain: ${chainConfig.id}, rpcUrl: ${chainConfig.rpcUrl}, isLocal: ${chainConfig.isLocalNode}'); + BigInt balance; - // Fetch balance using SubstrateService (exported by quantus_sdk) - final balance = await SubstrateService().queryBalance(address); + if (chainConfig.isLocalNode) { + // Use local node RPC for dev chain + _log.d('Querying balance from local node: ${chainConfig.rpcUrl}'); + balance = await _queryBalanceFromLocalNode(address, chainConfig.rpcUrl); + } else { + // Use SDK's SubstrateService for remote chains (dirac) + _log.d('Querying balance from remote (SDK SubstrateService)'); + balance = await SubstrateService().queryBalance(address); + } - print('balance: $balance'); + _log.d('Balance: $balance'); - setState(() { - // Assuming NumberFormattingService and AppConstants are available via quantus_sdk export - _walletBalance = NumberFormattingService().formatBalance(balance, addSymbol: true); - _walletAddress = address; - }); + if (mounted) { + setState(() { + _walletBalance = NumberFormattingService().formatBalance(balance, addSymbol: true); + _walletAddress = address; + }); + } } else { - // Address file exists but is empty _handleAddressNotSet(); } } else { - // Address file does not exist _handleAddressNotSet(); } } catch (e) { - setState(() { - _walletBalance = 'Error fetching balance'; - }); - print('Error fetching wallet balance: $e'); + if (mounted) { + setState(() { + // Show helpful message for dev chain when node not running + if (_chainId == 'dev') { + _walletBalance = 'Start node to view'; + } else { + _walletBalance = 'Error'; + } + }); + } + _log.w('Error fetching wallet balance', error: e); + } + } + + /// Query balance directly from local node using Polkadart + Future _queryBalanceFromLocalNode(String address, String rpcUrl) async { + try { + final provider = Provider.fromUri(Uri.parse(rpcUrl)); + final quantusApi = Schrodinger(provider); + + // Convert SS58 address to account ID using the SDK's crypto + final accountId = ss58ToAccountId(s: address); + + final accountInfo = await quantusApi.query.system.account(accountId); + return accountInfo.data.free; + } catch (e) { + _log.d('Error querying local node balance: $e'); + // Return zero if node is not running or address has no balance + return BigInt.zero; } } void _handleAddressNotSet() { - setState(() { - _walletBalance = 'Address not set'; - _walletAddress = null; - }); - print('Rewards address file not found or empty.'); - // Example Navigation (requires go_router setup) - // context.go('/rewards_address_setup'); + if (mounted) { + setState(() { + _walletBalance = 'Address not set'; + _walletAddress = null; + }); + } + _log.w('Rewards address file not found or empty'); } @override diff --git a/miner-app/lib/features/miner/miner_controls.dart b/miner-app/lib/features/miner/miner_controls.dart index bf8a3e9e..49397997 100644 --- a/miner-app/lib/features/miner/miner_controls.dart +++ b/miner-app/lib/features/miner/miner_controls.dart @@ -2,27 +2,29 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/services/mining_orchestrator.dart'; import 'package:quantus_miner/src/services/mining_stats_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import '../../main.dart'; import '../../src/services/binary_manager.dart'; import '../../src/services/gpu_detection_service.dart'; -import '../../src/services/miner_process.dart'; import '../../src/services/miner_settings_service.dart'; +final _log = log.withTag('MinerControls'); + class MinerControls extends StatefulWidget { - final MinerProcess? minerProcess; + final MiningOrchestrator? orchestrator; final MiningStats miningStats; - final Function(MiningStats) onMetricsUpdate; - final Function(MinerProcess?) onMinerProcessChanged; + final Function(MiningOrchestrator?) onOrchestratorChanged; const MinerControls({ super.key, - required this.minerProcess, + required this.orchestrator, required this.miningStats, - required this.onMetricsUpdate, - required this.onMinerProcessChanged, + required this.onOrchestratorChanged, }); @override @@ -30,10 +32,12 @@ class MinerControls extends StatefulWidget { } class _MinerControlsState extends State { - bool _isAttemptingToggle = false; + bool _isNodeToggling = false; + bool _isMinerToggling = false; int _cpuWorkers = 8; int _gpuDevices = 0; int _detectedGpuCount = 0; + String _chainId = MinerConfig.defaultChainId; final _settingsService = MinerSettingsService(); @override @@ -46,11 +50,13 @@ class _MinerControlsState extends State { Future _loadSettings() async { final savedCpuWorkers = await _settingsService.getCpuWorkers(); final savedGpuDevices = await _settingsService.getGpuDevices(); + final savedChainId = await _settingsService.getChainId(); if (mounted) { setState(() { _cpuWorkers = savedCpuWorkers ?? (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 8); _gpuDevices = savedGpuDevices ?? 0; + _chainId = savedChainId; }); } } @@ -64,113 +70,205 @@ class _MinerControlsState extends State { } } - Future _toggle() async { - if (_isAttemptingToggle) return; - setState(() => _isAttemptingToggle = true); - - if (widget.minerProcess == null) { - print('Starting mining'); - - // Check for all required files and binaries - final id = File('${await BinaryManager.getQuantusHomeDirectoryPath()}/node_key.p2p'); - final rew = File('${await BinaryManager.getQuantusHomeDirectoryPath()}/rewards-address.txt'); - final binPath = await BinaryManager.getNodeBinaryFilePath(); - final bin = File(binPath); - final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); - final minerBin = File(minerBinPath); - - // Check node binary - if (!await bin.exists()) { - print('Node binary not found. Cannot start mining.'); - if (mounted) { - context.showWarningSnackbar(title: 'Node binary not found!', message: 'Please run setup.'); - } - setState(() => _isAttemptingToggle = false); - return; - } + // ============================================================ + // Node Control + // ============================================================ + + Future _toggleNode() async { + if (_isNodeToggling) return; + setState(() => _isNodeToggling = true); + + if (!_isNodeRunning) { + await _startNode(); + } else { + await _stopNode(); + } + + if (mounted) { + setState(() => _isNodeToggling = false); + } + } + + Future _startNode() async { + _log.i('Starting node'); + + // Reload chain ID in case it was changed in settings + final chainId = await _settingsService.getChainId(); + if (mounted) { + setState(() => _chainId = chainId); + } - // Check external miner binary - if (!await minerBin.exists()) { - print('External miner binary not found. Cannot start mining.'); - if (mounted) { - context.showWarningSnackbar(title: 'External miner binary not found!', message: 'Please run setup.'); - } - setState(() => _isAttemptingToggle = false); - return; + // Check for required files + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final identityFile = File('$quantusHome/node_key.p2p'); + final rewardsFile = File('$quantusHome/rewards-address.txt'); + final nodeBinPath = await BinaryManager.getNodeBinaryFilePath(); + final nodeBin = File(nodeBinPath); + final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final minerBin = File(minerBinPath); + + if (!await nodeBin.exists()) { + _log.w('Node binary not found'); + if (mounted) { + context.showWarningSnackbar(title: 'Node binary not found!', message: 'Please run setup.'); } + return; + } - final newProc = MinerProcess( - bin, - id, - rew, - onStatsUpdate: widget.onMetricsUpdate, - cpuWorkers: _cpuWorkers, - gpuDevices: _gpuDevices, - detectedGpuCount: _detectedGpuCount, + // Create new orchestrator + final orchestrator = MiningOrchestrator(); + widget.onOrchestratorChanged(orchestrator); + + try { + await orchestrator.startNode( + MiningSessionConfig( + nodeBinary: nodeBin, + minerBinary: minerBin, + identityFile: identityFile, + rewardsFile: rewardsFile, + chainId: _chainId, + cpuWorkers: _cpuWorkers, + gpuDevices: _gpuDevices, + detectedGpuCount: _detectedGpuCount, + ), ); - // Notify parent about the new miner process - widget.onMinerProcessChanged.call(newProc); + } catch (e) { + _log.e('Error starting node', error: e); + if (mounted) { + context.showErrorSnackbar(title: 'Error starting node!', message: e.toString()); + } + orchestrator.dispose(); + widget.onOrchestratorChanged(null); + } + } + + Future _stopNode() async { + _log.i('Stopping node'); + if (widget.orchestrator != null) { try { - final newMiningStats = widget.miningStats.copyWith(isSyncing: true, status: MiningStatus.syncing); - widget.onMetricsUpdate(newMiningStats); - await newProc.start(); + await widget.orchestrator!.stopNode(); } catch (e) { - print('Error starting miner process: $e'); - if (mounted) { - context.showErrorSnackbar(title: 'Error starting miner!', message: e.toString()); - } - - // Notify parent that miner process is null - widget.onMinerProcessChanged.call(null); - final newMiningStats = MiningStats.empty(); - widget.onMetricsUpdate(newMiningStats); + _log.e('Error stopping node', error: e); } + widget.orchestrator!.dispose(); + } + + await GlobalMinerManager.cleanup(); + widget.onOrchestratorChanged(null); + } + + // ============================================================ + // Miner Control + // ============================================================ + + Future _toggleMiner() async { + if (_isMinerToggling) return; + setState(() => _isMinerToggling = true); + + if (!_isMining) { + await _startMiner(); } else { - print('Stopping mining'); + await _stopMiner(); + } - try { - widget.minerProcess!.stop(); - // Wait a moment for graceful shutdown - await Future.delayed(const Duration(seconds: 1)); - } catch (e) { - print('Error during graceful stop: $e'); + if (mounted) { + setState(() => _isMinerToggling = false); + } + } + + Future _startMiner() async { + _log.i('Starting miner'); + + if (widget.orchestrator == null) { + if (mounted) { + context.showWarningSnackbar(title: 'Node not running!', message: 'Start the node first.'); } + return; + } - await GlobalMinerManager.cleanup(); + // Check miner binary exists + final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final minerBin = File(minerBinPath); - // Notify parent that miner process is stopped - widget.onMinerProcessChanged.call(null); - final newMiningStats = MiningStats.empty(); - widget.onMetricsUpdate(newMiningStats); + if (!await minerBin.exists()) { + _log.w('Miner binary not found'); + if (mounted) { + context.showWarningSnackbar(title: 'Miner binary not found!', message: 'Please run setup.'); + } + return; } - if (mounted) { - setState(() => _isAttemptingToggle = false); + + try { + // Update settings in case they changed while miner was stopped + widget.orchestrator!.updateMinerSettings(cpuWorkers: _cpuWorkers, gpuDevices: _gpuDevices); + + await widget.orchestrator!.startMiner(); + } catch (e) { + _log.e('Error starting miner', error: e); + if (mounted) { + context.showErrorSnackbar(title: 'Error starting miner!', message: e.toString()); + } } } - @override - void dispose() { - // _poll?.cancel(); // _poll removed - if (widget.minerProcess != null) { - print('MinerControls: disposing, force stopping miner process'); + Future _stopMiner() async { + _log.i('Stopping miner'); + if (widget.orchestrator != null) { try { - widget.minerProcess!.forceStop(); + await widget.orchestrator!.stopMiner(); } catch (e) { - print('MinerControls: Error force stopping miner process in dispose: $e'); + _log.e('Error stopping miner', error: e); } + } + } - // Use GlobalMinerManager for comprehensive cleanup - GlobalMinerManager.cleanup(); + // ============================================================ + // State Helpers + // ============================================================ - widget.onMinerProcessChanged.call(null); - } - super.dispose(); + bool get _isNodeRunning => widget.orchestrator?.isNodeRunning ?? false; + bool get _isMining => widget.orchestrator?.isMining ?? false; + + /// Whether miner is starting or running (for disabling settings) + bool get _isMinerActive { + final state = widget.orchestrator?.state; + return state == MiningState.startingMiner || state == MiningState.mining || state == MiningState.stoppingMiner; + } + + String get _nodeButtonText { + final state = widget.orchestrator?.state; + if (state == MiningState.startingNode) return 'Starting...'; + if (state == MiningState.waitingForRpc) return 'Connecting...'; + if (_isNodeRunning) return 'Stop Node'; + return 'Start Node'; + } + + String get _minerButtonText { + final state = widget.orchestrator?.state; + if (state == MiningState.startingMiner) return 'Starting...'; + if (state == MiningState.stoppingMiner) return 'Stopping...'; + if (_isMining) return 'Stop Mining'; + return 'Start Mining'; + } + + Color get _nodeButtonColor { + if (_isNodeRunning) return Colors.orange; + return Colors.blue; + } + + Color get _minerButtonColor { + if (_isMining) return Colors.red; + return Colors.green; } @override Widget build(BuildContext context) { + // Allow editing settings when miner is stopped (even if node is running) + // Disable during startingMiner, mining, and stoppingMiner states + final canEditSettings = !_isMinerActive; + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -193,7 +291,7 @@ class _MinerControlsState extends State { max: (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 16).toDouble(), divisions: (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 16), label: _cpuWorkers.toString(), - onChanged: widget.minerProcess == null + onChanged: canEditSettings ? (value) { final rounded = value.round(); setState(() => _cpuWorkers = rounded); @@ -205,6 +303,7 @@ class _MinerControlsState extends State { ), ), const SizedBox(height: 16), + // GPU Devices Control Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), @@ -224,7 +323,7 @@ class _MinerControlsState extends State { max: _detectedGpuCount > 0 ? _detectedGpuCount.toDouble() : 1, divisions: _detectedGpuCount > 0 ? _detectedGpuCount : 1, label: _gpuDevices.toString(), - onChanged: widget.minerProcess == null + onChanged: canEditSettings ? (value) { final rounded = value.round(); setState(() => _gpuDevices = rounded); @@ -236,16 +335,43 @@ class _MinerControlsState extends State { ), ), const SizedBox(height: 24), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: widget.minerProcess == null ? Colors.green : Colors.blue, - padding: const EdgeInsets.symmetric(vertical: 15), - textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - minimumSize: const Size(200, 50), - ), - onPressed: _isAttemptingToggle ? null : _toggle, - child: Text(widget.minerProcess == null ? 'Start Mining' : 'Stop Mining'), + + // Control Buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Node Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _nodeButtonColor, + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + minimumSize: const Size(140, 50), + ), + onPressed: _isNodeToggling ? null : _toggleNode, + child: Text(_nodeButtonText), + ), + const SizedBox(width: 16), + + // Miner Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _minerButtonColor, + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + minimumSize: const Size(140, 50), + ), + onPressed: (_isMinerToggling || !_isNodeRunning) ? null : _toggleMiner, + child: Text(_minerButtonText), + ), + ], ), + + // Status indicator + if (_isNodeRunning && !_isMining) ...[ + const SizedBox(height: 12), + Text('Node running - ready to mine', style: TextStyle(color: Colors.green.shade300, fontSize: 12)), + ], ], ); } diff --git a/miner-app/lib/features/miner/miner_dashboard_screen.dart b/miner-app/lib/features/miner/miner_dashboard_screen.dart index aac149c2..0eb05041 100644 --- a/miner-app/lib/features/miner/miner_dashboard_screen.dart +++ b/miner-app/lib/features/miner/miner_dashboard_screen.dart @@ -5,8 +5,9 @@ import 'package:quantus_miner/features/miner/miner_balance_card.dart'; import 'package:quantus_miner/features/miner/miner_app_bar.dart'; import 'package:quantus_miner/features/miner/miner_stats_card.dart'; import 'package:quantus_miner/features/miner/miner_status.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; -import 'package:quantus_miner/src/services/miner_process.dart'; +import 'package:quantus_miner/src/services/mining_orchestrator.dart'; import 'package:quantus_miner/src/services/mining_stats_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; import 'package:quantus_miner/src/ui/logs_widget.dart'; @@ -34,25 +35,32 @@ class _MinerDashboardScreenState extends State { Timer? _nodePollingTimer; MiningStats _miningStats = MiningStats.empty(); - MinerProcess? _currentMinerProcess; + + // The orchestrator manages all mining operations + MiningOrchestrator? _orchestrator; + + // Subscriptions + StreamSubscription? _statsSubscription; + StreamSubscription? _errorSubscription; + StreamSubscription? _stateSubscription; @override void initState() { super.initState(); - _initializeNodeUpdatePolling(); _initializeMinerUpdatePolling(); } @override void dispose() { - // Clean up global miner process - if (_currentMinerProcess != null) { - try { - _currentMinerProcess!.forceStop(); - } catch (e) { - print('MinerDashboard: Error stopping miner process on dispose: $e'); - } + // Clean up subscriptions + _statsSubscription?.cancel(); + _errorSubscription?.cancel(); + _stateSubscription?.cancel(); + + // Clean up orchestrator + if (_orchestrator != null) { + _orchestrator!.forceStop(); } GlobalMinerManager.cleanup(); @@ -62,21 +70,69 @@ class _MinerDashboardScreenState extends State { super.dispose(); } - void _onMetricsUpdate(MiningStats miningStats) { - setState(() { - _miningStats = miningStats; - }); + void _onStatsUpdate(MiningStats stats) { + if (mounted) { + setState(() { + _miningStats = stats; + }); + } } - void _onMinerProcessChanged(MinerProcess? minerProcess) { + void _onOrchestratorChanged(MiningOrchestrator? orchestrator) { + // Cancel old subscriptions + _statsSubscription?.cancel(); + _errorSubscription?.cancel(); + _stateSubscription?.cancel(); + if (mounted) { setState(() { - _currentMinerProcess = minerProcess; + _orchestrator = orchestrator; }); } - // Register with global app lifecycle for cleanup - GlobalMinerManager.setMinerProcess(minerProcess); + // Set up new subscriptions + if (orchestrator != null) { + _statsSubscription = orchestrator.statsStream.listen(_onStatsUpdate); + _errorSubscription = orchestrator.errorStream.listen(_onError); + _stateSubscription = orchestrator.stateStream.listen(_onStateChange); + } + + // Register with global manager for cleanup + GlobalMinerManager.setOrchestrator(orchestrator); + } + + void _onStateChange(MiningState state) { + // Trigger rebuild when orchestrator state changes + // This ensures button labels and UI state update properly + if (mounted) { + setState(() {}); + } + } + + void _onError(MinerError error) { + if (!mounted) return; + + // Show error to user + context.showErrorSnackbar(title: _getErrorTitle(error), message: error.message); + } + + String _getErrorTitle(MinerError error) { + switch (error.type) { + case MinerErrorType.minerCrashed: + return 'Miner Crashed'; + case MinerErrorType.nodeCrashed: + return 'Node Crashed'; + case MinerErrorType.minerStartupFailed: + return 'Miner Startup Failed'; + case MinerErrorType.nodeStartupFailed: + return 'Node Startup Failed'; + case MinerErrorType.metricsConnectionLost: + return 'Metrics Connection Lost'; + case MinerErrorType.rpcConnectionLost: + return 'RPC Connection Lost'; + case MinerErrorType.unknown: + return 'Error'; + } } void _initializeMinerUpdatePolling() { @@ -114,7 +170,7 @@ class _MinerDashboardScreenState extends State { } void _handleUpdateMiner() async { - if (_currentMinerProcess != null) { + if (_orchestrator?.isMining == true) { context.showErrorSnackbar( title: 'Miner is running!', message: 'To update the binary please stop the miner first.', @@ -176,7 +232,7 @@ class _MinerDashboardScreenState extends State { } void _handleUpdateNode() async { - if (_currentMinerProcess != null) { + if (_orchestrator?.isMining == true) { context.showErrorSnackbar( title: 'Miner is running!', message: 'To update the binary please stop the miner first.', @@ -261,10 +317,9 @@ class _MinerDashboardScreenState extends State { child: SizedBox( width: double.infinity, child: MinerControls( - minerProcess: _currentMinerProcess, + orchestrator: _orchestrator, miningStats: _miningStats, - onMetricsUpdate: _onMetricsUpdate, - onMinerProcessChanged: _onMinerProcessChanged, + onOrchestratorChanged: _onOrchestratorChanged, ), ), ), @@ -312,7 +367,7 @@ class _MinerDashboardScreenState extends State { ), ), // Logs content - Expanded(child: LogsWidget(minerProcess: _currentMinerProcess, maxLines: 200)), + Expanded(child: LogsWidget(orchestrator: _orchestrator, maxLines: 200)), ], ), ), @@ -332,7 +387,7 @@ class _MinerDashboardScreenState extends State { if (constraints.maxWidth > 800) { return Row( children: [ - Expanded(child: MinerBalanceCard()), + Expanded(child: MinerBalanceCard(currentBlock: _miningStats.currentBlock)), const SizedBox(width: 16), Expanded(child: MinerStatsCard(miningStats: _miningStats)), ], @@ -340,7 +395,7 @@ class _MinerDashboardScreenState extends State { } else { return Column( children: [ - MinerBalanceCard(), + MinerBalanceCard(currentBlock: _miningStats.currentBlock), MinerStatsCard(miningStats: _miningStats), ], ); diff --git a/miner-app/lib/features/settings/settings_screen.dart b/miner-app/lib/features/settings/settings_screen.dart index 3d40bf27..c354afb3 100644 --- a/miner-app/lib/features/settings/settings_screen.dart +++ b/miner-app/lib/features/settings/settings_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:quantus_miner/features/settings/settings_app_bar.dart'; +import 'package:quantus_miner/main.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/miner_settings_service.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; class SettingsScreen extends StatefulWidget { @@ -15,32 +18,95 @@ class _SettingsScreenState extends State { BinaryVersion? _nodeUpdateInfo; bool _isLoading = true; + // Chain selection + final MinerSettingsService _settingsService = MinerSettingsService(); + String _selectedChainId = MinerConfig.defaultChainId; + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - _getBinaryInfo(); + _loadSettings(); }); } - Future _getBinaryInfo() async { - // Simulate a tiny delay for smooth UI transition if cached - // await Future.delayed(const Duration(milliseconds: 300)); - + Future _loadSettings() async { final [nodeUpdateInfo, minerUpdateInfo] = await Future.wait([ BinaryManager.getNodeBinaryVersion(), BinaryManager.getMinerBinaryVersion(), ]); + final chainId = await _settingsService.getChainId(); + if (mounted) { setState(() { _minerUpdateInfo = minerUpdateInfo; _nodeUpdateInfo = nodeUpdateInfo; + _selectedChainId = chainId; _isLoading = false; }); } } + Future _onChainChanged(String? newChainId) async { + if (newChainId == null || newChainId == _selectedChainId) return; + + // Check if mining is currently running + final orchestrator = GlobalMinerManager.getOrchestrator(); + final isMining = orchestrator?.isRunning ?? false; + + if (isMining) { + // Show warning dialog + final shouldChange = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1C1C1C), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Stop Mining?', style: TextStyle(color: Colors.white)), + content: const Text( + 'Changing the chain requires stopping mining first. ' + 'Do you want to stop mining and switch chains?', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('Cancel', style: TextStyle(color: Colors.white.useOpacity(0.7))), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: const Color(0xFF00E676)), + child: const Text('Stop & Switch'), + ), + ], + ), + ); + + if (shouldChange != true) return; + + // Stop mining + await orchestrator?.stop(); + } + + // Save the new chain ID + await _settingsService.saveChainId(newChainId); + + if (mounted) { + setState(() { + _selectedChainId = newChainId; + }); + + // Show confirmation + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Switched to ${MinerConfig.getChainById(newChainId).displayName}'), + backgroundColor: const Color(0xFF00E676), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + @override Widget build(BuildContext context) { // Define a theme-consistent accent color (e.g., a tech green or teal) @@ -108,8 +174,22 @@ class _SettingsScreenState extends State { const SizedBox(height: 32), - // Example: You could add another section here later - // Text('ACCOUNT', style: ...), + // Network Section Header + Text( + 'NETWORK', + style: TextStyle( + color: Colors.white.useOpacity(0.5), + fontSize: 12, + letterSpacing: 1.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + + // Chain Selector + _buildChainSelector(accentColor), + + const SizedBox(height: 32), ], ), ), @@ -184,4 +264,77 @@ class _SettingsScreenState extends State { ), ); } + + Widget _buildChainSelector(Color accentColor) { + final selectedChain = MinerConfig.getChainById(_selectedChainId); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1C1C1C), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.useOpacity(0.05), width: 1), + boxShadow: [BoxShadow(color: Colors.black.useOpacity(0.2), blurRadius: 10, offset: const Offset(0, 4))], + ), + child: Row( + children: [ + // Icon Container + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: accentColor.useOpacity(0.1), borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.link_rounded, color: accentColor, size: 20), + ), + const SizedBox(width: 16), + + // Title and description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Chain', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + Text(selectedChain.description, style: TextStyle(color: Colors.white.useOpacity(0.5), fontSize: 12)), + ], + ), + ), + + // Dropdown + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white.useOpacity(0.3)), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.useOpacity(0.1)), + ), + child: DropdownButton( + value: _selectedChainId, + dropdownColor: const Color(0xFF1C1C1C), + underline: const SizedBox(), + icon: Icon(Icons.arrow_drop_down, color: Colors.white.useOpacity(0.7)), + style: TextStyle( + color: Colors.white.useOpacity(0.9), + fontFamily: 'Courier', + fontWeight: FontWeight.bold, + fontSize: 13, + ), + items: MinerConfig.availableChains.map((chain) { + return DropdownMenuItem(value: chain.id, child: Text(chain.displayName)); + }).toList(), + onChanged: _onChainChanged, + ), + ), + ], + ), + ); + } } diff --git a/miner-app/lib/features/setup/rewards_address_setup_screen.dart b/miner-app/lib/features/setup/rewards_address_setup_screen.dart index 5c60d10f..dbe44c53 100644 --- a/miner-app/lib/features/setup/rewards_address_setup_screen.dart +++ b/miner-app/lib/features/setup/rewards_address_setup_screen.dart @@ -83,7 +83,7 @@ class _RewardsAddressSetupScreenState extends State { print('Rewards address saved: $address'); if (mounted) { - context.showSuccessBar(content: Text('Rewards address saved successfully!')); + context.showSuccessBar(content: const Text('Rewards address saved successfully!')); // Navigate to the main mining screen context.go('/miner_dashboard'); } diff --git a/miner-app/lib/main.dart b/miner-app/lib/main.dart index ae01d2be..765d0e79 100644 --- a/miner-app/lib/main.dart +++ b/miner-app/lib/main.dart @@ -8,71 +8,83 @@ import 'features/setup/node_identity_setup_screen.dart'; import 'features/setup/rewards_address_setup_screen.dart'; import 'features/miner/miner_dashboard_screen.dart'; import 'src/services/binary_manager.dart'; -import 'src/services/miner_process.dart'; +import 'src/services/mining_orchestrator.dart'; +import 'src/services/process_cleanup_service.dart'; +import 'src/utils/app_logger.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -/// Global class to manage miner process lifecycle +final _log = log.withTag('App'); + +/// Global class to manage mining orchestrator lifecycle. +/// +/// This is used for cleanup during app exit/detach events. class GlobalMinerManager { - static MinerProcess? _globalMinerProcess; + static MiningOrchestrator? _orchestrator; - static void setMinerProcess(MinerProcess? process) { - _globalMinerProcess = process; - print('GlobalMinerManager: Set miner process: ${process != null}'); + /// Register the active orchestrator for lifecycle management. + static void setOrchestrator(MiningOrchestrator? orchestrator) { + _orchestrator = orchestrator; + _log.d('Orchestrator registered: ${orchestrator != null}'); } - static MinerProcess? getMinerProcess() { - return _globalMinerProcess; + /// Get the current orchestrator, if any. + static MiningOrchestrator? getOrchestrator() { + return _orchestrator; } - static Future cleanup() async { - print('GlobalMinerManager: Starting cleanup...'); - if (_globalMinerProcess != null) { + /// Synchronous force stop for app detach scenarios. + /// + /// This is called from _onAppDetach which cannot be async. + /// It fires off process kills without waiting for completion. + static void forceStopAll() { + _log.i('Force stopping all processes (sync)...'); + if (_orchestrator != null) { try { - print('GlobalMinerManager: Force stopping global miner process'); - _globalMinerProcess!.forceStop(); - _globalMinerProcess = null; + _orchestrator!.forceStop(); + _orchestrator = null; } catch (e) { - print('GlobalMinerManager: Error stopping miner process: $e'); + _log.e('Error force stopping orchestrator', error: e); } } - // Kill any remaining quantus processes - await _killQuantusProcesses(); + // Fire and forget - kill any remaining quantus processes + ProcessCleanupService.killAllQuantusProcesses(); } - static Future _killQuantusProcesses() async { - try { - print('GlobalMinerManager: Killing quantus processes by name...'); - - // Kill all quantus processes - await Process.run('pkill', ['-9', '-f', 'quantus-node']); - await Process.run('pkill', ['-9', '-f', 'quantus-miner']); - - print('GlobalMinerManager: Cleanup commands executed'); - } catch (e) { - print('GlobalMinerManager: Error killing processes by name: $e'); + /// Cleanup all mining processes. + /// + /// Called during app exit (async context). + static Future cleanup() async { + _log.i('Starting global cleanup...'); + if (_orchestrator != null) { + try { + _orchestrator!.forceStop(); + _orchestrator = null; + } catch (e) { + _log.e('Error stopping orchestrator', error: e); + } } + + // Kill any remaining quantus processes using the cleanup service + await ProcessCleanupService.killAllQuantusProcesses(); } } Future initialRedirect(BuildContext context, GoRouterState state) async { final currentRoute = state.uri.toString(); - print('initialRedirect'); - // Check 1: Node Installed bool isNodeInstalled = false; try { isNodeInstalled = await BinaryManager.hasBinary(); - print('isNodeInstalled: $isNodeInstalled'); } catch (e) { - print('Error checking node installation status: $e'); + _log.e('Error checking node installation', error: e); isNodeInstalled = false; } if (!isNodeInstalled) { - print('node not installed, going to node setup'); + _log.d('Node not installed, redirecting to setup'); return (currentRoute == '/node_setup') ? null : '/node_setup'; } @@ -82,7 +94,7 @@ Future initialRedirect(BuildContext context, GoRouterState state) async final identityPath = '${await BinaryManager.getQuantusHomeDirectoryPath()}/node_key.p2p'; isIdentitySet = await File(identityPath).exists(); } catch (e) { - print('Error checking node identity status: $e'); + _log.e('Error checking node identity', error: e); isIdentitySet = false; } @@ -97,7 +109,7 @@ Future initialRedirect(BuildContext context, GoRouterState state) async final rewardsFile = File('$quantusHome/rewards-address.txt'); isRewardsAddressSet = await rewardsFile.exists(); } catch (e) { - print('Error checking rewards address status: $e'); + _log.e('Error checking rewards address', error: e); isRewardsAddressSet = false; } @@ -133,10 +145,9 @@ Future main() async { try { await QuantusSdk.init(); - print('SubstrateService and QuantusSdk initialized successfully.'); + _log.i('SDK initialized'); } catch (e) { - print('Error initializing SDK: $e'); - // Depending on the app, you might want to show an error UI or prevent app startup + _log.e('Error initializing SDK', error: e); } runApp(const MinerApp()); } @@ -170,31 +181,28 @@ class _MinerAppState extends State { } void _onAppDetach() { - print('App lifecycle: App detached, forcing cleanup...'); - GlobalMinerManager.cleanup(); + _log.i('App detached, cleaning up...'); + // Use synchronous force stop since _onAppDetach cannot be async + GlobalMinerManager.forceStopAll(); } Future _onExitRequested() async { - print('App lifecycle: Exit requested, cleaning up processes...'); + _log.i('Exit requested, cleaning up...'); try { await GlobalMinerManager.cleanup(); - print('App lifecycle: Cleanup completed, allowing exit'); return AppExitResponse.exit; } catch (e) { - print('App lifecycle: Error during cleanup: $e'); + _log.e('Error during exit cleanup', error: e); // Still allow exit even if cleanup fails return AppExitResponse.exit; } } void _onStateChanged(AppLifecycleState state) { - print('App lifecycle state changed to: $state'); - - if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { - print('App lifecycle: App backgrounded/detached, cleaning up...'); - GlobalMinerManager.cleanup(); - } + _log.d('Lifecycle state: $state'); + // Note: We intentionally do NOT cleanup on pause/background + // Mining should continue when the app is backgrounded } @override diff --git a/miner-app/lib/src/config/miner_config.dart b/miner-app/lib/src/config/miner_config.dart new file mode 100644 index 00000000..8884b23b --- /dev/null +++ b/miner-app/lib/src/config/miner_config.dart @@ -0,0 +1,187 @@ +/// Centralized configuration for the miner application. +/// +/// All ports, timeouts, URLs, and other constants should be defined here +/// rather than scattered throughout the codebase. +class MinerConfig { + MinerConfig._(); + + // ============================================================ + // Network Ports + // ============================================================ + + /// QUIC port for miner-to-node communication + static const int defaultQuicPort = 9833; + + /// Prometheus metrics port for the external miner + static const int defaultMinerMetricsPort = 9900; + + /// JSON-RPC port for the node + static const int defaultNodeRpcPort = 9933; + + /// Prometheus metrics port for the node + static const int defaultNodePrometheusPort = 9616; + + /// P2P port for node networking + static const int defaultNodeP2pPort = 30333; + + // ============================================================ + // Timeouts & Retry Configuration + // ============================================================ + + /// Time to wait for graceful process shutdown before force killing + static const Duration gracefulShutdownTimeout = Duration(seconds: 2); + + /// Initial delay between RPC connection retries + static const Duration rpcInitialRetryDelay = Duration(seconds: 2); + + /// Maximum delay between RPC connection retries (with exponential backoff) + static const Duration rpcMaxRetryDelay = Duration(seconds: 10); + + /// Maximum number of RPC connection attempts before giving up + static const int maxRpcRetries = 30; + + /// Number of consecutive metrics failures before resetting hashrate to zero + static const int maxConsecutiveMetricsFailures = 5; + + /// Delay after killing a process before checking if port is free + static const Duration portCleanupDelay = Duration(seconds: 1); + + /// Delay after process cleanup before continuing + static const Duration processCleanupDelay = Duration(seconds: 2); + + /// Timeout for force kill operations + static const Duration forceKillTimeout = Duration(seconds: 5); + + /// Delay for process verification after kill + static const Duration processVerificationDelay = Duration(milliseconds: 500); + + // ============================================================ + // Polling Intervals + // ============================================================ + + /// How often to poll external miner metrics endpoint + static const Duration metricsPollingInterval = Duration(seconds: 1); + + /// How often to poll node Prometheus metrics (for target block) + static const Duration prometheusPollingInterval = Duration(seconds: 3); + + /// How often to check for binary updates + static const Duration binaryUpdatePollingInterval = Duration(minutes: 30); + + /// How often to poll chain RPC for peer count and block info + static const Duration chainRpcPollingInterval = Duration(seconds: 1); + + /// How often to poll wallet balance (backup timer) + static const Duration balancePollingInterval = Duration(seconds: 30); + + // ============================================================ + // Hardware Detection + // ============================================================ + + /// Maximum number of GPU devices to probe for during detection + static const int maxGpuProbeCount = 8; + + // ============================================================ + // URLs & Endpoints + // ============================================================ + + /// Returns the miner metrics URL for a given port + static String minerMetricsUrl(int port) => 'http://127.0.0.1:$port/metrics'; + + /// Returns the node RPC URL for a given port + static String nodeRpcUrl(int port) => 'http://127.0.0.1:$port'; + + /// Returns the node Prometheus metrics URL for a given port + static String nodePrometheusUrl(int port) => 'http://127.0.0.1:$port/metrics'; + + /// Default localhost address for connections + static const String localhost = '127.0.0.1'; + + // ============================================================ + // Chain Configuration + // ============================================================ + + /// Available chain IDs + static const List availableChains = [ + ChainConfig( + id: 'dev', + displayName: 'Development', + description: 'Local development chain', + rpcUrl: 'http://127.0.0.1:9933', + isDefault: true, + ), + ChainConfig( + id: 'dirac', + displayName: 'Dirac', + description: 'Dirac testnet', + rpcUrl: 'https://a1-dirac.quantus.cat', + isDefault: false, + ), + ]; + + /// Get chain config by ID, returns dev chain if not found + static ChainConfig getChainById(String id) { + return availableChains.firstWhere((chain) => chain.id == id, orElse: () => availableChains.first); + } + + /// The default chain ID + static String get defaultChainId => availableChains.firstWhere((c) => c.isDefault).id; + + // ============================================================ + // Process Names (for cleanup) + // ============================================================ + + /// Node binary name (without extension) + static const String nodeBinaryName = 'quantus-node'; + + /// Miner binary name (without extension) + static const String minerBinaryName = 'quantus-miner'; + + /// Node binary name with Windows extension + static String get nodeBinaryNameWindows => '$nodeBinaryName.exe'; + + /// Miner binary name with Windows extension + static String get minerBinaryNameWindows => '$minerBinaryName.exe'; + + // ============================================================ + // Logging + // ============================================================ + + /// Maximum number of log lines to keep in memory + static const int maxLogLines = 200; + + /// Initial lines to print before filtering kicks in + static const int initialLinesToPrint = 50; + + // ============================================================ + // Port Range for Finding Alternatives + // ============================================================ + + /// Number of ports to try when finding an alternative + static const int portSearchRange = 10; +} + +/// Configuration for a blockchain network. +/// +/// Named ChainConfig to avoid conflict with ChainInfo in chain_rpc_client.dart +class ChainConfig { + final String id; + final String displayName; + final String description; + final String rpcUrl; + final bool isDefault; + + const ChainConfig({ + required this.id, + required this.displayName, + required this.description, + required this.rpcUrl, + required this.isDefault, + }); + + /// Whether this chain uses the local node RPC + bool get isLocalNode => rpcUrl.contains('127.0.0.1') || rpcUrl.contains('localhost'); + + @override + String toString() => 'ChainConfig(id: $id, displayName: $displayName, rpcUrl: $rpcUrl)'; +} diff --git a/miner-app/lib/src/models/miner_error.dart b/miner-app/lib/src/models/miner_error.dart new file mode 100644 index 00000000..edc9daad --- /dev/null +++ b/miner-app/lib/src/models/miner_error.dart @@ -0,0 +1,86 @@ +/// Types of errors that can occur during mining. +enum MinerErrorType { + /// The miner process crashed unexpectedly. + minerCrashed, + + /// The node process crashed unexpectedly. + nodeCrashed, + + /// Failed to start the miner process. + minerStartupFailed, + + /// Failed to start the node process. + nodeStartupFailed, + + /// Lost connection to the miner metrics endpoint. + metricsConnectionLost, + + /// Lost connection to the node RPC endpoint. + rpcConnectionLost, + + /// Generic/unknown error. + unknown, +} + +/// Represents an error that occurred during mining operations. +class MinerError { + /// The type of error. + final MinerErrorType type; + + /// Human-readable error message. + final String message; + + /// Process exit code, if applicable. + final int? exitCode; + + /// The underlying exception, if any. + final Object? exception; + + /// Stack trace, if available. + final StackTrace? stackTrace; + + /// When the error occurred. + final DateTime timestamp; + + MinerError({ + required this.type, + required this.message, + this.exitCode, + this.exception, + this.stackTrace, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + /// Create a miner crash error. + factory MinerError.minerCrashed(int exitCode) => MinerError( + type: MinerErrorType.minerCrashed, + message: 'Miner process crashed unexpectedly (exit code: $exitCode)', + exitCode: exitCode, + ); + + /// Create a node crash error. + factory MinerError.nodeCrashed(int exitCode) => MinerError( + type: MinerErrorType.nodeCrashed, + message: 'Node process crashed unexpectedly (exit code: $exitCode)', + exitCode: exitCode, + ); + + /// Create a miner startup failure error. + factory MinerError.minerStartupFailed(Object error, [StackTrace? stackTrace]) => MinerError( + type: MinerErrorType.minerStartupFailed, + message: 'Failed to start miner: $error', + exception: error, + stackTrace: stackTrace, + ); + + /// Create a node startup failure error. + factory MinerError.nodeStartupFailed(Object error, [StackTrace? stackTrace]) => MinerError( + type: MinerErrorType.nodeStartupFailed, + message: 'Failed to start node: $error', + exception: error, + stackTrace: stackTrace, + ); + + @override + String toString() => 'MinerError($type): $message'; +} diff --git a/miner-app/lib/src/services/base_process_manager.dart b/miner-app/lib/src/services/base_process_manager.dart new file mode 100644 index 00000000..45fd678d --- /dev/null +++ b/miner-app/lib/src/services/base_process_manager.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show protected; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +/// Abstract base class for process managers. +/// +/// Provides common functionality for managing external processes like +/// the node and miner processes. +abstract class BaseProcessManager { + Process? _process; + late LogStreamProcessor _logProcessor; + final _errorController = StreamController.broadcast(); + + bool _intentionalStop = false; + + /// Tag for logging - subclasses should override + TaggedLoggerWrapper get log; + + /// Name of this process type (for logging) + String get processName; + + /// Stream of log entries from the process. + Stream get logs => _logProcessor.logs; + + /// Stream of errors (crashes, startup failures). + Stream get errors => _errorController.stream; + + /// The process ID, or null if not running. + int? get pid => _process?.pid; + + /// Whether the process is currently running. + bool get isRunning => _process != null; + + /// Access to the error controller for subclasses + @protected + StreamController get errorController => _errorController; + + /// Set the intentional stop flag (for subclasses) + @protected + set intentionalStop(bool value) => _intentionalStop = value; + + /// Clear the process reference (for subclasses) + @protected + void clearProcess() => _process = null; + + /// Initialize the log processor for a source + void initLogProcessor(String sourceName, {SyncStateProvider? getSyncState}) { + _logProcessor = LogStreamProcessor(sourceName: sourceName, getSyncState: getSyncState); + } + + /// Attach process streams to log processor + void attachProcess(Process process) { + _process = process; + _logProcessor.attach(process); + } + + /// Create an error for startup failure - subclasses should override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]); + + /// Create an error for crash - subclasses should override + MinerError createCrashError(int exitCode); + + /// Stop the process gracefully. + /// + /// Returns a Future that completes when the process has stopped. + Future stop() async { + if (_process == null) { + return; + } + + _intentionalStop = true; + final processPid = _process!.pid; + log.i('Stopping $processName (PID: $processPid)...'); + + // Try graceful termination first + _process!.kill(ProcessSignal.sigterm); + + // Wait for graceful shutdown + final exited = await _waitForExit(MinerConfig.gracefulShutdownTimeout); + + if (!exited) { + // Force kill if still running + log.d('$processName still running, force killing...'); + await _forceKill(); + } + + _cleanup(); + log.i('$processName stopped'); + } + + /// Force stop the process immediately. + void forceStop() { + if (_process == null) { + return; + } + + _intentionalStop = true; + final processPid = _process!.pid; + log.i('Force stopping $processName (PID: $processPid)...'); + + try { + _process!.kill(ProcessSignal.sigkill); + } catch (e) { + log.e('Error force killing $processName', error: e); + } + + // Also use system cleanup as backup + ProcessCleanupService.forceKillProcess(processPid, processName); + + _cleanup(); + } + + /// Handle process exit. + void handleExit(int exitCode) { + if (_intentionalStop) { + log.d('$processName exited (code: $exitCode) - intentional stop'); + } else { + log.w('$processName crashed (exit code: $exitCode)'); + _errorController.add(createCrashError(exitCode)); + } + _cleanup(); + } + + /// Dispose of all resources. + void dispose() { + forceStop(); + _logProcessor.dispose(); + if (!_errorController.isClosed) { + _errorController.close(); + } + } + + Future _waitForExit(Duration timeout) async { + if (_process == null) return true; + + try { + await _process!.exitCode.timeout(timeout); + return true; + } on TimeoutException { + return false; + } + } + + Future _forceKill() async { + if (_process == null) return; + + try { + _process!.kill(ProcessSignal.sigkill); + await _process!.exitCode.timeout(MinerConfig.processVerificationDelay, onTimeout: () => -1); + } catch (e) { + log.e('Error during force kill', error: e); + } + } + + void _cleanup() { + _logProcessor.detach(); + _process = null; + } +} diff --git a/miner-app/lib/src/services/binary_manager.dart b/miner-app/lib/src/services/binary_manager.dart index 010b62c5..5a25ca53 100644 --- a/miner-app/lib/src/services/binary_manager.dart +++ b/miner-app/lib/src/services/binary_manager.dart @@ -4,6 +4,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('BinaryManager'); class DownloadProgress { final int downloadedBytes; @@ -89,7 +92,7 @@ class BinaryManager { final json = jsonDecode(content) as Map; return BinaryVersion.fromJson(json); } catch (e) { - print('Error reading node version file: $e'); + _log.d('Error reading node version file: $e'); return null; } } @@ -107,7 +110,7 @@ class BinaryManager { final json = jsonDecode(content) as Map; return BinaryVersion.fromJson(json); } catch (e) { - print('Error reading miner version file: $e'); + _log.d('Error reading miner version file: $e'); return null; } } @@ -168,7 +171,7 @@ class BinaryManager { downloadUrl: updateAvailable ? _buildNodeDownloadUrl(latestVersion) : null, ); } catch (e) { - print('Error checking node update: $e'); + _log.w('Error checking node update', error: e); return BinaryUpdateInfo(updateAvailable: false); } } @@ -195,7 +198,7 @@ class BinaryManager { downloadUrl: updateAvailable ? _buildMinerDownloadUrl(latestVersion) : null, ); } catch (e) { - print('Error checking miner update: $e'); + _log.w('Error checking miner update', error: e); return BinaryUpdateInfo(updateAvailable: false); } } @@ -275,7 +278,7 @@ class BinaryManager { } static Future updateNodeBinary({void Function(DownloadProgress progress)? onProgress}) async { - print('Updating node binary to latest version...'); + _log.i('Updating node binary to latest version...'); final binPath = await getNodeBinaryFilePath(); final binFile = File(binPath); @@ -284,9 +287,9 @@ class BinaryManager { // Create backup of existing binary if it exists if (await binFile.exists()) { - print('Creating backup of existing binary...'); + _log.d('Creating backup of existing binary...'); await binFile.copy(backupPath); - print('Backup created at: $backupPath'); + _log.d('Backup created at: $backupPath'); } try { @@ -296,19 +299,19 @@ class BinaryManager { // If download successful, replace the old binary if (await backupFile.exists()) { await backupFile.delete(); - print('Backup removed after successful update'); + _log.d('Backup removed after successful update'); } - print('Node binary updated successfully!'); + _log.i('Node binary updated successfully!'); return newBinary; } catch (e) { // If download failed, restore from backup - print('Download failed: $e'); + _log.e('Download failed', error: e); if (await backupFile.exists()) { - print('Restoring from backup...'); + _log.i('Restoring from backup...'); await backupFile.copy(binPath); await backupFile.delete(); - print('Binary restored from backup'); + _log.i('Binary restored from backup'); } rethrow; } @@ -322,7 +325,7 @@ class BinaryManager { final rel = await http.get(Uri.parse('https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest')); final tag = jsonDecode(rel.body)['tag_name'] as String; - print('found latest tag: $tag'); + _log.d('Found latest tag: $tag'); // Pick asset name final target = _targetTriple(); @@ -409,10 +412,10 @@ class BinaryManager { final binPath = await getExternalMinerBinaryFilePath(); final binFile = File(binPath); - print('DEBUG: Checking for external miner at path: $binPath'); + _log.d('Checking for external miner at path: $binPath'); if (await binFile.exists() && !forceDownload) { - print('DEBUG: External miner binary already exists at $binPath'); + _log.d('External miner binary already exists at $binPath'); onProgress?.call(DownloadProgress(1, 1)); return binFile; } @@ -421,7 +424,7 @@ class BinaryManager { } static Future updateMinerBinary({void Function(DownloadProgress progress)? onProgress}) async { - print('Updating miner binary to latest version...'); + _log.i('Updating miner binary to latest version...'); final binPath = await getExternalMinerBinaryFilePath(); final binFile = File(binPath); @@ -430,9 +433,9 @@ class BinaryManager { // Create backup of existing binary if it exists if (await binFile.exists()) { - print('Creating backup of existing miner binary...'); + _log.d('Creating backup of existing miner binary...'); await binFile.copy(backupPath); - print('Backup created at: $backupPath'); + _log.d('Backup created at: $backupPath'); } try { @@ -442,19 +445,19 @@ class BinaryManager { // If download successful, replace the old binary if (await backupFile.exists()) { await backupFile.delete(); - print('Backup removed after successful update'); + _log.d('Backup removed after successful update'); } - print('Miner binary updated successfully!'); + _log.i('Miner binary updated successfully!'); return newBinary; } catch (e) { // If download failed, restore from backup - print('Download failed: $e'); + _log.e('Download failed', error: e); if (await backupFile.exists()) { - print('Restoring from backup...'); + _log.i('Restoring from backup...'); await backupFile.copy(binPath); await backupFile.delete(); - print('Binary restored from backup'); + _log.i('Binary restored from backup'); } rethrow; } @@ -464,18 +467,18 @@ class BinaryManager { void Function(DownloadProgress progress)? onProgress, bool isUpdate = false, }) async { - print('DEBUG: External miner binary download process starting...'); + _log.d('External miner binary download process starting...'); // Find latest tag on GitHub final releaseUrl = 'https://api.github.com/repos/$_repoOwner/$_minerRepoName/releases/latest'; - print('DEBUG: Fetching latest release from: $releaseUrl'); + _log.d('Fetching latest release from: $releaseUrl'); final rel = await http.get(Uri.parse(releaseUrl)); final releaseData = jsonDecode(rel.body); final tag = releaseData['tag_name'] as String; - print('DEBUG: Found latest external miner tag: $tag'); + _log.d('Found latest external miner tag: $tag'); // Pick asset name String platform; @@ -505,16 +508,16 @@ class BinaryManager { ? '$_minerReleaseBinary-$platform-$arch.exe' : '$_minerReleaseBinary-$platform-$arch'; - print('DEBUG: Looking for asset: $asset'); + _log.d('Looking for asset: $asset'); final url = 'https://github.com/$_repoOwner/$_minerRepoName/releases/download/$tag/$asset'; // Check if the asset exists in the release final assets = releaseData['assets'] as List; - print('DEBUG: Available assets in release:'); + _log.d('Available assets in release:'); bool assetFound = false; for (var assetInfo in assets) { - print(' - ${assetInfo['name']} (${assetInfo['browser_download_url']})'); + _log.d(' - ${assetInfo['name']} (${assetInfo['browser_download_url']})'); if (assetInfo['name'] == asset) { assetFound = true; } @@ -530,20 +533,20 @@ class BinaryManager { final cacheDir = await _getCacheDir(); final tempFileName = isUpdate ? '$asset.tmp' : asset; final tempBinaryFile = File(p.join(cacheDir.path, tempFileName)); - print('DEBUG: Will download to: ${tempBinaryFile.path}'); + _log.d('Will download to: ${tempBinaryFile.path}'); final client = http.Client(); try { final request = http.Request('GET', Uri.parse(url)); final response = await client.send(request); - print('DEBUG: Download response status: ${response.statusCode}'); + _log.d('Download response status: ${response.statusCode}'); if (response.statusCode != 200) { throw Exception('Failed to download external miner binary: ${response.statusCode} ${response.reasonPhrase}'); } final totalBytes = response.contentLength ?? -1; - print('DEBUG: Expected download size: $totalBytes bytes'); + _log.d('Expected download size: $totalBytes bytes'); int downloadedBytes = 0; List allBytes = []; @@ -557,7 +560,7 @@ class BinaryManager { } } await tempBinaryFile.writeAsBytes(allBytes); - print('DEBUG: Downloaded ${allBytes.length} bytes to ${tempBinaryFile.path}'); + _log.d('Downloaded ${allBytes.length} bytes to ${tempBinaryFile.path}'); if (totalBytes > 0 && downloadedBytes < totalBytes) { onProgress?.call(DownloadProgress(totalBytes, totalBytes)); @@ -570,37 +573,37 @@ class BinaryManager { // Set executable permissions on temp file if (!Platform.isWindows) { - print('DEBUG: Setting executable permissions on ${tempBinaryFile.path}'); + _log.d('Setting executable permissions on ${tempBinaryFile.path}'); final chmodResult = await Process.run('chmod', ['+x', tempBinaryFile.path]); - print('DEBUG: chmod exit code: ${chmodResult.exitCode}'); + _log.d('chmod exit code: ${chmodResult.exitCode}'); if (chmodResult.exitCode != 0) { - print('DEBUG: chmod stderr: ${chmodResult.stderr}'); + _log.e('chmod stderr: ${chmodResult.stderr}'); throw Exception('Failed to set executable permissions'); } } // Move to final location (atomic operation) final binPath = await getExternalMinerBinaryFilePath(); - print('DEBUG: Moving binary from ${tempBinaryFile.path} to $binPath'); + _log.d('Moving binary from ${tempBinaryFile.path} to $binPath'); // Copy instead of rename for cross-device compatibility await tempBinaryFile.copy(binPath); await tempBinaryFile.delete(); - print('DEBUG: Contents of cache directory after download:'); + _log.d('Contents of cache directory after download:'); final cacheDirContents = await cacheDir.list().toList(); for (var item in cacheDirContents) { - print(' - ${item.path}'); + _log.d(' - ${item.path}'); } // Final check final binFile = File(binPath); if (await binFile.exists()) { - print('DEBUG: External miner binary successfully created at $binPath'); + _log.i('External miner binary successfully created at $binPath'); // Save version info await _saveMinerVersion(tag); } else { - print('DEBUG: ERROR - External miner binary still not found at $binPath after download!'); + _log.e('External miner binary still not found at $binPath after download!'); throw Exception('External miner binary not found after download at $binPath'); } @@ -619,12 +622,12 @@ class BinaryManager { if (await nodeKeyFile.exists()) { final stat = await nodeKeyFile.stat(); if (stat.size > 0) { - print('Node key file already exists and has content (size: ${stat.size} bytes)'); + _log.d('Node key file already exists and has content (size: ${stat.size} bytes)'); return nodeKeyFile; } } - print('Node key file not found or empty. Generating new key...'); + _log.i('Node key file not found or empty. Generating new key...'); final nodeBinaryPath = await getNodeBinaryFilePath(); if (!await File(nodeBinaryPath).exists()) { throw Exception( @@ -639,7 +642,7 @@ class BinaryManager { if (await nodeKeyFile.exists()) { final stat = await nodeKeyFile.stat(); if (stat.size > 0) { - print('Successfully generated node key file: ${nodeKeyFile.path} (size: ${stat.size} bytes)'); + _log.i('Successfully generated node key file: ${nodeKeyFile.path} (size: ${stat.size} bytes)'); return nodeKeyFile; } else { throw Exception('Node key file was created but is empty'); @@ -653,7 +656,7 @@ class BinaryManager { ); } } catch (e) { - print('Error generating node key: $e'); + _log.e('Error generating node key', error: e); rethrow; } } diff --git a/miner-app/lib/src/services/chain_rpc_client.dart b/miner-app/lib/src/services/chain_rpc_client.dart index 54c23655..08298ceb 100644 --- a/miner-app/lib/src/services/chain_rpc_client.dart +++ b/miner-app/lib/src/services/chain_rpc_client.dart @@ -2,6 +2,10 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ChainRpc'); class ChainInfo { final int peerCount; @@ -32,8 +36,9 @@ class ChainRpcClient { final http.Client _httpClient; int _requestId = 1; - ChainRpcClient({this.rpcUrl = 'http://127.0.0.1:9933', this.timeout = const Duration(seconds: 10)}) - : _httpClient = http.Client(); + ChainRpcClient({String? rpcUrl, this.timeout = const Duration(seconds: 10)}) + : rpcUrl = rpcUrl ?? MinerConfig.nodeRpcUrl(MinerConfig.defaultNodeRpcPort), + _httpClient = http.Client(); /// Get comprehensive chain information Future getChainInfo() async { @@ -119,15 +124,14 @@ class ChainRpcClient { nodeVersion: nodeVersion, ); - // Only log successful chain info - print('DEBUG: Chain connected - Peers: $peerCount, Block: $currentBlock'); + _log.d('Chain connected - Peers: $peerCount, Block: $currentBlock'); return info; } catch (e) { // Only log unexpected errors, not connection issues during startup if (!e.toString().contains('Connection refused') && !e.toString().contains('Connection reset') && !e.toString().contains('timeout')) { - print('DEBUG: getChainInfo error: $e'); + _log.w('getChainInfo error', error: e); } return null; } @@ -197,8 +201,7 @@ class ChainRpcClient { /// Execute a JSON-RPC call Future _rpcCall(String method, [List? params]) async { - final request = {'jsonrpc': '2.0', 'id': _requestId++, 'method': method}; - if (params != null) request['params'] = params; + final request = {'jsonrpc': '2.0', 'id': _requestId++, 'method': method, 'params': ?params}; // Only print RPC calls when debugging connection issues // print('DEBUG: Making RPC call: $method with request: ${json.encode(request)}'); @@ -218,7 +221,7 @@ class ChainRpcClient { } else { // Don't log connection errors during startup - they're expected if (response.statusCode != 0) { - print('DEBUG: RPC HTTP error for $method: ${response.statusCode} ${response.reasonPhrase}'); + _log.w('RPC HTTP error for $method: ${response.statusCode} ${response.reasonPhrase}'); } throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } @@ -248,7 +251,8 @@ class PollingChainRpcClient extends ChainRpcClient { void Function(ChainInfo info)? onChainInfoUpdate; void Function(String error)? onError; - PollingChainRpcClient({super.rpcUrl, super.timeout, this.pollInterval = const Duration(seconds: 3)}); + PollingChainRpcClient({super.rpcUrl, super.timeout, Duration? pollInterval}) + : pollInterval = pollInterval ?? MinerConfig.prometheusPollingInterval; /// Start polling for chain information void startPolling() { diff --git a/miner-app/lib/src/services/external_miner_api_client.dart b/miner-app/lib/src/services/external_miner_api_client.dart index b9d5b411..df34db12 100644 --- a/miner-app/lib/src/services/external_miner_api_client.dart +++ b/miner-app/lib/src/services/external_miner_api_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; class ExternalMinerMetrics { final double hashRate; @@ -28,7 +29,6 @@ class ExternalMinerMetrics { } class ExternalMinerApiClient { - final String baseUrl; final String metricsUrl; final Duration timeout; final http.Client _httpClient; @@ -39,17 +39,14 @@ class ExternalMinerApiClient { void Function(ExternalMinerMetrics metrics)? onMetricsUpdate; void Function(String error)? onError; - ExternalMinerApiClient({ - this.baseUrl = 'http://127.0.0.1:9833', - String? metricsUrl, - this.timeout = const Duration(seconds: 5), - }) : metricsUrl = metricsUrl ?? 'http://127.0.0.1:9900/metrics', - _httpClient = http.Client(); + ExternalMinerApiClient({String? metricsUrl, this.timeout = const Duration(seconds: 5)}) + : metricsUrl = metricsUrl ?? MinerConfig.minerMetricsUrl(MinerConfig.defaultMinerMetricsPort), + _httpClient = http.Client(); - /// Start polling for metrics every second + /// Start polling for metrics void startPolling() { _pollTimer?.cancel(); - _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollMetrics()); + _pollTimer = Timer.periodic(MinerConfig.metricsPollingInterval, (_) => _pollMetrics()); } /// Stop polling for metrics @@ -167,18 +164,6 @@ class ExternalMinerApiClient { } } - /// Test if the external miner is reachable - Future isReachable() async { - try { - final response = await _httpClient.get(Uri.parse(baseUrl)).timeout(const Duration(seconds: 3)); - - // Any response (even 404) means the server is running - return response.statusCode >= 200 && response.statusCode < 500; - } catch (e) { - return false; - } - } - /// Test if the metrics endpoint is available Future isMetricsAvailable() async { try { diff --git a/miner-app/lib/src/services/gpu_detection_service.dart b/miner-app/lib/src/services/gpu_detection_service.dart index 420d71b2..dfaf24c0 100644 --- a/miner-app/lib/src/services/gpu_detection_service.dart +++ b/miner-app/lib/src/services/gpu_detection_service.dart @@ -1,7 +1,12 @@ import 'dart:io'; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + import 'binary_manager.dart'; +final _log = log.withTag('GpuDetection'); + class GpuDetectionService { /// Detects the number of GPU devices on the system by probing the miner process static Future detectGpuCount() async { @@ -10,12 +15,12 @@ class GpuDetectionService { final bin = File(binPath); if (!await bin.exists()) { - print('External miner binary not found at $binPath'); + _log.w('External miner binary not found at $binPath'); return 0; } - // Start probing from 8 down to 1 - for (int i = 8; i >= 1; i--) { + // Start probing from maxGpuProbeCount down to 1 + for (int i = MinerConfig.maxGpuProbeCount; i >= 1; i--) { try { // Use a very short duration to fail fast or succeed quickly // If it succeeds, it will take 1 second. @@ -45,11 +50,11 @@ class GpuDetectionService { } } } catch (e) { - print('Error probing for $i GPUs: $e'); + _log.d('Error probing for $i GPUs', error: e); } } } catch (e) { - print('Error in GPU detection service: $e'); + _log.e('Error in GPU detection service', error: e); } return 0; } diff --git a/miner-app/lib/src/services/log_stream_processor.dart b/miner-app/lib/src/services/log_stream_processor.dart new file mode 100644 index 00000000..d53411e3 --- /dev/null +++ b/miner-app/lib/src/services/log_stream_processor.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:quantus_miner/src/services/log_filter_service.dart'; +import 'package:quantus_miner/src/shared/extensions/log_string_extension.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('LogProcessor'); + +/// Represents a single log entry from a process. +class LogEntry { + /// The log message content. + final String message; + + /// When the log was received. + final DateTime timestamp; + + /// Source identifier (e.g., 'node', 'miner', 'node-error'). + final String source; + + /// Whether this is an error-level log. + final bool isError; + + LogEntry({required this.message, required this.timestamp, required this.source, this.isError = false}); + + @override + String toString() { + final timeStr = timestamp.toIso8601String().substring(11, 19); // HH:MM:SS + return '[$timeStr] [$source] $message'; + } +} + +/// Callback type for checking if node is currently syncing. +typedef SyncStateProvider = bool Function(); + +/// Processes stdout/stderr streams from a process and emits filtered LogEntries. +/// +/// Handles: +/// - Stream decoding (UTF8) +/// - Line splitting +/// - Log filtering based on keywords and sync state +/// - Error detection +class LogStreamProcessor { + final String sourceName; + final LogFilterService _filter; + final SyncStateProvider? _getSyncState; + + StreamSubscription? _stdoutSubscription; + StreamSubscription? _stderrSubscription; + + final _logController = StreamController.broadcast(); + + /// Stream of processed log entries. + Stream get logs => _logController.stream; + + /// Whether the processor is currently active. + bool get isActive => _stdoutSubscription != null || _stderrSubscription != null; + + LogStreamProcessor({required this.sourceName, SyncStateProvider? getSyncState}) + : _filter = LogFilterService(), + _getSyncState = getSyncState; + + /// Start processing logs from a process. + /// + /// Call this after starting the process. + void attach(Process process) { + _filter.reset(); + + _stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processStdoutLine); + + _stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processStderrLine); + + _log.d('Attached to process (PID: ${process.pid})'); + } + + /// Stop processing and release resources. + void detach() { + _stdoutSubscription?.cancel(); + _stderrSubscription?.cancel(); + _stdoutSubscription = null; + _stderrSubscription = null; + _log.d('Detached from process'); + } + + /// Close the log stream permanently. + void dispose() { + detach(); + if (!_logController.isClosed) { + _logController.close(); + } + } + + void _processStdoutLine(String line) { + final shouldPrint = _filter.shouldPrintLine(line, isNodeSyncing: _getSyncState?.call() ?? false); + + if (shouldPrint) { + final isError = _isErrorLine(line); + final entry = LogEntry( + message: line, + timestamp: DateTime.now(), + source: isError ? '$sourceName-error' : sourceName, + isError: isError, + ); + _logController.add(entry); + + if (isError) { + _log.w('[$sourceName] $line'); + } else { + _log.d('[$sourceName] $line'); + } + } + } + + void _processStderrLine(String line) { + // stderr is always potentially important + final isError = _isErrorLine(line); + final entry = LogEntry( + message: line, + timestamp: DateTime.now(), + source: isError ? '$sourceName-error' : sourceName, + isError: isError, + ); + _logController.add(entry); + + if (isError) { + _log.w('[$sourceName] $line'); + } else { + _log.d('[$sourceName] $line'); + } + } + + bool _isErrorLine(String line) { + // Use the extension method if available for source-specific checks + if (sourceName == 'node') { + return line.isNodeError; + } else if (sourceName == 'miner') { + return line.isMinerError; + } + // Fallback generic error detection + final lower = line.toLowerCase(); + return lower.contains('error') || lower.contains('panic') || lower.contains('fatal') || lower.contains('failed'); + } +} diff --git a/miner-app/lib/src/services/miner_process.dart b/miner-app/lib/src/services/miner_process.dart deleted file mode 100644 index b8c65240..00000000 --- a/miner-app/lib/src/services/miner_process.dart +++ /dev/null @@ -1,917 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:quantus_miner/src/services/prometheus_service.dart'; -import 'package:quantus_miner/src/shared/extensions/log_string_extension.dart'; - -import './binary_manager.dart'; -import './chain_rpc_client.dart'; -import './external_miner_api_client.dart'; -import './log_filter_service.dart'; -import './mining_stats_service.dart'; - -class LogEntry { - final String message; - final DateTime timestamp; - final String source; // 'node', 'quantus-miner', 'error' - - LogEntry({required this.message, required this.timestamp, required this.source}); - - @override - String toString() { - final timeStr = timestamp.toIso8601String().substring(11, 19); // HH:MM:SS - return '[$timeStr] [$source] $message'; - } -} - -/// quantus_sdk/lib/src/services/miner_process.dart -class MinerProcess { - final File bin; - final File identityPath; - final File rewardsPath; - late Process _nodeProcess; - Process? _externalMinerProcess; - late LogFilterService _stdoutFilter; - late LogFilterService _stderrFilter; - - late MiningStatsService _statsService; - late PrometheusService _prometheusService; - late ExternalMinerApiClient _externalMinerApiClient; - late PollingChainRpcClient _chainRpcClient; - - Timer? _syncStatusTimer; - final int cpuWorkers; - final int gpuDevices; - - final int externalMinerPort; - final int detectedGpuCount; - - // Track metrics state to prevent premature hashrate reset - double _lastValidHashrate = 0.0; - int _consecutiveMetricsFailures = 0; - static const int _maxConsecutiveFailures = 5; - - // Public getters for process PIDs (for cleanup tracking) - int? get nodeProcessPid { - try { - return _nodeProcess.pid; - } catch (e) { - return null; - } - } - - int? get externalMinerProcessPid { - try { - return _externalMinerProcess?.pid; - } catch (e) { - return null; - } - } - - // Stream for logs - final _logsController = StreamController.broadcast(); - Stream get logsStream => _logsController.stream; - - final Function(MiningStats stats)? onStatsUpdate; - - MinerProcess( - this.bin, - this.identityPath, - this.rewardsPath, { - this.onStatsUpdate, - this.cpuWorkers = 8, - this.gpuDevices = 0, - this.detectedGpuCount = 0, - this.externalMinerPort = 9833, - }) { - // Initialize services - _statsService = MiningStatsService(); - _prometheusService = PrometheusService(); - _stdoutFilter = LogFilterService(); - _stderrFilter = LogFilterService(); - - // Initialize external miner API client with metrics endpoint - _externalMinerApiClient = ExternalMinerApiClient( - baseUrl: 'http://127.0.0.1:$externalMinerPort', - metricsUrl: 'http://127.0.0.1:9900/metrics', // Standard metrics port - ); - - // Set up external miner API callbacks - _externalMinerApiClient.onMetricsUpdate = _handleExternalMinerMetrics; - _externalMinerApiClient.onError = _handleExternalMinerError; - - // Initialize chain RPC client - _chainRpcClient = PollingChainRpcClient(rpcUrl: 'http://127.0.0.1:9933'); - _chainRpcClient.onChainInfoUpdate = _handleChainInfoUpdate; - _chainRpcClient.onError = _handleChainRpcError; - - // Initialize stats with the configured worker count - _statsService.updateWorkers(cpuWorkers); - // Initialize stats with total CPU capacity from platform - _statsService.updateCpuCapacity(Platform.numberOfProcessors); - // Initialize stats with the configured GPU devices - _statsService.updateGpuDevices(gpuDevices); - // Initialize stats with total GPU capacity from detection - _statsService.updateGpuCapacity(detectedGpuCount); - } - - Future start() async { - // First, ensure both binaries are available - await BinaryManager.ensureNodeBinary(); - - // Cleanup any existing processes first - await _cleanupExistingNodeProcesses(); - await _cleanupExistingMinerProcesses(); - - // Cleanup database lock files if needed - await _cleanupDatabaseLocks(); - - // Ensure database directory has proper access - await _ensureDatabaseDirectoryAccess(); - - // Check if ports are available and cleanup if needed - await _ensurePortsAvailable(); - - final externalMinerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); - - await BinaryManager.ensureExternalMinerBinary(); - final externalMinerBin = File(externalMinerBinPath); - - if (!await externalMinerBin.exists()) { - throw Exception('External miner binary not found at $externalMinerBinPath'); - } - - // Start the external miner first with metrics enabled - - final minerArgs = [ - 'serve', - '--port', - externalMinerPort.toString(), - '--cpu-workers', - cpuWorkers.toString(), - '--gpu-devices', - gpuDevices.toString(), - '--metrics-port', - await _getMetricsPort().then((port) => port.toString()), - ]; - - try { - _externalMinerProcess = await Process.start(externalMinerBin.path, minerArgs); - } catch (e) { - throw Exception('Failed to start external miner: $e'); - } - - // Set up external miner log handling - _externalMinerProcess!.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - final logEntry = LogEntry(message: line, timestamp: DateTime.now(), source: 'quantus-miner'); - _logsController.add(logEntry); - print('[ext-miner] $line'); - }); - - _externalMinerProcess!.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - final logEntry = LogEntry( - message: line, - timestamp: DateTime.now(), - source: line.isMinerError ? 'quantus-miner-error' : 'quantus-miner', - ); - _logsController.add(logEntry); - print('[ext-miner-err] $line'); - }); - - // Monitor external miner process exit - _externalMinerProcess!.exitCode.then((exitCode) { - if (exitCode != 0) { - print('External miner process exited with code: $exitCode'); - } - }); - - // Give the external miner a moment to start up - await Future.delayed(const Duration(seconds: 3)); - - // Check if external miner process is still alive - bool minerStillRunning = true; - try { - // Check if the process has exited by looking at its PID - final pid = _externalMinerProcess!.pid; - minerStillRunning = await _isProcessRunning(pid); - } catch (e) { - minerStillRunning = false; - } - - if (!minerStillRunning) { - throw Exception('External miner process died during startup'); - } - - // Test if external miner is responding on the port - try { - final testClient = HttpClient(); - testClient.connectionTimeout = const Duration(seconds: 5); - final request = await testClient.getUrl(Uri.parse('http://127.0.0.1:$externalMinerPort')); - final response = await request.close(); - await response.drain(); // Consume the response - testClient.close(); - } catch (e) { - // External miner might still be starting up - } - - // Now start the node process - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final basePath = p.join(quantusHome, 'node_data'); - await Directory(basePath).create(recursive: true); - - final nodeKeyFileFromFileSystem = await BinaryManager.getNodeKeyFile(); - if (await nodeKeyFileFromFileSystem.exists()) { - final stat = await nodeKeyFileFromFileSystem.stat(); - print('DEBUG: nodeKeyFileFromFileSystem (${nodeKeyFileFromFileSystem.path}) exists (size: ${stat.size} bytes)'); - } else { - print('DEBUG: nodeKeyFileFromFileSystem (${nodeKeyFileFromFileSystem.path}) does not exist.'); - } - - if (!await identityPath.exists()) { - throw Exception('Identity file not found: ${identityPath.path}'); - } - - // Read the rewards address from the file - String rewardsAddress; - try { - if (!await rewardsPath.exists()) { - throw Exception('Rewards address file not found: ${rewardsPath.path}'); - } - rewardsAddress = await rewardsPath.readAsString(); - rewardsAddress = rewardsAddress.trim(); // Remove any whitespace/newlines - print('DEBUG: Read rewards address from file: $rewardsAddress'); - } catch (e) { - throw Exception('Failed to read rewards address from file ${rewardsPath.path}: $e'); - } - - final List args = [ - '--base-path', - basePath, - '--node-key-file', - identityPath.path, - '--rewards-address', - rewardsAddress, - '--validator', - '--chain', - 'dirac', - '--port', - '30333', - '--prometheus-port', - '9616', - '--experimental-rpc-endpoint', - 'listen-addr=127.0.0.1:9933,methods=unsafe,cors=all', - '--name', - 'QuantusMinerGUI', - '--external-miner-url', - 'http://127.0.0.1:$externalMinerPort', - '--enable-peer-sharing', - ]; - - print('DEBUG: Executing command:\n ${bin.path} ${args.join(' ')}'); - print('DEBUG: Args: ${args.join('\n')}'); - - _nodeProcess = await Process.start(bin.path, args); - _stdoutFilter = LogFilterService(); - _stderrFilter = LogFilterService(); - // Services are now initialized in constructor - - _stdoutFilter.reset(); - _stderrFilter.reset(); - - Future syncBlockTargetWithPrometheusMetrics() async { - try { - final metrics = await _prometheusService.fetchMetrics(); - if (metrics == null || metrics.targetBlock == null) return; - if (_statsService.currentStats.targetBlock >= metrics.targetBlock!) { - return; - } - - _statsService.updateTargetBlock(metrics.targetBlock!); - - onStatsUpdate?.call(_statsService.currentStats); - } catch (e) { - print('Failed to fetch target block height: $e'); - } - } - - // Start Prometheus polling for target block (every 3 seconds) - _syncStatusTimer?.cancel(); - _syncStatusTimer = Timer.periodic(const Duration(seconds: 3), (timer) => syncBlockTargetWithPrometheusMetrics()); - - // Start external miner API polling (every second) - _externalMinerApiClient.startPolling(); - - // Wait for node to be ready before starting RPC polling - _waitForNodeReadyThenStartRpc(); - - // Process each log line - void processLogLine(String line, String streamType) { - bool shouldPrint; - if (streamType == 'stdout') { - shouldPrint = _stdoutFilter.shouldPrintLine(line, isNodeSyncing: _statsService.currentStats.isSyncing); - } else { - shouldPrint = _stderrFilter.shouldPrintLine(line, isNodeSyncing: _statsService.currentStats.isSyncing); - } - - if (shouldPrint) { - String source; - if (line.isNodeError) { - source = 'node-error'; - } else if (streamType == 'stdout') { - source = 'node'; - } else { - source = 'node'; - } - - final logEntry = LogEntry(message: line, timestamp: DateTime.now(), source: source); - _logsController.add(logEntry); - print(source == 'node' ? '[node] $line' : '[node-error] $line'); - } - } - - _nodeProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - processLogLine(line, 'stdout'); - }); - - _nodeProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - processLogLine(line, 'stderr'); - }); - } - - void stop() { - print('MinerProcess: stop() called. Killing processes.'); - _syncStatusTimer?.cancel(); - _externalMinerApiClient.stopPolling(); - _chainRpcClient.stopPolling(); - - // Kill external miner process first - if (_externalMinerProcess != null) { - try { - print('MinerProcess: Attempting to kill external miner process (PID: ${_externalMinerProcess!.pid})'); - - // Try graceful termination first - _externalMinerProcess!.kill(ProcessSignal.sigterm); - - // Wait briefly for graceful shutdown - Future.delayed(const Duration(seconds: 2)).then((_) async { - // Check if process is still running and force kill if necessary - try { - if (await _isProcessRunning(_externalMinerProcess!.pid)) { - print('MinerProcess: External miner still running, force killing...'); - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } - } catch (e) { - // Process is already dead, which is what we want - print('MinerProcess: External miner process already terminated'); - } - }); - } catch (e) { - print('MinerProcess: Error killing external miner process: $e'); - // Try force kill as backup - try { - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } catch (e2) { - print('MinerProcess: Error force killing external miner process: $e2'); - } - } - } - - // Kill node process - try { - print('MinerProcess: Attempting to kill node process (PID: ${_nodeProcess.pid})'); - - // Try graceful termination first - _nodeProcess.kill(ProcessSignal.sigterm); - - // Wait briefly for graceful shutdown - Future.delayed(const Duration(seconds: 2)).then((_) async { - // Check if process is still running and force kill if necessary - try { - if (await _isProcessRunning(_nodeProcess.pid)) { - print('MinerProcess: Node process still running, force killing...'); - _nodeProcess.kill(ProcessSignal.sigkill); - } - } catch (e) { - // Process is already dead, which is what we want - print('MinerProcess: Node process already terminated'); - } - }); - } catch (e) { - print('MinerProcess: Error killing node process: $e'); - // Try force kill as backup - try { - _nodeProcess.kill(ProcessSignal.sigkill); - } catch (e2) { - print('MinerProcess: Error force killing node process: $e2'); - } - } - - // Close the logs stream - if (!_logsController.isClosed) { - _logsController.close(); - } - } - - /// Force stop both processes immediately with SIGKILL - void forceStop() { - print('MinerProcess: forceStop() called. Force killing processes.'); - _syncStatusTimer?.cancel(); - - final List> killFutures = []; - - // Force kill external miner - if (_externalMinerProcess != null) { - final minerPid = _externalMinerProcess!.pid; - killFutures.add(_forceKillProcess(minerPid, 'external miner')); - try { - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } catch (e) { - print('MinerProcess: Error force killing external miner process: $e'); - } - _externalMinerProcess = null; - } - - // Force kill node process - try { - final nodePid = _nodeProcess.pid; - killFutures.add(_forceKillProcess(nodePid, 'node')); - _nodeProcess.kill(ProcessSignal.sigkill); - } catch (e) { - print('MinerProcess: Error force killing node process: $e'); - } - - // Wait for all kills to complete (with timeout) - Future.wait(killFutures).timeout( - const Duration(seconds: 5), - onTimeout: () { - print('MinerProcess: Force kill operations timed out'); - return []; - }, - ); - - // Close the logs stream - if (!_logsController.isClosed) { - _logsController.close(); - } - } - - /// Check if a process with the given PID is running - Future _isProcessRunning(int pid) async { - try { - if (Platform.isWindows) { - final result = await Process.run('tasklist', ['/FI', 'PID eq $pid']); - return result.stdout.toString().contains(' $pid '); - } else { - final result = await Process.run('kill', ['-0', pid.toString()]); - return result.exitCode == 0; - } - } catch (e) { - return false; - } - } - - /// Helper method to force kill a process by PID with verification - Future _forceKillProcess(int pid, String processName) async { - try { - print('MinerProcess: Force killing $processName process (PID: $pid)'); - - if (Platform.isWindows) { - final killResult = await Process.run('taskkill', ['/F', '/PID', pid.toString()]); - if (killResult.exitCode == 0) { - print('MinerProcess: Successfully force killed $processName (PID: $pid)'); - } else { - print('MinerProcess: taskkill failed for $processName (PID: $pid), exit code: ${killResult.exitCode}'); - } - - await Future.delayed(const Duration(milliseconds: 500)); - - // Verify - final checkResult = await Process.run('tasklist', ['/FI', 'PID eq $pid']); - if (checkResult.stdout.toString().contains(' $pid ')) { - print('MinerProcess: WARNING - $processName (PID: $pid) may still be running'); - // Try by name as last resort - final binaryName = processName.contains('miner') ? 'quantus-miner.exe' : 'quantus-node.exe'; - await Process.run('taskkill', ['/F', '/IM', binaryName]); - } else { - print('MinerProcess: Verified $processName (PID: $pid) is terminated'); - } - } else { - // First try SIGKILL via kill command for better reliability - final killResult = await Process.run('kill', ['-9', pid.toString()]); - - if (killResult.exitCode == 0) { - print('MinerProcess: Successfully force killed $processName (PID: $pid)'); - } else { - print('MinerProcess: kill command failed for $processName (PID: $pid), exit code: ${killResult.exitCode}'); - } - - // Wait a moment then verify the process is dead - await Future.delayed(const Duration(milliseconds: 500)); - - final checkResult = await Process.run('kill', ['-0', pid.toString()]); - if (checkResult.exitCode != 0) { - print('MinerProcess: Verified $processName (PID: $pid) is terminated'); - } else { - print('MinerProcess: WARNING - $processName (PID: $pid) may still be running'); - // Try pkill as last resort - await Process.run('pkill', ['-9', '-f', processName.contains('miner') ? 'quantus-miner' : 'quantus-node']); - } - } - } catch (e) { - print('MinerProcess: Error in _forceKillProcess for $processName: $e'); - } - } - - /// Handle external miner metrics updates - void _handleExternalMinerMetrics(ExternalMinerMetrics metrics) { - if (metrics.isHealthy && metrics.hashRate > 0) { - // Valid metrics received - _lastValidHashrate = metrics.hashRate; - _consecutiveMetricsFailures = 0; - - _statsService.updateHashrate(metrics.hashRate); - - // Update workers count from external miner if available - if (metrics.workers > 0) { - _statsService.updateWorkers(metrics.workers); - } - - // Update CPU capacity from external miner if available - if (metrics.cpuCapacity > 0) { - _statsService.updateCpuCapacity(metrics.cpuCapacity); - } - - // Update GPU devices count from external miner if available - if (metrics.gpuDevices > 0) { - _statsService.updateGpuDevices(metrics.gpuDevices); - } - - onStatsUpdate?.call(_statsService.currentStats); - } else if (metrics.hashRate == 0.0 && _lastValidHashrate > 0) { - // Received 0.0 but we have a valid hashrate - ignore it and keep the last valid one - _statsService.updateHashrate(_lastValidHashrate); - onStatsUpdate?.call(_statsService.currentStats); - } else { - // Invalid or zero metrics - _consecutiveMetricsFailures++; - - // Only reset to zero after multiple consecutive failures - if (_consecutiveMetricsFailures >= _maxConsecutiveFailures) { - _statsService.updateHashrate(0.0); - _lastValidHashrate = 0.0; - onStatsUpdate?.call(_statsService.currentStats); - } else { - // Keep the last valid hashrate during temporary issues - if (_lastValidHashrate > 0) { - _statsService.updateHashrate(_lastValidHashrate); - onStatsUpdate?.call(_statsService.currentStats); - } - } - } - } - - /// Handle external miner API errors - void _handleExternalMinerError(String error) { - _consecutiveMetricsFailures++; - - // Only reset hashrate after multiple consecutive errors - if (_consecutiveMetricsFailures >= _maxConsecutiveFailures) { - if (_statsService.currentStats.hashrate != 0.0) { - _statsService.updateHashrate(0.0); - _lastValidHashrate = 0.0; - onStatsUpdate?.call(_statsService.currentStats); - } - } - } - - /// Check if required ports are available and cleanup if needed - Future _ensurePortsAvailable() async { - // Check if external miner port (9833) is in use - if (await _isPortInUse(externalMinerPort)) { - await _killProcessOnPort(externalMinerPort); - await Future.delayed(const Duration(seconds: 1)); - - if (await _isPortInUse(externalMinerPort)) { - throw Exception('Port $externalMinerPort is still in use after cleanup attempt'); - } - } - - // Check if metrics port (9900) is in use - if (await _isPortInUse(9900)) { - await _killProcessOnPort(9900); - await Future.delayed(const Duration(seconds: 1)); - - if (await _isPortInUse(9900)) { - final altMetricsPort = await _findAvailablePort(9900); - if (altMetricsPort != 9900) { - // Update the metrics URL for the API client - _externalMinerApiClient = ExternalMinerApiClient( - baseUrl: 'http://127.0.0.1:$externalMinerPort', - metricsUrl: 'http://127.0.0.1:$altMetricsPort/metrics', - ); - _externalMinerApiClient.onMetricsUpdate = _handleExternalMinerMetrics; - _externalMinerApiClient.onError = _handleExternalMinerError; - } - } - } - } - - /// Find an available port starting from the given port - Future _findAvailablePort(int startPort) async { - for (int port = startPort; port <= startPort + 10; port++) { - if (!(await _isPortInUse(port))) { - return port; - } - } - return startPort; // Return original if no alternative found - } - - /// Check if a port is currently in use - Future _isPortInUse(int port) async { - try { - if (Platform.isWindows) { - final result = await Process.run('netstat', ['-ano']); - return result.exitCode == 0 && result.stdout.toString().contains(':$port'); - } else { - final result = await Process.run('lsof', ['-i', ':$port']); - return result.exitCode == 0 && result.stdout.toString().isNotEmpty; - } - } catch (e) { - // lsof might not be available, try netstat as fallback - try { - final result = await Process.run('netstat', ['-an']); - return result.stdout.toString().contains(':$port'); - } catch (e2) { - print('DEBUG: Could not check port $port availability: $e2'); - return false; - } - } - } - - /// Kill process using a specific port - Future _killProcessOnPort(int port) async { - try { - if (Platform.isWindows) { - final result = await Process.run('netstat', ['-ano']); - if (result.exitCode == 0) { - final lines = result.stdout.toString().split('\n'); - for (final line in lines) { - if (line.contains(':$port')) { - final parts = line.trim().split(RegExp(r'\s+')); - if (parts.isNotEmpty) { - final pid = parts.last; - // Verify it's a valid PID number - if (int.tryParse(pid) != null) { - await Process.run('taskkill', ['/F', '/PID', pid]); - } - } - } - } - } - } else { - // Find process using the port - final result = await Process.run('lsof', ['-ti', ':$port']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - await Process.run('kill', ['-9', pid.trim()]); - } - } - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Get the metrics port to use (either 9900 or alternative) - Future _getMetricsPort() async { - if (await _isPortInUse(9900)) { - return await _findAvailablePort(9901); - } - return 9900; - } - - /// Cleanup any existing quantus-miner processes - Future _cleanupExistingMinerProcesses() async { - try { - if (Platform.isWindows) { - try { - await Process.run('taskkill', ['/F', '/IM', 'quantus-miner.exe']); - await Future.delayed(const Duration(seconds: 2)); - } catch (_) { - // Process might not exist - } - } else { - // Find all quantus-miner processes - final result = await Process.run('pgrep', ['-f', 'quantus-miner']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - try { - // Try graceful termination first - await Process.run('kill', ['-15', pid.trim()]); - await Future.delayed(const Duration(seconds: 1)); - - // Check if still running, force kill if needed - final checkResult = await Process.run('kill', ['-0', pid.trim()]); - if (checkResult.exitCode == 0) { - await Process.run('kill', ['-9', pid.trim()]); - } - } catch (e) { - // Ignore cleanup errors - } - } - } - - // Wait a moment for processes to fully terminate - await Future.delayed(const Duration(seconds: 2)); - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Cleanup any existing quantus-node processes - Future _cleanupExistingNodeProcesses() async { - try { - if (Platform.isWindows) { - try { - await Process.run('taskkill', ['/F', '/IM', 'quantus-node.exe']); - await Future.delayed(const Duration(seconds: 2)); - } catch (_) { - // Process might not exist - } - } else { - // Find all quantus-node processes - final result = await Process.run('pgrep', ['-f', 'quantus-node']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - try { - // Try graceful termination first - await Process.run('kill', ['-15', pid.trim()]); - await Future.delayed(const Duration(seconds: 2)); - - // Check if still running, force kill if needed - final checkResult = await Process.run('kill', ['-0', pid.trim()]); - if (checkResult.exitCode == 0) { - await Process.run('kill', ['-9', pid.trim()]); - } - } catch (e) { - // Ignore cleanup errors - } - } - } - - // Wait a moment for processes to fully terminate - await Future.delayed(const Duration(seconds: 3)); - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Cleanup database lock files that may prevent node startup - Future _cleanupDatabaseLocks() async { - try { - // Get the quantus home directory path - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final lockFilePath = '$quantusHome/node_data/chains/dirac/db/full/LOCK'; - final lockFile = File(lockFilePath); - - if (await lockFile.exists()) { - // At this point node processes should already be cleaned up - // Safe to remove the stale lock file - await lockFile.delete(); - } - - // Also check for other potential lock files - final dbDir = Directory('$quantusHome/node_data/chains/dirac/db/full'); - if (await dbDir.exists()) { - await for (final entity in dbDir.list()) { - if (entity is File && entity.path.contains('LOCK')) { - try { - await entity.delete(); - } catch (e) { - // Ignore cleanup errors - } - } - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Check and fix database directory permissions - Future _ensureDatabaseDirectoryAccess() async { - try { - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final dbPath = '$quantusHome/node_data/chains/dirac/db'; - final dbDir = Directory(dbPath); - - // Create the directory if it doesn't exist - if (!await dbDir.exists()) { - await dbDir.create(recursive: true); - } - - // Check if directory is writable - final testFile = File('$dbPath/test_write_access'); - try { - await testFile.writeAsString('test'); - await testFile.delete(); - } catch (e) { - // Try to fix permissions - try { - await Process.run('chmod', ['-R', '755', dbPath]); - } catch (permError) { - // Ignore permission fix errors - } - } - } catch (e) { - // Ignore directory access errors - } - } - - /// Handle chain RPC information updates - /// Wait for the node RPC to be ready, then start polling - Future _waitForNodeReadyThenStartRpc() async { - print('DEBUG: Waiting for node RPC to be ready...'); - - // Try to connect to RPC endpoint with exponential backoff - int attempts = 0; - const maxAttempts = 20; // Up to ~2 minutes of retries - Duration delay = const Duration(seconds: 2); - - while (attempts < maxAttempts) { - try { - final isReady = await _chainRpcClient.isReachable(); - if (isReady) { - print('DEBUG: Node RPC is ready! Starting chain RPC polling...'); - _chainRpcClient.startPolling(); - return; - } - } catch (e) { - // Expected during startup - } - - attempts++; - print('DEBUG: Node RPC not ready yet (attempt $attempts/$maxAttempts), waiting ${delay.inSeconds}s...'); - - await Future.delayed(delay); - - // Exponential backoff, but cap at 10 seconds - if (delay.inSeconds < 10) { - delay = Duration(seconds: (delay.inSeconds * 1.5).round()); - } - } - - print('DEBUG: Failed to connect to node RPC after $maxAttempts attempts. Will retry with polling...'); - // Start polling anyway - the error handling in RPC client will manage failures - _chainRpcClient.startPolling(); - } - - void _handleChainInfoUpdate(ChainInfo info) { - print('DEBUG: Successfully received chain info - Peers: ${info.peerCount}, Block: ${info.currentBlock}'); - - // Update peer count from RPC (most accurate) - if (info.peerCount >= 0) { - _statsService.updatePeerCount(info.peerCount); - print('DEBUG: Updated peer count to: ${info.peerCount}'); - } - - // Update chain name from RPC - _statsService.updateChainName(info.chainName); - - // Always update current block and target block from RPC (most authoritative) - _statsService.setSyncingState(info.isSyncing, info.currentBlock, info.targetBlock ?? info.currentBlock); - print( - 'DEBUG: Updated blocks - current: ${info.currentBlock}, target: ${info.targetBlock ?? info.currentBlock}, syncing: ${info.isSyncing}, chain: ${info.chainName}', - ); - - onStatsUpdate?.call(_statsService.currentStats); - } - - /// Handle chain RPC errors - void _handleChainRpcError(String error) { - // Only log significant RPC errors, not connection issues during startup - if (!error.contains('Connection refused') && !error.contains('timeout')) { - print('Chain RPC error: $error'); - } - } - - /// Dispose of resources - void dispose() { - _syncStatusTimer?.cancel(); - _externalMinerApiClient.dispose(); - _chainRpcClient.dispose(); - } -} diff --git a/miner-app/lib/src/services/miner_process_manager.dart b/miner-app/lib/src/services/miner_process_manager.dart new file mode 100644 index 00000000..06b42c5f --- /dev/null +++ b/miner-app/lib/src/services/miner_process_manager.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/base_process_manager.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ExternalMiner'); + +/// Configuration for starting the external miner process. +class ExternalMinerConfig { + /// Path to the miner binary. + final File binary; + + /// Address and port of the node's QUIC endpoint (e.g., "127.0.0.1:9833"). + final String nodeAddress; + + /// Number of CPU worker threads. + final int cpuWorkers; + + /// Number of GPU devices to use. + final int gpuDevices; + + /// Port for the miner's Prometheus metrics endpoint. + final int metricsPort; + + ExternalMinerConfig({ + required this.binary, + required this.nodeAddress, + this.cpuWorkers = 8, + this.gpuDevices = 0, + this.metricsPort = 9900, + }); +} + +/// Manages the quantus-miner (external miner) process lifecycle. +/// +/// Responsibilities: +/// - Starting the miner process with proper arguments +/// - Monitoring process health and exit +/// - Stopping the process gracefully or forcefully +/// - Emitting log entries and error events +class MinerProcessManager extends BaseProcessManager { + @override + TaggedLoggerWrapper get log => _log; + + @override + String get processName => 'miner'; + + @override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]) { + return MinerError.minerStartupFailed(error, stackTrace); + } + + @override + MinerError createCrashError(int exitCode) { + return MinerError.minerCrashed(exitCode); + } + + MinerProcessManager() { + initLogProcessor('miner'); + } + + /// Start the miner process. + /// + /// Throws an exception if startup fails. + Future start(ExternalMinerConfig config) async { + if (isRunning) { + log.w('Miner already running (PID: $pid)'); + return; + } + + intentionalStop = false; + + // Validate binary exists + if (!await config.binary.exists()) { + final error = MinerError.minerStartupFailed('Miner binary not found: ${config.binary.path}'); + errorController.add(error); + throw Exception(error.message); + } + + // Build command arguments + final args = _buildArgs(config); + + log.i('Starting miner...'); + log.d('Command: ${config.binary.path} ${args.join(' ')}'); + + try { + final proc = await Process.start(config.binary.path, args); + attachProcess(proc); + + // Monitor for unexpected exit + proc.exitCode.then(handleExit); + + // Verify it started successfully by waiting briefly + await Future.delayed(const Duration(seconds: 2)); + + // Check if process is still running + // We just attached, so pid should be available + final processPid = pid; + final stillRunning = await ProcessCleanupService.isProcessRunning(processPid); + if (!stillRunning) { + final error = MinerError.minerStartupFailed('Miner died during startup'); + errorController.add(error); + clearProcess(); + throw Exception(error.message); + } + + log.i('Miner started (PID: $pid)'); + } catch (e, st) { + if (e.toString().contains('Miner died during startup')) { + rethrow; + } + final error = MinerError.minerStartupFailed(e, st); + errorController.add(error); + clearProcess(); + rethrow; + } + } + + List _buildArgs(ExternalMinerConfig config) { + return [ + 'serve', // Subcommand required by new miner CLI + '--node-addr', + config.nodeAddress, + '--cpu-workers', + config.cpuWorkers.toString(), + '--gpu-devices', + config.gpuDevices.toString(), + '--metrics-port', + config.metricsPort.toString(), + ]; + } +} diff --git a/miner-app/lib/src/services/miner_settings_service.dart b/miner-app/lib/src/services/miner_settings_service.dart index a6f873cf..0a617c94 100644 --- a/miner-app/lib/src/services/miner_settings_service.dart +++ b/miner-app/lib/src/services/miner_settings_service.dart @@ -1,11 +1,16 @@ import 'dart:io'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +final _log = log.withTag('Settings'); + class MinerSettingsService { static const String _keyCpuWorkers = 'cpu_workers'; static const String _keyGpuDevices = 'gpu_devices'; + static const String _keyChainId = 'chain_id'; Future saveCpuWorkers(int cpuWorkers) async { final prefs = await SharedPreferences.getInstance(); @@ -27,8 +32,35 @@ class MinerSettingsService { return prefs.getInt(_keyGpuDevices); } + /// Save the selected chain ID. + Future saveChainId(String chainId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyChainId, chainId); + } + + /// Get the saved chain ID, returns default if not set. + Future getChainId() async { + final prefs = await SharedPreferences.getInstance(); + final savedChainId = prefs.getString(_keyChainId); + if (savedChainId == null) { + return MinerConfig.defaultChainId; + } + // Validate that the chain ID is still valid + final validIds = MinerConfig.availableChains.map((c) => c.id).toList(); + if (!validIds.contains(savedChainId)) { + return MinerConfig.defaultChainId; + } + return savedChainId; + } + + /// Get the ChainConfig for the saved chain ID. + Future getChainConfig() async { + final chainId = await getChainId(); + return MinerConfig.getChainById(chainId); + } + Future logout() async { - print('Starting app logout/reset...'); + _log.i('Starting app logout/reset...'); // 1. Delete node identity file (node_key.p2p) try { @@ -36,12 +68,12 @@ class MinerSettingsService { final identityFile = File('$quantusHome/node_key.p2p'); if (await identityFile.exists()) { await identityFile.delete(); - print('✅ Node identity file deleted: ${identityFile.path}'); + _log.i('✅ Node identity file deleted: ${identityFile.path}'); } else { - print('ℹ️ Node identity file not found, skipping deletion.'); + _log.d('ℹ️ Node identity file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node identity file: $e'); + _log.e('❌ Error deleting node identity file', error: e); } // 2. Delete rewards address file @@ -50,12 +82,12 @@ class MinerSettingsService { final rewardsFile = File('$quantusHome/rewards-address.txt'); if (await rewardsFile.exists()) { await rewardsFile.delete(); - print('✅ Rewards address file deleted: ${rewardsFile.path}'); + _log.i('✅ Rewards address file deleted: ${rewardsFile.path}'); } else { - print('ℹ️ Rewards address file not found, skipping deletion.'); + _log.d('ℹ️ Rewards address file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting rewards address file: $e'); + _log.e('❌ Error deleting rewards address file', error: e); } // 3. Delete node binary @@ -64,12 +96,12 @@ class MinerSettingsService { final binaryFile = File(nodeBinaryPath); if (await binaryFile.exists()) { await binaryFile.delete(); - print('✅ Node binary file deleted: ${binaryFile.path}'); + _log.i('✅ Node binary file deleted: ${binaryFile.path}'); } else { - print('ℹ️ Node binary file not found, skipping deletion.'); + _log.d('ℹ️ Node binary file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node binary file: $e'); + _log.e('❌ Error deleting node binary file', error: e); } // 4. Delete external miner binary @@ -78,12 +110,12 @@ class MinerSettingsService { final minerFile = File(minerBinaryPath); if (await minerFile.exists()) { await minerFile.delete(); - print('✅ External miner binary deleted: ${minerFile.path}'); + _log.i('✅ External miner binary deleted: ${minerFile.path}'); } else { - print('ℹ️ External miner binary not found, skipping deletion.'); + _log.d('ℹ️ External miner binary not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting external miner binary: $e'); + _log.e('❌ Error deleting external miner binary', error: e); } // 5. Delete node data directory (blockchain data) @@ -92,12 +124,12 @@ class MinerSettingsService { final nodeDataDir = Directory('$quantusHome/node_data'); if (await nodeDataDir.exists()) { await nodeDataDir.delete(recursive: true); - print('✅ Node data directory deleted: ${nodeDataDir.path}'); + _log.i('✅ Node data directory deleted: ${nodeDataDir.path}'); } else { - print('ℹ️ Node data directory not found, skipping deletion.'); + _log.d('ℹ️ Node data directory not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node data directory: $e'); + _log.e('❌ Error deleting node data directory', error: e); } // 6. Clean up bin directory and leftover files @@ -109,22 +141,22 @@ class MinerSettingsService { final tarFiles = binDir.listSync().where((file) => file.path.endsWith('.tar.gz')); for (var file in tarFiles) { await file.delete(); - print('✅ Cleaned up archive: ${file.path}'); + _log.i('✅ Cleaned up archive: ${file.path}'); } // Try to remove bin directory if it's empty try { await binDir.delete(); - print('✅ Empty bin directory removed: ${binDir.path}'); + _log.i('✅ Empty bin directory removed: ${binDir.path}'); } catch (e) { // Directory not empty, that's fine - print('ℹ️ Bin directory not empty, keeping it.'); + _log.d('ℹ️ Bin directory not empty, keeping it.'); } } else { - print('ℹ️ Bin directory not found, skipping cleanup.'); + _log.d('ℹ️ Bin directory not found, skipping cleanup.'); } } catch (e) { - print('❌ Error cleaning up bin directory: $e'); + _log.e('❌ Error cleaning up bin directory', error: e); } // 7. Try to remove the entire .quantus directory if it's empty @@ -134,25 +166,25 @@ class MinerSettingsService { if (await quantusDir.exists()) { try { await quantusDir.delete(); - print('✅ Removed empty .quantus directory: $quantusHome'); + _log.i('✅ Removed empty .quantus directory: $quantusHome'); } catch (e) { // Directory not empty, that's fine - print('ℹ️ .quantus directory not empty, keeping it.'); + _log.d('ℹ️ .quantus directory not empty, keeping it.'); } } } catch (e) { - print('❌ Error removing .quantus directory: $e'); + _log.e('❌ Error removing .quantus directory', error: e); } // 8. Clear SharedPreferences try { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - print('✅ SharedPreferences cleared'); + _log.i('✅ SharedPreferences cleared'); } catch (e) { - print('❌ Error clearing SharedPreferences: $e'); + _log.e('❌ Error clearing SharedPreferences', error: e); } - print('🎉 App logout/reset complete! You can now go through setup again.'); + _log.i('🎉 App logout/reset complete!'); } } diff --git a/miner-app/lib/src/services/mining_orchestrator.dart b/miner-app/lib/src/services/mining_orchestrator.dart new file mode 100644 index 00000000..8e0a1240 --- /dev/null +++ b/miner-app/lib/src/services/mining_orchestrator.dart @@ -0,0 +1,642 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/chain_rpc_client.dart'; +import 'package:quantus_miner/src/services/external_miner_api_client.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/services/miner_process_manager.dart'; +import 'package:quantus_miner/src/services/mining_stats_service.dart'; +import 'package:quantus_miner/src/services/node_process_manager.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/services/prometheus_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('Orchestrator'); + +/// Current state of the mining orchestrator. +enum MiningState { + /// Not started, ready to begin. + idle, + + /// Node is starting up. + startingNode, + + /// Node is running, waiting for RPC to be ready. + waitingForRpc, + + /// Node is running (RPC ready), miner not started. + nodeRunning, + + /// Miner is starting up. + startingMiner, + + /// Both node and miner are running, mining is active. + mining, + + /// Stopping miner only. + stoppingMiner, + + /// Currently stopping everything. + stopping, + + /// An error occurred. + error, +} + +/// Configuration for starting a mining session. +class MiningSessionConfig { + /// Path to the node binary. + final File nodeBinary; + + /// Path to the miner binary. + final File minerBinary; + + /// Path to the node identity key file. + final File identityFile; + + /// Path to the rewards address file. + final File rewardsFile; + + /// Chain ID to connect to. + final String chainId; + + /// Number of CPU worker threads. + final int cpuWorkers; + + /// Number of GPU devices to use. + final int gpuDevices; + + /// Detected GPU count for stats. + final int detectedGpuCount; + + /// Port for QUIC miner connection. + final int minerListenPort; + + MiningSessionConfig({ + required this.nodeBinary, + required this.minerBinary, + required this.identityFile, + required this.rewardsFile, + this.chainId = 'dev', + this.cpuWorkers = 8, + this.gpuDevices = 0, + this.detectedGpuCount = 0, + this.minerListenPort = 9833, + }); +} + +/// Orchestrates the complete mining workflow. +/// +/// Coordinates: +/// - Node process lifecycle +/// - Miner process lifecycle +/// - Stats collection from multiple sources +/// - Error handling and crash detection +/// +/// This is the main entry point for mining operations and replaces +/// the old monolithic MinerProcess class. +class MiningOrchestrator { + // Process managers + final NodeProcessManager _nodeManager = NodeProcessManager(); + final MinerProcessManager _minerManager = MinerProcessManager(); + + // API clients for stats + late ExternalMinerApiClient _minerApiClient; + late PollingChainRpcClient _chainRpcClient; + late PrometheusService _prometheusService; + + // Stats + final MiningStatsService _statsService = MiningStatsService(); + + // State + MiningState _state = MiningState.idle; + Timer? _prometheusTimer; + int _actualMetricsPort = MinerConfig.defaultMinerMetricsPort; + + // Hashrate tracking for resilience + double _lastValidHashrate = 0.0; + int _consecutiveMetricsFailures = 0; + + // Stream controllers + final _logsController = StreamController.broadcast(); + final _statsController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + // Subscriptions + StreamSubscription? _nodeLogsSubscription; + StreamSubscription? _minerLogsSubscription; + StreamSubscription? _nodeErrorSubscription; + StreamSubscription? _minerErrorSubscription; + + // ============================================================ + // Public API + // ============================================================ + + /// Current mining state. + MiningState get state => _state; + + /// Stream of log entries from both node and miner. + Stream get logsStream => _logsController.stream; + + /// Stream of mining statistics updates. + Stream get statsStream => _statsController.stream; + + /// Stream of errors (crashes, startup failures, etc.). + Stream get errorStream => _errorController.stream; + + /// Stream of state changes. + Stream get stateStream => _stateController.stream; + + /// Current mining statistics. + MiningStats get currentStats => _statsService.currentStats; + + /// Whether mining is currently active. + bool get isMining => _state == MiningState.mining; + + /// Whether the node is running (with or without miner). + bool get isNodeRunning => + _state == MiningState.nodeRunning || + _state == MiningState.startingMiner || + _state == MiningState.mining || + _state == MiningState.stoppingMiner; + + /// Whether the orchestrator is in any running state. + bool get isRunning => _state != MiningState.idle && _state != MiningState.error; + + /// Node process PID, if running. + int? get nodeProcessPid => _nodeManager.pid; + + /// Miner process PID, if running. + int? get minerProcessPid => _minerManager.pid; + + // Store config for later use when starting miner separately + MiningSessionConfig? _currentConfig; + + MiningOrchestrator() { + _initializeApiClients(); + _setupNodeSyncCallback(); + _subscribeToProcessEvents(); + } + + /// Start mining with the given configuration (starts both node and miner). + /// + /// This will: + /// 1. Cleanup any existing processes + /// 2. Ensure ports are available + /// 3. Start the node and wait for RPC + /// 4. Start the miner + /// 5. Begin polling for stats + Future start(MiningSessionConfig config) async { + await startNode(config); + if (_state == MiningState.nodeRunning) { + await startMiner(); + } + } + + /// Start only the node (without the miner). + /// + /// Use this to enable balance queries and chain sync without mining. + Future startNode(MiningSessionConfig config) async { + if (_state != MiningState.idle && _state != MiningState.error) { + _log.w('Cannot start node: already in state $_state'); + return; + } + + _currentConfig = config; + + try { + // Initialize stats with worker counts + _statsService.updateWorkers(config.cpuWorkers); + _statsService.updateCpuCapacity(Platform.numberOfProcessors); + _statsService.updateGpuDevices(config.gpuDevices); + _statsService.updateGpuCapacity(config.detectedGpuCount); + _emitStats(); + + // Perform pre-start cleanup + _setState(MiningState.startingNode); + await ProcessCleanupService.performPreStartCleanup(config.chainId); + + // Ensure ports are available + final ports = await ProcessCleanupService.ensurePortsAvailable( + quicPort: config.minerListenPort, + metricsPort: MinerConfig.defaultMinerMetricsPort, + ); + _actualMetricsPort = ports['metrics']!; + _updateMetricsClient(); + + // Read rewards address + final rewardsAddress = await _readRewardsAddress(config.rewardsFile); + + // Start node + await _nodeManager.start( + NodeConfig( + binary: config.nodeBinary, + identityFile: config.identityFile, + rewardsAddress: rewardsAddress, + chainId: config.chainId, + minerListenPort: config.minerListenPort, + ), + ); + + // Wait for node RPC to be ready + _setState(MiningState.waitingForRpc); + await _waitForNodeRpc(); + + // Start chain RPC polling (for balance, sync status, etc.) + _chainRpcClient.startPolling(); + + // Start Prometheus polling for target block + _prometheusTimer?.cancel(); + _prometheusTimer = Timer.periodic(MinerConfig.prometheusPollingInterval, (_) => _fetchPrometheusMetrics()); + + _setState(MiningState.nodeRunning); + _log.i('Node started successfully'); + } catch (e, st) { + _log.e('Failed to start node', error: e, stackTrace: st); + _setState(MiningState.error); + await _stopInternal(); + rethrow; + } + } + + /// Update miner settings (CPU workers, GPU devices). + /// Call this before startMiner() if settings have changed. + void updateMinerSettings({int? cpuWorkers, int? gpuDevices}) { + if (_currentConfig == null) { + _log.w('Cannot update settings: no config available'); + return; + } + + _currentConfig = MiningSessionConfig( + nodeBinary: _currentConfig!.nodeBinary, + minerBinary: _currentConfig!.minerBinary, + identityFile: _currentConfig!.identityFile, + rewardsFile: _currentConfig!.rewardsFile, + chainId: _currentConfig!.chainId, + cpuWorkers: cpuWorkers ?? _currentConfig!.cpuWorkers, + gpuDevices: gpuDevices ?? _currentConfig!.gpuDevices, + detectedGpuCount: _currentConfig!.detectedGpuCount, + minerListenPort: _currentConfig!.minerListenPort, + ); + + // Update stats to reflect new settings + _statsService.updateWorkers(_currentConfig!.cpuWorkers); + _statsService.updateGpuDevices(_currentConfig!.gpuDevices); + _emitStats(); + + _log.i( + 'Miner settings updated: cpuWorkers=${_currentConfig!.cpuWorkers}, gpuDevices=${_currentConfig!.gpuDevices}', + ); + } + + /// Start the miner (node must already be running). + Future startMiner() async { + if (_state != MiningState.nodeRunning) { + _log.w('Cannot start miner: node not running (state: $_state)'); + return; + } + + if (_currentConfig == null) { + _log.e('Cannot start miner: no config available'); + return; + } + + final config = _currentConfig!; + + try { + _setState(MiningState.startingMiner); + + await _minerManager.start( + ExternalMinerConfig( + binary: config.minerBinary, + nodeAddress: '${MinerConfig.localhost}:${config.minerListenPort}', + cpuWorkers: config.cpuWorkers, + gpuDevices: config.gpuDevices, + metricsPort: _actualMetricsPort, + ), + ); + + // Start miner metrics polling + _minerApiClient.startPolling(); + + // Update stats to reflect miner is running + _statsService.setMinerRunning(true); + _emitStats(); + + _setState(MiningState.mining); + _log.i('Miner started successfully'); + } catch (e, st) { + _log.e('Failed to start miner', error: e, stackTrace: st); + _statsService.setMinerRunning(false); + _setState(MiningState.nodeRunning); // Revert to node-only state + rethrow; + } + } + + /// Stop only the miner (keep node running). + Future stopMiner() async { + if (_state != MiningState.mining) { + _log.w('Cannot stop miner: not mining (state: $_state)'); + return; + } + + _log.i('Stopping miner...'); + _setState(MiningState.stoppingMiner); + + _minerApiClient.stopPolling(); + await _minerManager.stop(); + + // Update stats to reflect miner is stopped + _statsService.setMinerRunning(false); + _resetStats(); + _setState(MiningState.nodeRunning); + _log.i('Miner stopped, node still running'); + } + + /// Stop everything (node and miner) gracefully. + Future stop() async { + if (_state == MiningState.idle) { + return; + } + + _log.i('Stopping everything...'); + _setState(MiningState.stopping); + await _stopInternal(); + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('All processes stopped'); + } + + /// Stop only the node (and miner if running). + Future stopNode() async { + if (!isNodeRunning && _state != MiningState.startingNode && _state != MiningState.waitingForRpc) { + _log.w('Cannot stop node: not running (state: $_state)'); + return; + } + + _log.i('Stopping node...'); + _setState(MiningState.stopping); + await _stopInternal(); + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('Node stopped'); + } + + /// Force stop everything immediately. + void forceStop() { + _log.i('Force stopping everything...'); + _setState(MiningState.stopping); + + _stopPolling(); + _minerManager.forceStop(); + _nodeManager.forceStop(); + + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('Force stopped'); + } + + /// Dispose of all resources. + void dispose() { + forceStop(); + + _nodeLogsSubscription?.cancel(); + _minerLogsSubscription?.cancel(); + _nodeErrorSubscription?.cancel(); + _minerErrorSubscription?.cancel(); + + _nodeManager.dispose(); + _minerManager.dispose(); + _minerApiClient.dispose(); + _chainRpcClient.dispose(); + + _logsController.close(); + _statsController.close(); + _errorController.close(); + _stateController.close(); + } + + // ============================================================ + // Internal Implementation + // ============================================================ + + void _initializeApiClients() { + _minerApiClient = ExternalMinerApiClient( + metricsUrl: MinerConfig.minerMetricsUrl(MinerConfig.defaultMinerMetricsPort), + ); + _minerApiClient.onMetricsUpdate = _handleMinerMetrics; + _minerApiClient.onError = _handleMinerMetricsError; + + _chainRpcClient = PollingChainRpcClient(); + _chainRpcClient.onChainInfoUpdate = _handleChainInfo; + _chainRpcClient.onError = _handleChainRpcError; + + _prometheusService = PrometheusService(); + } + + void _setupNodeSyncCallback() { + _nodeManager.getSyncState = () => _statsService.currentStats.isSyncing; + } + + void _subscribeToProcessEvents() { + // Forward node logs + _nodeLogsSubscription = _nodeManager.logs.listen((entry) { + _logsController.add(entry); + }); + + // Forward miner logs + _minerLogsSubscription = _minerManager.logs.listen((entry) { + _logsController.add(entry); + }); + + // Forward node errors + _nodeErrorSubscription = _nodeManager.errors.listen((error) { + _errorController.add(error); + if (error.type == MinerErrorType.nodeCrashed && _state == MiningState.mining) { + _log.w('Node crashed while mining, stopping...'); + _handleCrash(); + } + }); + + // Forward miner errors + _minerErrorSubscription = _minerManager.errors.listen((error) { + _errorController.add(error); + if (error.type == MinerErrorType.minerCrashed && _state == MiningState.mining) { + _log.w('Miner crashed while mining'); + // Don't stop everything - just emit the error for UI to show + } + }); + } + + void _updateMetricsClient() { + if (_actualMetricsPort != MinerConfig.defaultMinerMetricsPort) { + _minerApiClient = ExternalMinerApiClient(metricsUrl: MinerConfig.minerMetricsUrl(_actualMetricsPort)); + _minerApiClient.onMetricsUpdate = _handleMinerMetrics; + _minerApiClient.onError = _handleMinerMetricsError; + } + } + + Future _readRewardsAddress(File rewardsFile) async { + if (!await rewardsFile.exists()) { + throw Exception('Rewards address file not found: ${rewardsFile.path}'); + } + final address = await rewardsFile.readAsString(); + return address.trim(); + } + + Future _waitForNodeRpc() async { + _log.d('Waiting for node RPC...'); + int attempts = 0; + Duration delay = MinerConfig.rpcInitialRetryDelay; + + while (attempts < MinerConfig.maxRpcRetries) { + try { + final isReady = await _chainRpcClient.isReachable(); + if (isReady) { + _log.i('Node RPC is ready'); + return; + } + } catch (e) { + // Expected during startup + } + + attempts++; + _log.d('RPC not ready (attempt $attempts/${MinerConfig.maxRpcRetries})'); + await Future.delayed(delay); + + if (delay < MinerConfig.rpcMaxRetryDelay) { + delay = Duration(seconds: (delay.inSeconds * 1.5).round()); + if (delay > MinerConfig.rpcMaxRetryDelay) { + delay = MinerConfig.rpcMaxRetryDelay; + } + } + } + + _log.w('Node RPC not ready after max attempts, proceeding anyway'); + } + + void _stopPolling() { + _minerApiClient.stopPolling(); + _chainRpcClient.stopPolling(); + _prometheusTimer?.cancel(); + _prometheusTimer = null; + } + + Future _stopInternal() async { + _stopPolling(); + + // Stop miner first (depends on node) + await _minerManager.stop(); + + // Then stop node + await _nodeManager.stop(); + } + + void _handleCrash() { + _setState(MiningState.error); + _stopPolling(); + } + + void _setState(MiningState newState) { + if (_state != newState) { + _state = newState; + _stateController.add(newState); + _log.d('State changed to: $newState'); + } + } + + void _emitStats() { + _statsController.add(_statsService.currentStats); + } + + void _resetStats() { + _statsService.updateHashrate(0); + _statsService.setMinerRunning(false); + _lastValidHashrate = 0; + _consecutiveMetricsFailures = 0; + _emitStats(); + } + + // ============================================================ + // Metrics Handlers + // ============================================================ + + void _handleMinerMetrics(ExternalMinerMetrics metrics) { + if (metrics.isHealthy && metrics.hashRate > 0) { + _lastValidHashrate = metrics.hashRate; + _consecutiveMetricsFailures = 0; + + _statsService.updateHashrate(metrics.hashRate); + // NOTE: Don't update workers from metrics - miner_workers includes GPU workers + // which would incorrectly inflate the CPU count. We use the configured cpuWorkers instead. + if (metrics.cpuCapacity > 0) { + _statsService.updateCpuCapacity(metrics.cpuCapacity); + } + // NOTE: Don't update gpuDevices from metrics - use the configured value instead + // if (metrics.gpuDevices > 0) { + // _statsService.updateGpuDevices(metrics.gpuDevices); + // } + _emitStats(); + } else if (metrics.hashRate == 0.0 && _lastValidHashrate > 0) { + // Keep last valid hashrate during temporary zeroes + _statsService.updateHashrate(_lastValidHashrate); + _emitStats(); + } else { + _consecutiveMetricsFailures++; + if (_consecutiveMetricsFailures >= MinerConfig.maxConsecutiveMetricsFailures) { + _statsService.updateHashrate(0); + _lastValidHashrate = 0; + _emitStats(); + } else if (_lastValidHashrate > 0) { + _statsService.updateHashrate(_lastValidHashrate); + _emitStats(); + } + } + } + + void _handleMinerMetricsError(String error) { + _consecutiveMetricsFailures++; + if (_consecutiveMetricsFailures >= MinerConfig.maxConsecutiveMetricsFailures) { + if (_statsService.currentStats.hashrate != 0) { + _statsService.updateHashrate(0); + _lastValidHashrate = 0; + _emitStats(); + } + } + } + + void _handleChainInfo(ChainInfo info) { + if (info.peerCount >= 0) { + _statsService.updatePeerCount(info.peerCount); + } + _statsService.updateChainName(info.chainName); + _statsService.setSyncingState(info.isSyncing, info.currentBlock, info.targetBlock ?? info.currentBlock); + _emitStats(); + } + + void _handleChainRpcError(String error) { + if (!error.contains('Connection refused') && !error.contains('timeout')) { + _log.w('Chain RPC error: $error'); + } + } + + Future _fetchPrometheusMetrics() async { + try { + final metrics = await _prometheusService.fetchMetrics(); + if (metrics?.targetBlock != null) { + if (_statsService.currentStats.targetBlock < metrics!.targetBlock!) { + _statsService.updateTargetBlock(metrics.targetBlock!); + _emitStats(); + } + } + } catch (e) { + _log.w('Failed to fetch Prometheus metrics', error: e); + } + } +} diff --git a/miner-app/lib/src/services/mining_stats_service.dart b/miner-app/lib/src/services/mining_stats_service.dart index e07f86cc..1786e93b 100644 --- a/miner-app/lib/src/services/mining_stats_service.dart +++ b/miner-app/lib/src/services/mining_stats_service.dart @@ -10,6 +10,7 @@ class MiningStats { final int gpuDevices; final int gpuCapacity; final bool isSyncing; + final bool isMinerRunning; final MiningStatus status; final String chainName; @@ -23,6 +24,7 @@ class MiningStats { this.gpuDevices = 0, this.gpuCapacity = 0, required this.isSyncing, + this.isMinerRunning = false, required this.status, required this.chainName, }); @@ -37,6 +39,7 @@ class MiningStats { gpuDevices = 0, gpuCapacity = 0, isSyncing = false, + isMinerRunning = false, status = MiningStatus.idle, chainName = ''; @@ -50,6 +53,7 @@ class MiningStats { int? gpuDevices, int? gpuCapacity, bool? isSyncing, + bool? isMinerRunning, MiningStatus? status, String? chainName, }) { @@ -63,6 +67,7 @@ class MiningStats { gpuDevices: gpuDevices ?? this.gpuDevices, gpuCapacity: gpuCapacity ?? this.gpuCapacity, isSyncing: isSyncing ?? this.isSyncing, + isMinerRunning: isMinerRunning ?? this.isMinerRunning, status: status ?? this.status, chainName: chainName ?? this.chainName, ); @@ -134,14 +139,34 @@ class MiningStatsService { /// Manually set syncing state (used by RPC client) void setSyncingState(bool isSyncing, int? currentBlock, int? targetBlock) { - final status = isSyncing ? MiningStatus.syncing : MiningStatus.mining; - _currentStats = _currentStats.copyWith( isSyncing: isSyncing, - status: status, currentBlock: currentBlock ?? _currentStats.currentBlock, targetBlock: targetBlock ?? _currentStats.targetBlock, ); + _updateStatus(); + } + + /// Set whether the miner process is running + void setMinerRunning(bool isRunning) { + _currentStats = _currentStats.copyWith(isMinerRunning: isRunning); + _updateStatus(); + } + + /// Update the status based on current state + void _updateStatus() { + MiningStatus status; + if (_currentStats.isSyncing) { + status = MiningStatus.syncing; + } else if (_currentStats.isMinerRunning) { + status = MiningStatus.mining; + } else { + status = MiningStatus.idle; + } + + if (_currentStats.status != status) { + _currentStats = _currentStats.copyWith(status: status); + } } /// Update chain name from RPC data diff --git a/miner-app/lib/src/services/node_process_manager.dart b/miner-app/lib/src/services/node_process_manager.dart new file mode 100644 index 00000000..0a04f485 --- /dev/null +++ b/miner-app/lib/src/services/node_process_manager.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/base_process_manager.dart'; +import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('NodeProcess'); + +/// Configuration for starting the node process. +class NodeConfig { + /// Path to the node binary. + final File binary; + + /// Path to the node identity key file. + final File identityFile; + + /// The rewards address for mining. + final String rewardsAddress; + + /// Chain ID to connect to ('dev' or 'dirac'). + final String chainId; + + /// Port for the QUIC miner connection. + final int minerListenPort; + + /// Port for JSON-RPC endpoint. + final int rpcPort; + + /// Port for Prometheus metrics. + final int prometheusPort; + + /// Port for P2P networking. + final int p2pPort; + + NodeConfig({ + required this.binary, + required this.identityFile, + required this.rewardsAddress, + this.chainId = 'dev', + this.minerListenPort = 9833, + this.rpcPort = 9933, + this.prometheusPort = 9616, + this.p2pPort = 30333, + }); +} + +/// Manages the quantus-node process lifecycle. +/// +/// Responsibilities: +/// - Starting the node process with proper arguments +/// - Monitoring process health and exit +/// - Stopping the process gracefully or forcefully +/// - Emitting log entries and error events +class NodeProcessManager extends BaseProcessManager { + /// Callback to get current sync state for log filtering. + SyncStateProvider? getSyncState; + + @override + TaggedLoggerWrapper get log => _log; + + @override + String get processName => 'node'; + + @override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]) { + return MinerError.nodeStartupFailed(error, stackTrace); + } + + @override + MinerError createCrashError(int exitCode) { + return MinerError.nodeCrashed(exitCode); + } + + NodeProcessManager() { + initLogProcessor('node', getSyncState: () => getSyncState?.call() ?? false); + } + + /// Start the node process. + /// + /// Throws an exception if startup fails. + Future start(NodeConfig config) async { + if (isRunning) { + log.w('Node already running (PID: $pid)'); + return; + } + + intentionalStop = false; + + // Validate binary exists + if (!await config.binary.exists()) { + final error = MinerError.nodeStartupFailed('Node binary not found: ${config.binary.path}'); + errorController.add(error); + throw Exception(error.message); + } + + // Validate identity file exists + if (!await config.identityFile.exists()) { + final error = MinerError.nodeStartupFailed('Identity file not found: ${config.identityFile.path}'); + errorController.add(error); + throw Exception(error.message); + } + + // Prepare data directory + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final basePath = p.join(quantusHome, 'node_data'); + await Directory(basePath).create(recursive: true); + + // Build command arguments + final args = _buildArgs(config, basePath); + + log.i('Starting node...'); + log.d('Command: ${config.binary.path} ${args.join(' ')}'); + + try { + final proc = await Process.start(config.binary.path, args); + attachProcess(proc); + + // Monitor for unexpected exit + proc.exitCode.then(handleExit); + + log.i('Node started (PID: $pid)'); + } catch (e, st) { + final error = MinerError.nodeStartupFailed(e, st); + errorController.add(error); + clearProcess(); + rethrow; + } + } + + List _buildArgs(NodeConfig config, String basePath) { + return [ + // Only use --base-path for non-dev chains (dev uses temp storage for fresh state) + if (config.chainId != 'dev') ...['--base-path', basePath], + '--node-key-file', config.identityFile.path, + '--rewards-address', config.rewardsAddress, + '--validator', + // Chain selection + if (config.chainId == 'dev') '--dev' else ...['--chain', config.chainId], + '--port', config.p2pPort.toString(), + '--prometheus-port', config.prometheusPort.toString(), + '--experimental-rpc-endpoint', + 'listen-addr=${MinerConfig.localhost}:${config.rpcPort},methods=unsafe,cors=all', + '--name', 'QuantusMinerGUI', + '--miner-listen-port', config.minerListenPort.toString(), + '--enable-peer-sharing', + ]; + } +} diff --git a/miner-app/lib/src/services/process_cleanup_service.dart b/miner-app/lib/src/services/process_cleanup_service.dart new file mode 100644 index 00000000..a144e687 --- /dev/null +++ b/miner-app/lib/src/services/process_cleanup_service.dart @@ -0,0 +1,410 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ProcessCleanup'); + +/// Service responsible for platform-specific process management operations. +/// +/// This includes: +/// - Checking if processes are running +/// - Killing processes by PID or name +/// - Port availability checking and cleanup +/// - Database lock file cleanup +/// - Directory access verification +class ProcessCleanupService { + ProcessCleanupService._(); + + // ============================================================ + // Process Running Checks + // ============================================================ + + /// Check if a process with the given PID is currently running. + static Future isProcessRunning(int pid) async { + try { + if (Platform.isWindows) { + final result = await Process.run('tasklist', ['/FI', 'PID eq $pid']); + return result.stdout.toString().contains(' $pid '); + } else { + // On Unix, kill -0 checks if process exists without killing it + final result = await Process.run('kill', ['-0', pid.toString()]); + return result.exitCode == 0; + } + } catch (e) { + return false; + } + } + + // ============================================================ + // Process Killing + // ============================================================ + + /// Force kill a process by PID with verification. + /// + /// Returns true if the process was successfully killed or was already dead. + static Future forceKillProcess(int pid, String processName) async { + try { + _log.d(' Force killing $processName (PID: $pid)'); + + if (Platform.isWindows) { + return await _forceKillWindowsProcess(pid, processName); + } else { + return await _forceKillUnixProcess(pid, processName); + } + } catch (e) { + _log.e('Error killing $processName', error: e); + return false; + } + } + + static Future _forceKillWindowsProcess(int pid, String processName) async { + final killResult = await Process.run('taskkill', ['/F', '/PID', pid.toString()]); + + if (killResult.exitCode == 0) { + _log.d('Killed $processName (PID: $pid)'); + } else { + _log.w('taskkill failed for $processName (PID: $pid), exit: ${killResult.exitCode}'); + } + + await Future.delayed(MinerConfig.processVerificationDelay); + + // Verify process is dead + final checkResult = await Process.run('tasklist', ['/FI', 'PID eq $pid']); + if (checkResult.stdout.toString().contains(' $pid ')) { + _log.w('$processName (PID: $pid) may still be running'); + + // Try by name as last resort + final binaryName = processName.contains('miner') + ? MinerConfig.minerBinaryNameWindows + : MinerConfig.nodeBinaryNameWindows; + await Process.run('taskkill', ['/F', '/IM', binaryName]); + return false; + } + + _log.d('Verified $processName (PID: $pid) terminated'); + return true; + } + + static Future _forceKillUnixProcess(int pid, String processName) async { + // First try SIGKILL via kill command + final killResult = await Process.run('kill', ['-9', pid.toString()]); + + if (killResult.exitCode == 0) { + _log.d('Killed $processName (PID: $pid)'); + } else { + _log.w('kill failed for $processName (PID: $pid), exit: ${killResult.exitCode}'); + } + + await Future.delayed(MinerConfig.processVerificationDelay); + + // Verify process is dead + final checkResult = await Process.run('kill', ['-0', pid.toString()]); + if (checkResult.exitCode == 0) { + _log.w('$processName (PID: $pid) may still be running'); + + // Try pkill as last resort + final binaryName = processName.contains('miner') ? MinerConfig.minerBinaryName : MinerConfig.nodeBinaryName; + await Process.run('pkill', ['-9', '-f', binaryName]); + return false; + } + + _log.d('Verified $processName (PID: $pid) terminated'); + return true; + } + + /// Kill all processes matching the given binary name. + static Future killProcessesByName(String binaryName) async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', ['/F', '/IM', '$binaryName.exe']); + } else { + await Process.run('pkill', ['-9', '-f', binaryName]); + } + } catch (e) { + // Ignore errors - processes might not exist + } + } + + // ============================================================ + // Port Management + // ============================================================ + + /// Check if a port is currently in use. + static Future isPortInUse(int port) async { + try { + if (Platform.isWindows) { + final result = await Process.run('netstat', ['-ano']); + return result.exitCode == 0 && result.stdout.toString().contains(':$port'); + } else { + final result = await Process.run('lsof', ['-i', ':$port']); + return result.exitCode == 0 && result.stdout.toString().isNotEmpty; + } + } catch (e) { + // lsof might not be available, try netstat as fallback + try { + final result = await Process.run('netstat', ['-an']); + return result.stdout.toString().contains(':$port'); + } catch (e2) { + _log.d('Could not check port $port availability'); + return false; + } + } + } + + /// Kill any process using the specified port. + static Future killProcessOnPort(int port) async { + try { + if (Platform.isWindows) { + await _killProcessOnPortWindows(port); + } else { + await _killProcessOnPortUnix(port); + } + } catch (e) { + // Ignore cleanup errors + } + } + + static Future _killProcessOnPortWindows(int port) async { + final result = await Process.run('netstat', ['-ano']); + if (result.exitCode != 0) return; + + final lines = result.stdout.toString().split('\n'); + for (final line in lines) { + if (line.contains(':$port')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.isNotEmpty) { + final pid = parts.last; + // Verify it's a valid PID number + if (int.tryParse(pid) != null) { + await Process.run('taskkill', ['/F', '/PID', pid]); + } + } + } + } + } + + static Future _killProcessOnPortUnix(int port) async { + final result = await Process.run('lsof', ['-ti', ':$port']); + if (result.exitCode != 0) return; + + final pids = result.stdout.toString().trim().split('\n'); + for (final pid in pids) { + if (pid.isNotEmpty) { + await Process.run('kill', ['-9', pid.trim()]); + } + } + } + + /// Find an available port starting from the given port. + /// + /// Tries ports in range [startPort, startPort + MinerConfig.portSearchRange]. + /// Returns the original port if no alternative is found. + static Future findAvailablePort(int startPort) async { + for (int port = startPort; port <= startPort + MinerConfig.portSearchRange; port++) { + if (!(await isPortInUse(port))) { + return port; + } + } + return startPort; // Return original if no alternative found + } + + /// Ensure required ports are available, cleaning up if necessary. + /// + /// Returns a map of port names to their actual values (may differ from defaults + /// if an alternative port was needed). + static Future> ensurePortsAvailable({required int quicPort, required int metricsPort}) async { + final result = {'quic': quicPort, 'metrics': metricsPort}; + + // Check QUIC port + if (await isPortInUse(quicPort)) { + await killProcessOnPort(quicPort); + await Future.delayed(MinerConfig.portCleanupDelay); + + if (await isPortInUse(quicPort)) { + throw Exception('Port $quicPort is still in use after cleanup attempt'); + } + } + + // Check metrics port + if (await isPortInUse(metricsPort)) { + await killProcessOnPort(metricsPort); + await Future.delayed(MinerConfig.portCleanupDelay); + + if (await isPortInUse(metricsPort)) { + // Try to find an alternative port + final altPort = await findAvailablePort(metricsPort + 1); + _log.i('Using alternative metrics port: $altPort'); + result['metrics'] = altPort; + } + } + + return result; + } + + // ============================================================ + // Existing Process Cleanup + // ============================================================ + + /// Cleanup any existing quantus-node processes. + static Future cleanupExistingNodeProcesses() async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', ['/F', '/IM', MinerConfig.nodeBinaryNameWindows]); + await Future.delayed(MinerConfig.processCleanupDelay); + } else { + await _cleanupUnixProcesses(MinerConfig.nodeBinaryName); + } + } catch (e) { + // Ignore cleanup errors + } + } + + /// Cleanup any existing quantus-miner processes. + static Future cleanupExistingMinerProcesses() async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', ['/F', '/IM', MinerConfig.minerBinaryNameWindows]); + await Future.delayed(MinerConfig.processCleanupDelay); + } else { + await _cleanupUnixProcesses(MinerConfig.minerBinaryName); + } + } catch (e) { + // Ignore cleanup errors + } + } + + static Future _cleanupUnixProcesses(String processName) async { + final result = await Process.run('pgrep', ['-f', processName]); + if (result.exitCode != 0) return; + + final pids = result.stdout.toString().trim().split('\n'); + for (final pid in pids) { + if (pid.isEmpty) continue; + + try { + // Try graceful termination first (SIGTERM) + await Process.run('kill', ['-15', pid.trim()]); + await Future.delayed(const Duration(seconds: 1)); + + // Check if still running, force kill if needed + final checkResult = await Process.run('kill', ['-0', pid.trim()]); + if (checkResult.exitCode == 0) { + await Process.run('kill', ['-9', pid.trim()]); + } + } catch (e) { + // Ignore cleanup errors + } + } + + // Wait for processes to fully terminate + await Future.delayed(MinerConfig.processCleanupDelay); + } + + // ============================================================ + // Database & Directory Cleanup + // ============================================================ + + /// Cleanup database lock files that may prevent node startup. + static Future cleanupDatabaseLocks(String chainId) async { + try { + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final lockFilePath = '$quantusHome/node_data/chains/$chainId/db/full/LOCK'; + final lockFile = File(lockFilePath); + + if (await lockFile.exists()) { + await lockFile.delete(); + _log.d(' Deleted lock file: $lockFilePath'); + } + + // Also check for other potential lock files + final dbDir = Directory('$quantusHome/node_data/chains/$chainId/db/full'); + if (await dbDir.exists()) { + await for (final entity in dbDir.list()) { + if (entity is File && entity.path.contains('LOCK')) { + try { + await entity.delete(); + _log.d(' Deleted lock file: ${entity.path}'); + } catch (e) { + // Ignore cleanup errors + } + } + } + } + } catch (e) { + // Ignore cleanup errors + } + } + + /// Check and fix database directory permissions. + static Future ensureDatabaseDirectoryAccess(String chainId) async { + try { + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final dbPath = '$quantusHome/node_data/chains/$chainId/db'; + final dbDir = Directory(dbPath); + + // Create the directory if it doesn't exist + if (!await dbDir.exists()) { + await dbDir.create(recursive: true); + } + + // Check if directory is writable + final testFile = File('$dbPath/test_write_access'); + try { + await testFile.writeAsString('test'); + await testFile.delete(); + } catch (e) { + // Try to fix permissions (Unix only) + if (!Platform.isWindows) { + try { + await Process.run('chmod', ['-R', '755', dbPath]); + } catch (permError) { + // Ignore permission fix errors + } + } + } + } catch (e) { + // Ignore directory access errors + } + } + + // ============================================================ + // Combined Cleanup Operations + // ============================================================ + + /// Perform full cleanup before starting mining. + /// + /// This cleans up: + /// - Existing node processes + /// - Existing miner processes + /// - Database locks + /// - Ensures directory access + static Future performPreStartCleanup(String chainId) async { + await cleanupExistingNodeProcesses(); + await cleanupExistingMinerProcesses(); + await cleanupDatabaseLocks(chainId); + await ensureDatabaseDirectoryAccess(chainId); + } + + /// Kill all quantus processes by name. + /// + /// This is a more aggressive cleanup used during app exit. + static Future killAllQuantusProcesses() async { + try { + _log.d(' Killing all quantus processes...'); + + if (Platform.isWindows) { + await Process.run('taskkill', ['/F', '/IM', MinerConfig.nodeBinaryNameWindows]); + await Process.run('taskkill', ['/F', '/IM', MinerConfig.minerBinaryNameWindows]); + } else { + await Process.run('pkill', ['-9', '-f', MinerConfig.nodeBinaryName]); + await Process.run('pkill', ['-9', '-f', MinerConfig.minerBinaryName]); + } + + _log.d(' Cleanup commands executed'); + } catch (e) { + _log.d(' Error killing processes: $e'); + } + } +} diff --git a/miner-app/lib/src/services/prometheus_service.dart b/miner-app/lib/src/services/prometheus_service.dart index ab5629c2..68d45a0d 100644 --- a/miner-app/lib/src/services/prometheus_service.dart +++ b/miner-app/lib/src/services/prometheus_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; // Data class to hold the parsed metrics class PrometheusMetrics { @@ -19,7 +20,8 @@ class PrometheusMetrics { class PrometheusService { final String metricsUrl; - PrometheusService({this.metricsUrl = 'http://127.0.0.1:9616/metrics'}); + PrometheusService({String? metricsUrl}) + : metricsUrl = metricsUrl ?? MinerConfig.nodePrometheusUrl(MinerConfig.defaultNodePrometheusPort); Future fetchMetrics() async { try { diff --git a/miner-app/lib/src/ui/logs_widget.dart b/miner-app/lib/src/ui/logs_widget.dart index f86ed0d3..8f40a6ea 100644 --- a/miner-app/lib/src/ui/logs_widget.dart +++ b/miner-app/lib/src/ui/logs_widget.dart @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -import '../services/miner_process.dart'; +import '../services/log_stream_processor.dart'; +import '../services/mining_orchestrator.dart'; class LogsWidget extends StatefulWidget { - final MinerProcess? minerProcess; + final MiningOrchestrator? orchestrator; final int maxLines; - const LogsWidget({super.key, this.minerProcess, this.maxLines = 20000}); + const LogsWidget({super.key, this.orchestrator, this.maxLines = 20000}); @override State createState() => _LogsWidgetState(); @@ -30,7 +31,7 @@ class _LogsWidgetState extends State { @override void didUpdateWidget(LogsWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.minerProcess != widget.minerProcess) { + if (oldWidget.orchestrator != widget.orchestrator) { _setupLogsListener(); } } @@ -39,8 +40,8 @@ class _LogsWidgetState extends State { _logsSubscription?.cancel(); _logs.clear(); - if (widget.minerProcess != null) { - _logsSubscription = widget.minerProcess!.logsStream.listen((logEntry) { + if (widget.orchestrator != null) { + _logsSubscription = widget.orchestrator!.logsStream.listen((logEntry) { if (mounted) { setState(() { _logs.add(logEntry); @@ -89,6 +90,11 @@ class _LogsWidgetState extends State { return Colors.blue; case 'node-error': return Colors.red; + case 'miner': + return Colors.green; + case 'miner-error': + return Colors.orange; + // Legacy source names for compatibility case 'quantus-miner': return Colors.green; case 'quantus-miner-error': @@ -131,7 +137,7 @@ class _LogsWidgetState extends State { child: _logs.isEmpty ? const Center( child: Text( - 'No logs available\nStart mining to see live logs', + 'No logs available\nStart the node to see live logs', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), ), @@ -202,7 +208,7 @@ class _LogsWidgetState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Total logs: ${_logs.length}', style: TextStyle(fontSize: 12, color: Colors.grey[600])), - if (widget.minerProcess != null) + if (widget.orchestrator?.isMining ?? false) Text( 'Live', style: TextStyle(fontSize: 12, color: Colors.green, fontWeight: FontWeight.w500), diff --git a/miner-app/lib/src/utils/app_logger.dart b/miner-app/lib/src/utils/app_logger.dart new file mode 100644 index 00000000..276cc602 --- /dev/null +++ b/miner-app/lib/src/utils/app_logger.dart @@ -0,0 +1,132 @@ +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +/// Application-wide logger instance. +/// +/// Usage: +/// ```dart +/// import 'package:quantus_miner/src/utils/app_logger.dart'; +/// +/// log.d('Debug message'); +/// log.i('Info message'); +/// log.w('Warning message'); +/// log.e('Error message', error: e, stackTrace: st); +/// ``` +/// +/// Log levels: +/// - `d` (debug): Detailed debugging information +/// - `i` (info): General information about app operation +/// - `w` (warning): Potential issues that don't prevent operation +/// - `e` (error): Errors that affect functionality +final Logger log = Logger( + // In release mode, only show warnings and errors + // In debug mode, show all logs + level: kReleaseMode ? Level.warning : Level.all, + printer: _AppLogPrinter(), + // No file output for now + output: null, +); + +/// Custom log printer for cleaner console output. +/// +/// Format: `[LEVEL] [SOURCE] message` +/// Example: `[D] [MinerProcess] Starting node...` +class _AppLogPrinter extends LogPrinter { + static final _levelPrefixes = { + Level.trace: 'T', + Level.debug: 'D', + Level.info: 'I', + Level.warning: 'W', + Level.error: 'E', + Level.fatal: 'F', + }; + + static final _levelColors = { + Level.trace: AnsiColor.fg(AnsiColor.grey(0.5)), + Level.debug: AnsiColor.none(), + Level.info: AnsiColor.fg(12), // Light blue + Level.warning: AnsiColor.fg(208), // Orange + Level.error: AnsiColor.fg(196), // Red + Level.fatal: AnsiColor.fg(199), // Magenta + }; + + @override + List log(LogEvent event) { + final prefix = _levelPrefixes[event.level] ?? '?'; + final color = _levelColors[event.level] ?? AnsiColor.none(); + final time = _formatTime(event.time); + + final messageStr = event.message.toString(); + final lines = []; + + // Main log line + lines.add(color('[$time] [$prefix] $messageStr')); + + // Add error if present + if (event.error != null) { + lines.add(color('[$time] [$prefix] Error: ${event.error}')); + } + + // Add stack trace if present (only for errors) + if (event.stackTrace != null && event.level.index >= Level.error.index) { + final stackLines = event.stackTrace.toString().split('\n').take(5); + for (final line in stackLines) { + if (line.trim().isNotEmpty) { + lines.add(color('[$time] [$prefix] $line')); + } + } + } + + return lines; + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + } +} + +/// Extension to make logging from specific sources cleaner. +/// +/// Usage: +/// ```dart +/// final _log = log.withTag('MinerProcess'); +/// _log.d('Starting...'); // Output: [D] [MinerProcess] Starting... +/// ``` +extension TaggedLogger on Logger { + /// Create a logger that prefixes all messages with a tag. + TaggedLoggerWrapper withTag(String tag) => TaggedLoggerWrapper(this, tag); +} + +/// Wrapper that adds a tag prefix to all log messages. +class TaggedLoggerWrapper { + final Logger _logger; + final String _tag; + + TaggedLoggerWrapper(this._logger, this._tag); + + void t(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.t('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } + + void d(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.d('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } + + void i(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.i('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } + + void w(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.w('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } + + void e(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.e('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } + + void f(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + _logger.f('[$_tag] $message', time: time, error: error, stackTrace: stackTrace); + } +} diff --git a/miner-app/pubspec.yaml b/miner-app/pubspec.yaml index c3d1e6fb..f23926b3 100644 --- a/miner-app/pubspec.yaml +++ b/miner-app/pubspec.yaml @@ -18,15 +18,12 @@ dependencies: # Networking and storage http: # Version managed by melos.yaml shared_preferences: # Version managed by melos.yaml + polkadart: # For local node RPC queries - # State management and architecture - provider: # Version managed by melos.yaml - hooks_riverpod: # Version managed by melos.yaml - flutter_hooks: # Version managed by melos.yaml + # Routing go_router: # Version managed by melos.yaml # UI and utilities - another_flushbar: # Version managed by melos.yaml flutter_svg: # Version managed by melos.yaml # System and file operations diff --git a/mobile-app/ios/Runner/GoogleService-Info.plist b/mobile-app/ios/Runner/GoogleService-Info.plist new file mode 100644 index 00000000..ae6614c1 --- /dev/null +++ b/mobile-app/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCMkKGsze91nCjszkNBvlfiSL2wwSYV188 + GCM_SENDER_ID + 700047185713 + PLIST_VERSION + 1 + BUNDLE_ID + com.quantus.mobile-wallet + PROJECT_ID + quantus-wallet + STORAGE_BUCKET + quantus-wallet.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:700047185713:ios:4689e532e8a4f174d98210 + + \ No newline at end of file diff --git a/mobile-app/lib/features/components/card_info.dart b/mobile-app/lib/features/components/card_info.dart index 498f3586..50e8b323 100644 --- a/mobile-app/lib/features/components/card_info.dart +++ b/mobile-app/lib/features/components/card_info.dart @@ -36,7 +36,7 @@ class CardInfo extends StatelessWidget { width: context.isTablet ? 660 : 251, child: Text(text, style: context.themeText.tag?.copyWith(color: textColor)), ), - if (icon != null) icon!, + ?icon, ], ), ),