Mining/pkg/mining/miner.go
Claude 39f0a57847
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
ax(mining): replace fmt.Sprintf with string concatenation in miner.go
Two uses of the banned fmt import used fmt.Sprintf for trivial string
concatenation (timestamp prefix in LogBuffer.Write, XDG path in GetPath).
Replaced with direct string concatenation per AX banned-import rules.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 17:29:57 +01:00

640 lines
17 KiB
Go

package mining
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"forge.lthn.ai/Snider/Mining/pkg/logging"
"github.com/adrg/xdg"
)
// buffer := NewLogBuffer(500)
// cmd.Stdout = buffer // satisfies io.Writer; ring-buffers miner output
type LogBuffer struct {
lines []string
maxLines int
mutex sync.RWMutex
}
// buffer := NewLogBuffer(500)
// cmd.Stdout = buffer
func NewLogBuffer(maxLines int) *LogBuffer {
return &LogBuffer{
lines: make([]string, 0, maxLines),
maxLines: maxLines,
}
}
// maxLineLength is the maximum length of a single log line to prevent memory bloat.
const maxLineLength = 2000
// cmd.Stdout = lb // satisfies io.Writer; timestamps and ring-buffers each line
func (lb *LogBuffer) Write(p []byte) (n int, err error) {
lb.mutex.Lock()
defer lb.mutex.Unlock()
// Split input into lines
text := string(p)
newLines := strings.Split(text, "\n")
for _, line := range newLines {
if line == "" {
continue
}
// Truncate excessively long lines to prevent memory bloat
if len(line) > maxLineLength {
line = line[:maxLineLength] + "... [truncated]"
}
// Add timestamp prefix
timestampedLine := "[" + time.Now().Format("15:04:05") + "] " + line
lb.lines = append(lb.lines, timestampedLine)
// Trim if over max - force reallocation to release memory
if len(lb.lines) > lb.maxLines {
newSlice := make([]string, lb.maxLines)
copy(newSlice, lb.lines[len(lb.lines)-lb.maxLines:])
lb.lines = newSlice
}
}
return len(p), nil
}
// lines := lb.GetLines()
// response.Logs = lines[max(0, len(lines)-100):]
func (lb *LogBuffer) GetLines() []string {
lb.mutex.RLock()
defer lb.mutex.RUnlock()
result := make([]string, len(lb.lines))
copy(result, lb.lines)
return result
}
// lb.Clear() // called on miner Stop() to release memory
func (lb *LogBuffer) Clear() {
lb.mutex.Lock()
defer lb.mutex.Unlock()
lb.lines = lb.lines[:0]
}
// type XMRigMiner struct { BaseMiner }
// func NewXMRigMiner() *XMRigMiner { return &XMRigMiner{BaseMiner: BaseMiner{MinerType: "xmrig"}} }
type BaseMiner struct {
Name string `json:"name"`
MinerType string `json:"miner_type"` // Type identifier (e.g., "xmrig", "tt-miner")
Version string `json:"version"`
URL string `json:"url"`
Path string `json:"path"`
MinerBinary string `json:"miner_binary"`
ExecutableName string `json:"-"`
Running bool `json:"running"`
ConfigPath string `json:"configPath"`
API *API `json:"api"`
mutex sync.RWMutex
cmd *exec.Cmd
stdinPipe io.WriteCloser `json:"-"`
HashrateHistory []HashratePoint `json:"hashrateHistory"`
LowResHashrateHistory []HashratePoint `json:"lowResHashrateHistory"`
LastLowResAggregation time.Time `json:"-"`
LogBuffer *LogBuffer `json:"-"`
}
// minerType := miner.GetType() // "xmrig" or "tt-miner"
func (b *BaseMiner) GetType() string {
return b.MinerType
}
// name := miner.GetName() // e.g. "xmrig-randomx" or "tt-miner-kawpow"
func (b *BaseMiner) GetName() string {
b.mutex.RLock()
defer b.mutex.RUnlock()
return b.Name
}
// path := miner.GetPath() // e.g. "/home/user/.local/share/lethean-desktop/miners/xmrig"
func (b *BaseMiner) GetPath() string {
dataPath, err := xdg.DataFile("lethean-desktop/miners/" + b.ExecutableName)
if err != nil {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".lethean-desktop", "miners", b.ExecutableName)
}
return dataPath
}
// binary := miner.GetBinaryPath() // e.g. "/home/user/.local/share/lethean-desktop/miners/xmrig/xmrig"
func (b *BaseMiner) GetBinaryPath() string {
b.mutex.RLock()
defer b.mutex.RUnlock()
return b.MinerBinary
}
// if err := miner.Stop(); err != nil { log.Warn("stop failed", ...) }
func (b *BaseMiner) Stop() error {
b.mutex.Lock()
if !b.Running || b.cmd == nil {
b.mutex.Unlock()
return ErrMinerNotRunning(b.Name)
}
// Close stdin pipe if open
if b.stdinPipe != nil {
b.stdinPipe.Close()
b.stdinPipe = nil
}
// Capture cmd locally to avoid race with Wait() goroutine
cmd := b.cmd
process := cmd.Process
// Mark as not running immediately to prevent concurrent Stop() calls
b.Running = false
b.cmd = nil
b.mutex.Unlock()
// Try graceful shutdown with SIGTERM first (Unix only)
if runtime.GOOS != "windows" {
if err := process.Signal(syscall.SIGTERM); err == nil {
// Wait up to 3 seconds for graceful shutdown
done := make(chan struct{})
go func() {
process.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-time.After(3 * time.Second):
// Process didn't exit gracefully, force kill below
}
}
}
// Force kill and wait for process to exit
if err := process.Kill(); err != nil {
return err
}
// Wait for process to fully terminate to avoid zombies
process.Wait()
return nil
}
// stdinWriteTimeout is the maximum time to wait for stdin write to complete.
const stdinWriteTimeout = 5 * time.Second
// if err := miner.WriteStdin("h"); err != nil { /* miner not running */ }
func (b *BaseMiner) WriteStdin(input string) error {
b.mutex.RLock()
stdinPipe := b.stdinPipe
running := b.Running
b.mutex.RUnlock()
if !running || stdinPipe == nil {
return ErrMinerNotRunning(b.Name)
}
// Append newline if not present
if !strings.HasSuffix(input, "\n") {
input += "\n"
}
// Write with timeout to prevent blocking indefinitely.
// Use buffered channel size 1 so goroutine can exit even if we don't read the result.
done := make(chan error, 1)
go func() {
_, err := stdinPipe.Write([]byte(input))
// Non-blocking send - if timeout already fired, this won't block
select {
case done <- err:
default:
// Timeout already occurred, goroutine exits cleanly
}
}()
select {
case err := <-done:
return err
case <-time.After(stdinWriteTimeout):
return ErrTimeout("stdin write: miner may be unresponsive")
}
}
// if err := miner.Uninstall(); err != nil { return err }
func (b *BaseMiner) Uninstall() error {
return os.RemoveAll(b.GetPath())
}
// if err := b.InstallFromURL("https://github.com/xmrig/xmrig/releases/download/v6.22.1/xmrig-6.22.1-linux-static-x64.tar.gz"); err != nil { return err }
func (b *BaseMiner) InstallFromURL(url string) error {
tmpfile, err := os.CreateTemp("", b.ExecutableName+"-")
if err != nil {
return err
}
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
resp, err := getHTTPClient().Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body) // Drain body to allow connection reuse (error ignored intentionally)
return fmt.Errorf("failed to download release: unexpected status code %d", resp.StatusCode)
}
if _, err := io.Copy(tmpfile, resp.Body); err != nil {
// Drain remaining body to allow connection reuse (error ignored intentionally)
_, _ = io.Copy(io.Discard, resp.Body)
return err
}
baseInstallPath := b.GetPath()
if err := os.MkdirAll(baseInstallPath, 0755); err != nil {
return err
}
if strings.HasSuffix(url, ".zip") {
err = b.unzip(tmpfile.Name(), baseInstallPath)
} else {
err = b.untar(tmpfile.Name(), baseInstallPath)
}
if err != nil {
return fmt.Errorf("failed to extract miner: %w", err)
}
return nil
}
// a := parseVersion("6.24.0") // [6, 24, 0]
// b := parseVersion("5.0.1") // [5, 0, 1]
// if compareVersions(a, b) > 0 { /* a is newer */ }
func parseVersion(v string) []int {
parts := strings.Split(v, ".")
intParts := make([]int, len(parts))
for i, p := range parts {
val, err := strconv.Atoi(p)
if err != nil {
return []int{0} // Malformed version, treat as very old
}
intParts[i] = val
}
return intParts
}
// if compareVersions(parseVersion("6.24.0"), parseVersion("5.0.1")) > 0 { /* installed is newer, skip update */ }
func compareVersions(v1, v2 []int) int {
minimumLength := len(v1)
if len(v2) < minimumLength {
minimumLength = len(v2)
}
for i := 0; i < minimumLength; i++ {
if v1[i] > v2[i] {
return 1
}
if v1[i] < v2[i] {
return -1
}
}
if len(v1) > len(v2) {
return 1
}
if len(v1) < len(v2) {
return -1
}
return 0
}
// path, err := b.findMinerBinary()
// // path == "/home/user/.local/share/lethean-desktop/miners/xmrig/xmrig-6.24.0/xmrig"
func (b *BaseMiner) findMinerBinary() (string, error) {
executableName := b.ExecutableName
if runtime.GOOS == "windows" {
executableName += ".exe"
}
baseInstallPath := b.GetPath()
searchedPaths := []string{}
var highestVersion []int
var highestVersionDir string
// 1. Check the standard installation directory first
if _, err := os.Stat(baseInstallPath); err == nil {
dirs, err := os.ReadDir(baseInstallPath)
if err == nil {
for _, d := range dirs {
if d.IsDir() && strings.HasPrefix(d.Name(), b.ExecutableName+"-") {
// Extract version string, e.g., "xmrig-6.24.0" -> "6.24.0"
versionStr := strings.TrimPrefix(d.Name(), b.ExecutableName+"-")
currentVersion := parseVersion(versionStr)
if highestVersionDir == "" || compareVersions(currentVersion, highestVersion) > 0 {
highestVersion = currentVersion
highestVersionDir = d.Name()
}
versionedPath := filepath.Join(baseInstallPath, d.Name())
fullPath := filepath.Join(versionedPath, executableName)
searchedPaths = append(searchedPaths, fullPath)
}
}
}
if highestVersionDir != "" {
fullPath := filepath.Join(baseInstallPath, highestVersionDir, executableName)
if _, err := os.Stat(fullPath); err == nil {
logging.Debug("found miner binary at highest versioned path", logging.Fields{"path": fullPath})
return fullPath, nil
}
}
}
// 2. Fallback to searching the system PATH
path, err := exec.LookPath(executableName)
if err == nil {
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for '%s': %w", path, err)
}
logging.Debug("found miner binary in system PATH", logging.Fields{"path": absPath})
return absPath, nil
}
// If not found, return a detailed error
return "", fmt.Errorf("miner executable '%s' not found. Searched in: %s and system PATH", executableName, strings.Join(searchedPaths, ", "))
}
// details, err := miner.CheckInstallation()
// if !details.IsInstalled { log.Printf("xmrig not found: %v", err) }
func (b *BaseMiner) CheckInstallation() (*InstallationDetails, error) {
binaryPath, err := b.findMinerBinary()
if err != nil {
return &InstallationDetails{IsInstalled: false}, err
}
b.MinerBinary = binaryPath
b.Path = filepath.Dir(binaryPath)
cmd := exec.Command(binaryPath, "--version")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
b.Version = "Unknown (could not run executable)"
} else {
fields := strings.Fields(out.String())
if len(fields) >= 2 {
b.Version = fields[1]
} else {
b.Version = "Unknown (could not parse version)"
}
}
return &InstallationDetails{
IsInstalled: true,
MinerBinary: b.MinerBinary,
Path: b.Path,
Version: b.Version,
}, nil
}
// points := miner.GetHashrateHistory() // low-res (24h) + high-res (5min) points in chronological order
func (b *BaseMiner) GetHashrateHistory() []HashratePoint {
b.mutex.RLock()
defer b.mutex.RUnlock()
combinedHistory := make([]HashratePoint, 0, len(b.LowResHashrateHistory)+len(b.HashrateHistory))
combinedHistory = append(combinedHistory, b.LowResHashrateHistory...)
combinedHistory = append(combinedHistory, b.HashrateHistory...)
return combinedHistory
}
// miner.AddHashratePoint(HashratePoint{Timestamp: time.Now(), Hashrate: 1234.5})
func (b *BaseMiner) AddHashratePoint(point HashratePoint) {
b.mutex.Lock()
defer b.mutex.Unlock()
b.HashrateHistory = append(b.HashrateHistory, point)
}
// n := miner.GetHighResHistoryLength() // 0..30 points (last 5 min at 10s resolution)
func (b *BaseMiner) GetHighResHistoryLength() int {
b.mutex.RLock()
defer b.mutex.RUnlock()
return len(b.HashrateHistory)
}
// n := miner.GetLowResHistoryLength() // 0..1440 points (last 24h at 1min resolution)
func (b *BaseMiner) GetLowResHistoryLength() int {
b.mutex.RLock()
defer b.mutex.RUnlock()
return len(b.LowResHashrateHistory)
}
// lines := miner.GetLogs()
// response.Logs = lines[max(0, len(lines)-100):]
func (b *BaseMiner) GetLogs() []string {
b.mutex.RLock()
logBuffer := b.LogBuffer
b.mutex.RUnlock()
if logBuffer == nil {
return []string{}
}
return logBuffer.GetLines()
}
// miner.ReduceHashrateHistory(time.Now()) // aggregates high-res points older than 5 min into 1-min low-res buckets; trims low-res to 24h
func (b *BaseMiner) ReduceHashrateHistory(now time.Time) {
b.mutex.Lock()
defer b.mutex.Unlock()
if !b.LastLowResAggregation.IsZero() && now.Sub(b.LastLowResAggregation) < LowResolutionInterval {
return
}
var pointsToAggregate []HashratePoint
var newHighResHistory []HashratePoint
cutoff := now.Add(-HighResolutionDuration)
for _, p := range b.HashrateHistory {
if p.Timestamp.Before(cutoff) {
pointsToAggregate = append(pointsToAggregate, p)
} else {
newHighResHistory = append(newHighResHistory, p)
}
}
// Force reallocation if significantly oversized to free memory
if cap(b.HashrateHistory) > 1000 && len(newHighResHistory) < cap(b.HashrateHistory)/2 {
trimmed := make([]HashratePoint, len(newHighResHistory))
copy(trimmed, newHighResHistory)
b.HashrateHistory = trimmed
} else {
b.HashrateHistory = newHighResHistory
}
if len(pointsToAggregate) == 0 {
b.LastLowResAggregation = now
return
}
minuteGroups := make(map[time.Time][]int)
for _, p := range pointsToAggregate {
minute := p.Timestamp.Truncate(LowResolutionInterval)
minuteGroups[minute] = append(minuteGroups[minute], p.Hashrate)
}
var newLowResPoints []HashratePoint
for minute, hashrates := range minuteGroups {
if len(hashrates) > 0 {
totalHashrate := 0
for _, hr := range hashrates {
totalHashrate += hr
}
avgHashrate := totalHashrate / len(hashrates)
newLowResPoints = append(newLowResPoints, HashratePoint{Timestamp: minute, Hashrate: avgHashrate})
}
}
sort.Slice(newLowResPoints, func(i, j int) bool {
return newLowResPoints[i].Timestamp.Before(newLowResPoints[j].Timestamp)
})
b.LowResHashrateHistory = append(b.LowResHashrateHistory, newLowResPoints...)
lowResCutoff := now.Add(-LowResHistoryRetention)
firstValidLowResIndex := 0
for i, p := range b.LowResHashrateHistory {
if p.Timestamp.After(lowResCutoff) || p.Timestamp.Equal(lowResCutoff) {
firstValidLowResIndex = i
break
}
if i == len(b.LowResHashrateHistory)-1 {
firstValidLowResIndex = len(b.LowResHashrateHistory)
}
}
// Force reallocation if significantly oversized to free memory
newLowResLen := len(b.LowResHashrateHistory) - firstValidLowResIndex
if cap(b.LowResHashrateHistory) > 1000 && newLowResLen < cap(b.LowResHashrateHistory)/2 {
trimmed := make([]HashratePoint, newLowResLen)
copy(trimmed, b.LowResHashrateHistory[firstValidLowResIndex:])
b.LowResHashrateHistory = trimmed
} else {
b.LowResHashrateHistory = b.LowResHashrateHistory[firstValidLowResIndex:]
}
b.LastLowResAggregation = now
}
// b.unzip(tmpfile.Name(), "/home/user/.local/share/lethean-desktop/miners/xmrig")
func (b *BaseMiner) unzip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("%s: illegal file path", fpath)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory %s: %w", fpath, err)
}
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
// b.untar(tmpfile.Name(), "/home/user/.local/share/lethean-desktop/miners/xmrig")
func (b *BaseMiner) untar(src, dest string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tarReader := tar.NewReader(gzr)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target := filepath.Join(dest, header.Name)
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("%s: illegal file path in archive", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tarReader); err != nil {
f.Close()
return err
}
f.Close()
}
}
}