Mining/pkg/mining/miner.go
Virgil 68c826a3d8
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Align mining AX naming and comments
2026-04-04 05:33:08 +00:00

639 lines
18 KiB
Go

package mining
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"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,
}
}
// if len(line) > maxLineLength { line = line[:maxLineLength] + "... [truncated]" }
const maxLineLength = 2000
// cmd.Stdout = lb // satisfies io.Writer; timestamps and ring-buffers each line
func (logBuffer *LogBuffer) Write(p []byte) (n int, err error) {
logBuffer.mutex.Lock()
defer logBuffer.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
logBuffer.lines = append(logBuffer.lines, timestampedLine)
// Trim if over max - force reallocation to release memory
if len(logBuffer.lines) > logBuffer.maxLines {
newSlice := make([]string, logBuffer.maxLines)
copy(newSlice, logBuffer.lines[len(logBuffer.lines)-logBuffer.maxLines:])
logBuffer.lines = newSlice
}
}
return len(p), nil
}
// lines := logBuffer.GetLines()
// response.Logs = lines[max(0, len(lines)-100):]
func (logBuffer *LogBuffer) GetLines() []string {
logBuffer.mutex.RLock()
defer logBuffer.mutex.RUnlock()
result := make([]string, len(logBuffer.lines))
copy(result, logBuffer.lines)
return result
}
// logBuffer.Clear() // called on miner Stop() to release memory
func (logBuffer *LogBuffer) Clear() {
logBuffer.mutex.Lock()
defer logBuffer.mutex.Unlock()
logBuffer.lines = logBuffer.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 such as `xmrig` or `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() // returns values such as `xmrig` or `tt-miner`.
func (b *BaseMiner) GetType() string {
return b.MinerType
}
// name := miner.GetName() // returns values such as `xmrig-randomx` or `tt-miner-kawpow`.
func (b *BaseMiner) GetName() string {
b.mutex.RLock()
defer b.mutex.RUnlock()
return b.Name
}
// path := miner.GetPath() // returns paths such as `/home/alice/.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() // returns paths such as `/home/alice/.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
}
// case <-time.After(stdinWriteTimeout): return ErrTimeout("stdin write")
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()
response, err := getHTTPClient().Get(url)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, response.Body) // Drain body to allow connection reuse (error ignored intentionally)
return ErrInstallFailed(b.ExecutableName).WithDetails("unexpected status code " + strconv.Itoa(response.StatusCode))
}
if _, err := io.Copy(tmpfile, response.Body); err != nil {
// Drain remaining body to allow connection reuse (error ignored intentionally)
_, _ = io.Copy(io.Discard, response.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 ErrInstallFailed(b.ExecutableName).WithCause(err).WithDetails("failed to extract archive")
}
return nil
}
// current := parseVersion("6.24.0") // [6, 24, 0]
// previous := parseVersion("5.0.1") // [5, 0, 1]
// if compareVersions(current, previous) > 0 { /* current is newer */ }
func parseVersion(versionString string) []int {
parts := strings.Split(versionString, ".")
intParts := make([]int, len(parts))
for i, part := range parts {
val, err := strconv.Atoi(part)
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 _, entry := range dirs {
if entry.IsDir() && strings.HasPrefix(entry.Name(), b.ExecutableName+"-") {
// Extract the version suffix from a directory name such as `xmrig-6.24.0`.
versionStr := strings.TrimPrefix(entry.Name(), b.ExecutableName+"-")
currentVersion := parseVersion(versionStr)
if highestVersionDir == "" || compareVersions(currentVersion, highestVersion) > 0 {
highestVersion = currentVersion
highestVersionDir = entry.Name()
}
versionedPath := filepath.Join(baseInstallPath, entry.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 "", ErrInternal("failed to get absolute path for '" + path + "'").WithCause(err)
}
logging.Debug("found miner binary in system PATH", logging.Fields{"path": absPath})
return absPath, nil
}
// If not found, return a detailed error
return "", ErrMinerNotFound(executableName).WithDetails("searched in: " + strings.Join(searchedPaths, ", ") + " and system PATH")
}
// details, err := miner.CheckInstallation()
// if !details.IsInstalled { logging.Warn("xmrig not found", logging.Fields{"error": 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 output bytes.Buffer
cmd.Stdout = &output
if err := cmd.Run(); err != nil {
b.Version = "Unknown (could not run executable)"
} else {
fields := strings.Fields(output.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)
}
// count := 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)
}
// count := 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 _, point := range b.HashrateHistory {
if point.Timestamp.Before(cutoff) {
pointsToAggregate = append(pointsToAggregate, point)
} else {
newHighResHistory = append(newHighResHistory, point)
}
}
// 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 _, point := range pointsToAggregate {
minute := point.Timestamp.Truncate(LowResolutionInterval)
minuteGroups[minute] = append(minuteGroups[minute], point.Hashrate)
}
var newLowResPoints []HashratePoint
for minute, hashrates := range minuteGroups {
if len(hashrates) > 0 {
totalHashrate := 0
for _, rate := range hashrates {
totalHashrate += rate
}
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, point := range b.LowResHashrateHistory {
if point.Timestamp.After(lowResCutoff) || point.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 {
zipReader, err := zip.OpenReader(src)
if err != nil {
return err
}
defer zipReader.Close()
for _, zipEntry := range zipReader.File {
entryPath := filepath.Join(dest, zipEntry.Name)
if !strings.HasPrefix(entryPath, filepath.Clean(dest)+string(os.PathSeparator)) {
return ErrInternal("illegal file path in archive").WithDetails(entryPath)
}
if zipEntry.FileInfo().IsDir() {
if err := os.MkdirAll(entryPath, os.ModePerm); err != nil {
return ErrInternal("failed to create directory").WithCause(err).WithDetails(entryPath)
}
continue
}
if err = os.MkdirAll(filepath.Dir(entryPath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(entryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipEntry.Mode())
if err != nil {
return err
}
entryReader, err := zipEntry.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, entryReader)
outFile.Close()
entryReader.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()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
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 ErrInternal("illegal file path in archive").WithDetails(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
}
outputFile, 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(outputFile, tarReader); err != nil {
outputFile.Close()
return err
}
outputFile.Close()
}
}
}