Error Handling: - Fix silent Write() error in WebSocket (events.go) - Add error context to transport handshake messages - Check os.MkdirAll error in zip extraction (miner.go) - Explicitly ignore io.Copy errors on drain with comments - Add retry logic (2 attempts) for transient stats collection failures Resource Lifecycle: - Add shutdown mechanism to DigestAuth goroutine - Call Service.Stop() on context cancellation - Add NodeService transport cleanup to Service.Stop() - Fix WriteStdin goroutine leak on timeout with non-blocking send API Design: - Add profile validation (name, miner type required) - Return 404 instead of 500 for missing profile PUT - Make DELETE profile idempotent (return success if not found) - Standardize error responses in node_service.go handlers Observability: - Add logging for P2P GetAllStats failures - Add request ID correlation helper for handler logs - Add logging for miner process exits (xmrig_start.go) - Rate limit debug logs in transport hot path (1 in 100) - Add metrics infrastructure with /metrics endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
314 lines
8.9 KiB
Go
314 lines
8.9 KiB
Go
package mining
|
|
|
|
import (
|
|
"context"
|
|
"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 simulation manager to avoid autostart conflicts
|
|
manager := NewManagerForSimulation()
|
|
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: "throttle-single",
|
|
}
|
|
|
|
minerInstance, err := manager.StartMiner(context.Background(), "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(context.Background(), 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")
|
|
}
|
|
|
|
// Use simulation manager to avoid autostart conflicts
|
|
manager := NewManagerForSimulation()
|
|
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: "throttle-dual-1",
|
|
}
|
|
|
|
miner1Instance, err := manager.StartMiner(context.Background(), "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: "throttle-dual-2",
|
|
}
|
|
|
|
miner2Instance, err := manager.StartMiner(context.Background(), "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(context.Background(), miner1Instance.GetName())
|
|
manager.StopMiner(context.Background(), 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 simulation manager to avoid autostart conflicts
|
|
manager := NewManagerForSimulation()
|
|
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: "throttle-thread",
|
|
}
|
|
|
|
minerInstance, err := manager.StartMiner(context.Background(), "xmrig", config)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start miner: %v", err)
|
|
}
|
|
t.Logf("Started miner: %s", minerInstance.GetName())
|
|
defer manager.StopMiner(context.Background(), 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")
|
|
}
|
|
|
|
// Use simulation manager to avoid autostart conflicts
|
|
manager := NewManagerForSimulation()
|
|
defer manager.Stop()
|
|
|
|
// Start first miner
|
|
config1 := &Config{
|
|
Pool: "stratum+tcp://pool.supportxmr.com:3333",
|
|
Wallet: "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
|
CPUMaxThreadsHint: 25,
|
|
Algo: "isolation-1",
|
|
}
|
|
|
|
miner1, err := manager.StartMiner(context.Background(), "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(context.Background())
|
|
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: "isolation-2",
|
|
}
|
|
|
|
miner2, err := manager.StartMiner(context.Background(), "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(context.Background())
|
|
if err != nil {
|
|
t.Logf("Warning: couldn't get stats for miner 1: %v", err)
|
|
}
|
|
stats2, err := miner2.GetStats(context.Background())
|
|
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(context.Background(), miner1.GetName())
|
|
manager.StopMiner(context.Background(), 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
|
|
}
|