diff --git a/cmd/desktop/mining-desktop/main.go b/cmd/desktop/mining-desktop/main.go index afc9d55..c8a3171 100644 --- a/cmd/desktop/mining-desktop/main.go +++ b/cmd/desktop/mining-desktop/main.go @@ -41,11 +41,22 @@ func main() { }, }) - // Create the main window + // Get saved window state + windowState := miningService.GetWindowState() + width := windowState.Width + height := windowState.Height + if width == 0 { + width = 1400 + } + if height == 0 { + height = 900 + } + + // Create the main window with saved dimensions app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Mining Dashboard", - Width: 1400, - Height: 900, + Width: width, + Height: height, Mac: application.MacWindow{ InvisibleTitleBarHeight: 50, Backdrop: application.MacBackdropTranslucent, diff --git a/cmd/desktop/mining-desktop/miningservice.go b/cmd/desktop/mining-desktop/miningservice.go index df5c597..23fc903 100644 --- a/cmd/desktop/mining-desktop/miningservice.go +++ b/cmd/desktop/mining-desktop/miningservice.go @@ -13,17 +13,20 @@ import ( // MiningService exposes mining functionality to the Wails frontend. type MiningService struct { - manager *mining.Manager - profileMgr *mining.ProfileManager + manager *mining.Manager + profileMgr *mining.ProfileManager + settingsMgr *mining.SettingsManager } // NewMiningService creates a new mining service with an initialized manager. func NewMiningService() *MiningService { manager := mining.NewManager() profileMgr, _ := mining.NewProfileManager() + settingsMgr, _ := mining.NewSettingsManager() return &MiningService{ - manager: manager, - profileMgr: profileMgr, + manager: manager, + profileMgr: profileMgr, + settingsMgr: settingsMgr, } } @@ -297,3 +300,87 @@ func (s *MiningService) SendStdin(name, input string) error { func (s *MiningService) Shutdown() { s.manager.Stop() } + +// === Settings Methods === + +// GetSettings returns the current app settings +func (s *MiningService) GetSettings() (*mining.AppSettings, error) { + if s.settingsMgr == nil { + return mining.DefaultSettings(), nil + } + return s.settingsMgr.Get(), nil +} + +// SaveSettings saves the app settings +func (s *MiningService) SaveSettings(settings *mining.AppSettings) error { + if s.settingsMgr == nil { + return fmt.Errorf("settings manager not initialized") + } + return s.settingsMgr.Update(func(s *mining.AppSettings) { + *s = *settings + }) +} + +// SaveWindowState saves the window position and size +func (s *MiningService) SaveWindowState(x, y, width, height int, maximized bool) error { + if s.settingsMgr == nil { + return nil + } + return s.settingsMgr.UpdateWindowState(x, y, width, height, maximized) +} + +// WindowState represents window position and size for the frontend +type WindowState struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Maximized bool `json:"maximized"` +} + +// GetWindowState returns the saved window state +func (s *MiningService) GetWindowState() *WindowState { + if s.settingsMgr == nil { + return &WindowState{Width: 1400, Height: 900} + } + state := s.settingsMgr.GetWindowState() + return &WindowState{ + X: state.X, + Y: state.Y, + Width: state.Width, + Height: state.Height, + Maximized: state.Maximized, + } +} + +// SetStartOnBoot enables/disables start on system boot +func (s *MiningService) SetStartOnBoot(enabled bool) error { + if s.settingsMgr == nil { + return nil + } + return s.settingsMgr.SetStartOnBoot(enabled) +} + +// SetAutostartMiners enables/disables automatic miner start +func (s *MiningService) SetAutostartMiners(enabled bool) error { + if s.settingsMgr == nil { + return nil + } + return s.settingsMgr.SetAutostartMiners(enabled) +} + +// SetCPUThrottle configures CPU throttling settings +func (s *MiningService) SetCPUThrottle(enabled bool, maxPercent int) error { + if s.settingsMgr == nil { + return nil + } + return s.settingsMgr.SetCPUThrottle(enabled, maxPercent) +} + +// SetMinerDefaults updates default miner configuration +func (s *MiningService) SetMinerDefaults(defaults mining.MinerDefaults) error { + if s.settingsMgr == nil { + return nil + } + return s.settingsMgr.SetMinerDefaults(defaults) +} diff --git a/pkg/mining/settings_manager.go b/pkg/mining/settings_manager.go new file mode 100644 index 0000000..61f1fc1 --- /dev/null +++ b/pkg/mining/settings_manager.go @@ -0,0 +1,225 @@ +package mining + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/adrg/xdg" +) + +const settingsFileName = "settings.json" + +// WindowState stores the last window position and size +type WindowState struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Maximized bool `json:"maximized"` +} + +// MinerDefaults stores default configuration for miners +type MinerDefaults struct { + DefaultPool string `json:"defaultPool,omitempty"` + DefaultWallet string `json:"defaultWallet,omitempty"` + DefaultAlgorithm string `json:"defaultAlgorithm,omitempty"` + CPUMaxThreadsHint int `json:"cpuMaxThreadsHint,omitempty"` // Default CPU throttle percentage + CPUThrottleThreshold int `json:"cpuThrottleThreshold,omitempty"` // Throttle when CPU exceeds this % +} + +// AppSettings stores application-wide settings +type AppSettings struct { + // Window settings + Window WindowState `json:"window"` + + // Behavior settings + StartOnBoot bool `json:"startOnBoot"` + MinimizeToTray bool `json:"minimizeToTray"` + StartMinimized bool `json:"startMinimized"` + AutostartMiners bool `json:"autostartMiners"` + ShowNotifications bool `json:"showNotifications"` + + // Mining settings + MinerDefaults MinerDefaults `json:"minerDefaults"` + PauseOnBattery bool `json:"pauseOnBattery"` + PauseOnUserActive bool `json:"pauseOnUserActive"` + PauseOnUserActiveDelay int `json:"pauseOnUserActiveDelay"` // Seconds of inactivity before resuming + + // Performance settings + EnableCPUThrottle bool `json:"enableCpuThrottle"` + CPUThrottlePercent int `json:"cpuThrottlePercent"` // Target max CPU % when throttling + CPUMonitorInterval int `json:"cpuMonitorInterval"` // Seconds between CPU checks + AutoThrottleOnHighTemp bool `json:"autoThrottleOnHighTemp"` // Throttle when CPU temp is high + + // Theme + Theme string `json:"theme"` // "light", "dark", "system" +} + +// DefaultSettings returns sensible defaults for app settings +func DefaultSettings() *AppSettings { + return &AppSettings{ + Window: WindowState{ + Width: 1400, + Height: 900, + }, + StartOnBoot: false, + MinimizeToTray: true, + StartMinimized: false, + AutostartMiners: false, + ShowNotifications: true, + MinerDefaults: MinerDefaults{ + CPUMaxThreadsHint: 50, // Default to 50% CPU + CPUThrottleThreshold: 80, // Throttle if CPU > 80% + }, + PauseOnBattery: true, + PauseOnUserActive: false, + PauseOnUserActiveDelay: 60, + EnableCPUThrottle: false, + CPUThrottlePercent: 70, + CPUMonitorInterval: 5, + AutoThrottleOnHighTemp: false, + Theme: "system", + } +} + +// SettingsManager handles loading and saving app settings +type SettingsManager struct { + mu sync.RWMutex + settings *AppSettings + settingsPath string +} + +// NewSettingsManager creates a new settings manager +func NewSettingsManager() (*SettingsManager, error) { + settingsPath, err := xdg.ConfigFile(filepath.Join("lethean-desktop", settingsFileName)) + if err != nil { + return nil, fmt.Errorf("could not resolve settings path: %w", err) + } + + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + if err := sm.Load(); err != nil { + // If file doesn't exist, use defaults and save them + if os.IsNotExist(err) { + if saveErr := sm.Save(); saveErr != nil { + return nil, fmt.Errorf("could not save default settings: %w", saveErr) + } + } else { + return nil, fmt.Errorf("could not load settings: %w", err) + } + } + + return sm, nil +} + +// Load reads settings from disk +func (sm *SettingsManager) Load() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + data, err := os.ReadFile(sm.settingsPath) + if err != nil { + return err + } + + var settings AppSettings + if err := json.Unmarshal(data, &settings); err != nil { + return err + } + + sm.settings = &settings + return nil +} + +// Save writes settings to disk +func (sm *SettingsManager) Save() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + data, err := json.MarshalIndent(sm.settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(sm.settingsPath, data, 0644) +} + +// Get returns a copy of the current settings +func (sm *SettingsManager) Get() *AppSettings { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Return a copy to prevent concurrent modification + copy := *sm.settings + return © +} + +// Update applies changes to settings and saves +func (sm *SettingsManager) Update(fn func(*AppSettings)) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + fn(sm.settings) + + data, err := json.MarshalIndent(sm.settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(sm.settingsPath, data, 0644) +} + +// UpdateWindowState saves the current window state +func (sm *SettingsManager) UpdateWindowState(x, y, width, height int, maximized bool) error { + return sm.Update(func(s *AppSettings) { + s.Window.X = x + s.Window.Y = y + s.Window.Width = width + s.Window.Height = height + s.Window.Maximized = maximized + }) +} + +// GetWindowState returns the saved window state +func (sm *SettingsManager) GetWindowState() WindowState { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.settings.Window +} + +// SetStartOnBoot enables/disables start on boot +func (sm *SettingsManager) SetStartOnBoot(enabled bool) error { + return sm.Update(func(s *AppSettings) { + s.StartOnBoot = enabled + }) +} + +// SetAutostartMiners enables/disables miner autostart +func (sm *SettingsManager) SetAutostartMiners(enabled bool) error { + return sm.Update(func(s *AppSettings) { + s.AutostartMiners = enabled + }) +} + +// SetCPUThrottle configures CPU throttling +func (sm *SettingsManager) SetCPUThrottle(enabled bool, percent int) error { + return sm.Update(func(s *AppSettings) { + s.EnableCPUThrottle = enabled + if percent > 0 && percent <= 100 { + s.CPUThrottlePercent = percent + } + }) +} + +// SetMinerDefaults updates default miner configuration +func (sm *SettingsManager) SetMinerDefaults(defaults MinerDefaults) error { + return sm.Update(func(s *AppSettings) { + s.MinerDefaults = defaults + }) +} diff --git a/pkg/mining/settings_manager_test.go b/pkg/mining/settings_manager_test.go new file mode 100644 index 0000000..69e9d2c --- /dev/null +++ b/pkg/mining/settings_manager_test.go @@ -0,0 +1,211 @@ +package mining + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSettingsManager_DefaultSettings(t *testing.T) { + defaults := DefaultSettings() + + if defaults.Window.Width != 1400 { + t.Errorf("Expected default width 1400, got %d", defaults.Window.Width) + } + if defaults.Window.Height != 900 { + t.Errorf("Expected default height 900, got %d", defaults.Window.Height) + } + if defaults.MinerDefaults.CPUMaxThreadsHint != 50 { + t.Errorf("Expected default CPU hint 50, got %d", defaults.MinerDefaults.CPUMaxThreadsHint) + } + if defaults.MinerDefaults.CPUThrottleThreshold != 80 { + t.Errorf("Expected default throttle threshold 80, got %d", defaults.MinerDefaults.CPUThrottleThreshold) + } + if !defaults.PauseOnBattery { + t.Error("Expected PauseOnBattery to be true by default") + } +} + +func TestSettingsManager_SaveAndLoad(t *testing.T) { + // Use a temp directory for testing + tmpDir := t.TempDir() + settingsPath := filepath.Join(tmpDir, "settings.json") + + // Create settings manager with custom path + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + // Modify settings + sm.settings.Window.Width = 1920 + sm.settings.Window.Height = 1080 + sm.settings.StartOnBoot = true + sm.settings.AutostartMiners = true + sm.settings.CPUThrottlePercent = 50 + + // Save + err := sm.Save() + if err != nil { + t.Fatalf("Failed to save settings: %v", err) + } + + // Verify file exists + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Fatal("Settings file was not created") + } + + // Create new manager and load + sm2 := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + err = sm2.Load() + if err != nil { + t.Fatalf("Failed to load settings: %v", err) + } + + // Verify loaded values + if sm2.settings.Window.Width != 1920 { + t.Errorf("Expected width 1920, got %d", sm2.settings.Window.Width) + } + if sm2.settings.Window.Height != 1080 { + t.Errorf("Expected height 1080, got %d", sm2.settings.Window.Height) + } + if !sm2.settings.StartOnBoot { + t.Error("Expected StartOnBoot to be true") + } + if !sm2.settings.AutostartMiners { + t.Error("Expected AutostartMiners to be true") + } + if sm2.settings.CPUThrottlePercent != 50 { + t.Errorf("Expected CPUThrottlePercent 50, got %d", sm2.settings.CPUThrottlePercent) + } +} + +func TestSettingsManager_UpdateWindowState(t *testing.T) { + tmpDir := t.TempDir() + settingsPath := filepath.Join(tmpDir, "settings.json") + + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + err := sm.UpdateWindowState(100, 200, 800, 600, false) + if err != nil { + t.Fatalf("Failed to update window state: %v", err) + } + + state := sm.GetWindowState() + if state.X != 100 { + t.Errorf("Expected X 100, got %d", state.X) + } + if state.Y != 200 { + t.Errorf("Expected Y 200, got %d", state.Y) + } + if state.Width != 800 { + t.Errorf("Expected Width 800, got %d", state.Width) + } + if state.Height != 600 { + t.Errorf("Expected Height 600, got %d", state.Height) + } +} + +func TestSettingsManager_SetCPUThrottle(t *testing.T) { + tmpDir := t.TempDir() + settingsPath := filepath.Join(tmpDir, "settings.json") + + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + // Test enabling throttle + err := sm.SetCPUThrottle(true, 30) + if err != nil { + t.Fatalf("Failed to set CPU throttle: %v", err) + } + + settings := sm.Get() + if !settings.EnableCPUThrottle { + t.Error("Expected EnableCPUThrottle to be true") + } + if settings.CPUThrottlePercent != 30 { + t.Errorf("Expected CPUThrottlePercent 30, got %d", settings.CPUThrottlePercent) + } + + // Test invalid percentage (should be ignored) + err = sm.SetCPUThrottle(true, 150) + if err != nil { + t.Fatalf("Failed to set CPU throttle: %v", err) + } + settings = sm.Get() + if settings.CPUThrottlePercent != 30 { // Should remain unchanged + t.Errorf("Expected CPUThrottlePercent to remain 30, got %d", settings.CPUThrottlePercent) + } +} + +func TestSettingsManager_SetMinerDefaults(t *testing.T) { + tmpDir := t.TempDir() + settingsPath := filepath.Join(tmpDir, "settings.json") + + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + defaults := MinerDefaults{ + DefaultPool: "stratum+tcp://pool.example.com:3333", + DefaultWallet: "wallet123", + DefaultAlgorithm: "rx/0", + CPUMaxThreadsHint: 25, + CPUThrottleThreshold: 90, + } + + err := sm.SetMinerDefaults(defaults) + if err != nil { + t.Fatalf("Failed to set miner defaults: %v", err) + } + + settings := sm.Get() + if settings.MinerDefaults.DefaultPool != "stratum+tcp://pool.example.com:3333" { + t.Errorf("Expected pool to be set, got %s", settings.MinerDefaults.DefaultPool) + } + if settings.MinerDefaults.CPUMaxThreadsHint != 25 { + t.Errorf("Expected CPUMaxThreadsHint 25, got %d", settings.MinerDefaults.CPUMaxThreadsHint) + } +} + +func TestSettingsManager_ConcurrentAccess(t *testing.T) { + tmpDir := t.TempDir() + settingsPath := filepath.Join(tmpDir, "settings.json") + + sm := &SettingsManager{ + settings: DefaultSettings(), + settingsPath: settingsPath, + } + + // Concurrent reads and writes + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + for j := 0; j < 100; j++ { + _ = sm.Get() + sm.UpdateWindowState(n*10, n*10, 800+n, 600+n, false) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Should complete without race conditions + state := sm.GetWindowState() + if state.Width < 800 || state.Width > 900 { + t.Errorf("Unexpected width after concurrent access: %d", state.Width) + } +} diff --git a/pkg/mining/throttle_test.go b/pkg/mining/throttle_test.go new file mode 100644 index 0000000..cc475cb --- /dev/null +++ b/pkg/mining/throttle_test.go @@ -0,0 +1,311 @@ +package mining + +import ( + "runtime" + "testing" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/process" +) + +// TestCPUThrottleSingleMiner tests that a single miner respects CPU throttle settings +func TestCPUThrottleSingleMiner(t *testing.T) { + if testing.Short() { + t.Skip("Skipping CPU throttle test in short mode") + } + + miner := NewXMRigMiner() + details, err := miner.CheckInstallation() + if err != nil || !details.IsInstalled { + t.Skip("XMRig not installed, skipping throttle test") + } + + // Use the manager to start miner (handles API port assignment) + manager := NewManager() + defer manager.Stop() + + // Configure miner to use only 10% of CPU + config := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:3333", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + CPUMaxThreadsHint: 10, // 10% CPU usage + Algo: "rx/0", + } + + minerInstance, err := manager.StartMiner("xmrig", config) + if err != nil { + t.Fatalf("Failed to start miner: %v", err) + } + t.Logf("Started miner: %s", minerInstance.GetName()) + + // Let miner warm up + time.Sleep(15 * time.Second) + + // Measure CPU usage + avgCPU := measureCPUUsage(t, 10*time.Second) + + t.Logf("Configured: 10%% CPU, Measured: %.1f%% CPU", avgCPU) + + // Allow 15% margin (10% target + 5% tolerance) + if avgCPU > 25 { + t.Errorf("CPU usage %.1f%% exceeds expected ~10%% (with tolerance)", avgCPU) + } + + manager.StopMiner(minerInstance.GetName()) +} + +// TestCPUThrottleDualMiners tests that two miners together respect combined CPU limits +func TestCPUThrottleDualMiners(t *testing.T) { + if testing.Short() { + t.Skip("Skipping CPU throttle test in short mode") + } + + miner1 := NewXMRigMiner() + details, err := miner1.CheckInstallation() + if err != nil || !details.IsInstalled { + t.Skip("XMRig not installed, skipping throttle test") + } + + manager := NewManager() + defer manager.Stop() + + // Start first miner at 10% CPU with RandomX + config1 := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:3333", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + CPUMaxThreadsHint: 10, + Algo: "rx/0", + } + + miner1Instance, err := manager.StartMiner("xmrig", config1) + if err != nil { + t.Fatalf("Failed to start first miner: %v", err) + } + t.Logf("Started miner 1: %s", miner1Instance.GetName()) + + // Start second miner at 10% CPU with different algo + config2 := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:5555", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + CPUMaxThreadsHint: 10, + Algo: "gr", // GhostRider algo + } + + miner2Instance, err := manager.StartMiner("xmrig", config2) + if err != nil { + t.Fatalf("Failed to start second miner: %v", err) + } + t.Logf("Started miner 2: %s", miner2Instance.GetName()) + + // Let miners warm up + time.Sleep(20 * time.Second) + + // Verify both miners are running + miners := manager.ListMiners() + if len(miners) != 2 { + t.Fatalf("Expected 2 miners running, got %d", len(miners)) + } + + // Measure combined CPU usage + avgCPU := measureCPUUsage(t, 15*time.Second) + + t.Logf("Configured: 2x10%% CPU, Measured: %.1f%% CPU", avgCPU) + + // Combined should be ~20% with tolerance + if avgCPU > 40 { + t.Errorf("Combined CPU usage %.1f%% exceeds expected ~20%% (with tolerance)", avgCPU) + } + + // Clean up + manager.StopMiner(miner1Instance.GetName()) + manager.StopMiner(miner2Instance.GetName()) +} + +// TestCPUThrottleThreadCount tests thread-based CPU limiting +func TestCPUThrottleThreadCount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping CPU throttle test in short mode") + } + + miner := NewXMRigMiner() + details, err := miner.CheckInstallation() + if err != nil || !details.IsInstalled { + t.Skip("XMRig not installed, skipping throttle test") + } + + // Use the manager to start miner (handles API port assignment) + manager := NewManager() + defer manager.Stop() + + numCPU := runtime.NumCPU() + targetThreads := 1 // Use only 1 thread + expectedMaxCPU := float64(100) / float64(numCPU) * float64(targetThreads) * 1.5 // 50% tolerance + + config := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:3333", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + Threads: targetThreads, + Algo: "rx/0", + } + + minerInstance, err := manager.StartMiner("xmrig", config) + if err != nil { + t.Fatalf("Failed to start miner: %v", err) + } + t.Logf("Started miner: %s", minerInstance.GetName()) + defer manager.StopMiner(minerInstance.GetName()) + + // Let miner warm up + time.Sleep(15 * time.Second) + + avgCPU := measureCPUUsage(t, 10*time.Second) + + t.Logf("CPUs: %d, Threads: %d, Expected max: %.1f%%, Measured: %.1f%%", + numCPU, targetThreads, expectedMaxCPU, avgCPU) + + if avgCPU > expectedMaxCPU { + t.Errorf("CPU usage %.1f%% exceeds expected max %.1f%% for %d thread(s)", + avgCPU, expectedMaxCPU, targetThreads) + } +} + +// TestMinerResourceIsolation tests that miners don't interfere with each other +func TestMinerResourceIsolation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping resource isolation test in short mode") + } + + miner := NewXMRigMiner() + details, err := miner.CheckInstallation() + if err != nil || !details.IsInstalled { + t.Skip("XMRig not installed, skipping test") + } + + manager := NewManager() + defer manager.Stop() + + // Start first miner + config1 := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:3333", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + CPUMaxThreadsHint: 25, + Algo: "rx/0", + } + + miner1, err := manager.StartMiner("xmrig", config1) + if err != nil { + t.Fatalf("Failed to start miner 1: %v", err) + } + + time.Sleep(10 * time.Second) + + // Get baseline hashrate for miner 1 alone + stats1Alone, err := miner1.GetStats() + if err != nil { + t.Logf("Warning: couldn't get stats for miner 1: %v", err) + } + baselineHashrate := 0 + if stats1Alone != nil { + baselineHashrate = stats1Alone.Hashrate + } + + // Start second miner + config2 := &Config{ + Pool: "stratum+tcp://pool.supportxmr.com:5555", + Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A", + CPUMaxThreadsHint: 25, + Algo: "gr", + } + + miner2, err := manager.StartMiner("xmrig", config2) + if err != nil { + t.Fatalf("Failed to start miner 2: %v", err) + } + + time.Sleep(15 * time.Second) + + // Check both miners are running and producing hashrate + stats1, err := miner1.GetStats() + if err != nil { + t.Logf("Warning: couldn't get stats for miner 1: %v", err) + } + stats2, err := miner2.GetStats() + if err != nil { + t.Logf("Warning: couldn't get stats for miner 2: %v", err) + } + + t.Logf("Miner 1 baseline: %d H/s, with miner 2: %d H/s", baselineHashrate, getHashrate(stats1)) + t.Logf("Miner 2 hashrate: %d H/s", getHashrate(stats2)) + + // Both miners should be producing some hashrate + if stats1 != nil && stats1.Hashrate == 0 { + t.Error("Miner 1 has zero hashrate") + } + if stats2 != nil && stats2.Hashrate == 0 { + t.Error("Miner 2 has zero hashrate") + } + + // Clean up + manager.StopMiner(miner1.GetName()) + manager.StopMiner(miner2.GetName()) +} + +// measureCPUUsage measures average CPU usage over a duration +func measureCPUUsage(t *testing.T, duration time.Duration) float64 { + t.Helper() + + samples := int(duration.Seconds()) + if samples < 1 { + samples = 1 + } + + var totalCPU float64 + for i := 0; i < samples; i++ { + percentages, err := cpu.Percent(time.Second, false) + if err != nil { + t.Logf("Warning: failed to get CPU percentage: %v", err) + continue + } + if len(percentages) > 0 { + totalCPU += percentages[0] + } + } + + return totalCPU / float64(samples) +} + +// measureProcessCPU measures CPU usage of a specific process +func measureProcessCPU(t *testing.T, pid int32, duration time.Duration) float64 { + t.Helper() + + proc, err := process.NewProcess(pid) + if err != nil { + t.Logf("Warning: failed to get process: %v", err) + return 0 + } + + samples := int(duration.Seconds()) + if samples < 1 { + samples = 1 + } + + var totalCPU float64 + for i := 0; i < samples; i++ { + pct, err := proc.CPUPercent() + if err != nil { + continue + } + totalCPU += pct + time.Sleep(time.Second) + } + + return totalCPU / float64(samples) +} + +func getHashrate(stats *PerformanceMetrics) int { + if stats == nil { + return 0 + } + return stats.Hashrate +} diff --git a/pkg/mining/xmrig_start.go b/pkg/mining/xmrig_start.go index f9fbdfc..a29818b 100644 --- a/pkg/mining/xmrig_start.go +++ b/pkg/mining/xmrig_start.go @@ -150,6 +150,22 @@ func (m *XMRigMiner) createConfig(config *Config) error { apiListen = fmt.Sprintf("%s:%d", m.API.ListenHost, m.API.ListenPort) } + cpuConfig := map[string]interface{}{ + "enabled": true, + "huge-pages": config.HugePages, + } + + // Set thread count or max-threads-hint for CPU throttling + if config.Threads > 0 { + cpuConfig["threads"] = config.Threads + } + if config.CPUMaxThreadsHint > 0 { + cpuConfig["max-threads-hint"] = config.CPUMaxThreadsHint + } + if config.CPUPriority > 0 { + cpuConfig["priority"] = config.CPUPriority + } + c := map[string]interface{}{ "api": map[string]interface{}{ "enabled": m.API != nil && m.API.Enabled, @@ -165,11 +181,9 @@ func (m *XMRigMiner) createConfig(config *Config) error { "tls": config.TLS, }, }, - "cpu": map[string]interface{}{ - "enabled": true, - "threads": config.Threads, - "huge-pages": config.HugePages, - }, + "cpu": cpuConfig, + "pause-on-active": config.PauseOnActive, + "pause-on-battery": config.PauseOnBattery, } data, err := json.MarshalIndent(c, "", " ")