feat: Add CPU throttling, settings manager, and multi-miner tests
- Add CPUMaxThreadsHint, priority, pause-on-active/battery to XMRig config - Create SettingsManager for app preferences (window state, miner defaults) - Add settings API to desktop app service (GetSettings, SaveWindowState, etc) - Create throttle_test.go with multi-miner CPU usage verification tests - Create settings_manager_test.go with concurrent access tests - Desktop app now remembers window size between launches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a23dbbedc0
commit
ab47bae0a3
6 changed files with 871 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
225
pkg/mining/settings_manager.go
Normal file
225
pkg/mining/settings_manager.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
211
pkg/mining/settings_manager_test.go
Normal file
211
pkg/mining/settings_manager_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
311
pkg/mining/throttle_test.go
Normal file
311
pkg/mining/throttle_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "", " ")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue