From fa3047a314d962421fe96bbbd08762bde0598092 Mon Sep 17 00:00:00 2001 From: snider Date: Wed, 31 Dec 2025 13:33:42 +0000 Subject: [PATCH] refactor: Add reliability fixes, centralized version fetching, and CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reliability fixes: - Fix race condition on uninitialized HTTP server in transport.go - Add proper error logging for HTTP server startup errors - Fix potential goroutine leak in process cleanup (xmrig_start.go) - Propagate context to DB writes for proper timeout handling Architecture improvements: - Centralize GitHub version fetching via FetchLatestGitHubVersion() - Add respondWithMiningError() helper for standardized API error responses - Update XMRig and TTMiner to use centralized version fetcher Documentation: - Add CHANGELOG.md with release history - Update FUTURE_IDEAS.md with demo GIF task 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 78 +++++++++++++++++++++++++++++++++++++++ docs/FUTURE_IDEAS.md | 1 + pkg/mining/manager.go | 8 ++-- pkg/mining/service.go | 31 ++++++++++++++-- pkg/mining/ttminer.go | 22 +---------- pkg/mining/version.go | 37 +++++++++++++++++++ pkg/mining/xmrig.go | 21 +---------- pkg/mining/xmrig_start.go | 10 ++++- pkg/node/transport.go | 14 ++++--- 9 files changed, 166 insertions(+), 56 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..322412c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Structured logging package with configurable log levels +- Rate limiter with automatic cleanup for API protection +- X-Request-ID middleware for request tracing +- Structured API error responses with error codes and suggestions +- MinerFactory pattern for centralized miner instantiation +- StatsCollector pattern for parallel stats collection +- Context propagation throughout the codebase +- WebSocket event system for real-time updates +- Simulation mode for UI development and testing +- Mermaid architecture diagrams in documentation + +### Changed +- Optimized `collectMinerStats()` for parallel execution +- Replaced `log.Printf` with structured logging throughout +- Improved hashrate history with two-tier storage (high-res and low-res) +- Enhanced shutdown handling with proper cleanup + +### Fixed +- Race conditions in concurrent database access +- Memory leaks in hashrate history retention +- Context cancellation propagation to database operations + +## [0.0.9] - 2025-12-11 + +### Added +- Enhanced dashboard layout with responsive stats bar +- Setup wizard for first-time configuration +- Admin panel for miner management +- Profile management with multiple miner support +- Live hashrate visualization with Highcharts +- Comprehensive docstrings throughout the mining package +- CI/CD matrix testing and conditional releases + +### Changed +- Refactored profile selection to support multiple miners +- Improved UI layout and accessibility +- Enhanced mining configuration management + +### Fixed +- UI build and server configuration issues + +## [0.0.8] - 2025-11-09 + +### Added +- Web dashboard (`mbe-mining-dashboard.js`) included in release binaries +- Interactive web interface for miner-cli + +## [0.0.7] - 2025-11-09 + +### Fixed +- Windows build compatibility + +## [0.0.6] - 2025-11-09 + +### Added +- Initial public release +- XMRig miner support +- TT-Miner (GPU) support +- RESTful API with Swagger documentation +- CLI with interactive shell +- Miner autostart configuration +- Hashrate history tracking + +[Unreleased]: https://github.com/Snider/Mining/compare/v0.0.9...HEAD +[0.0.9]: https://github.com/Snider/Mining/compare/v0.0.8...v0.0.9 +[0.0.8]: https://github.com/Snider/Mining/compare/v0.0.7...v0.0.8 +[0.0.7]: https://github.com/Snider/Mining/compare/v0.0.6...v0.0.7 +[0.0.6]: https://github.com/Snider/Mining/releases/tag/v0.0.6 diff --git a/docs/FUTURE_IDEAS.md b/docs/FUTURE_IDEAS.md index 053d1e7..3070328 100644 --- a/docs/FUTURE_IDEAS.md +++ b/docs/FUTURE_IDEAS.md @@ -133,6 +133,7 @@ deploy/ ### GitHub Repository Optimization - [ ] Add topic tags: `mining`, `monero`, `xmrig`, `cryptocurrency`, `dashboard`, `self-hosted`, `golang`, `angular` - [ ] Add social preview image +- [ ] Create demo GIF for README showcasing the dashboard UI - [ ] Create GitHub Discussions for community Q&A - [ ] Add "Used By" section in README diff --git a/pkg/mining/manager.go b/pkg/mining/manager.go index 0933e94..198471e 100644 --- a/pkg/mining/manager.go +++ b/pkg/mining/manager.go @@ -582,9 +582,9 @@ func (m *Manager) collectSingleMinerStats(miner Miner, minerType string, now tim // Use context with timeout to prevent hanging on unresponsive miner APIs ctx, cancel := context.WithTimeout(context.Background(), statsCollectionTimeout) - stats, err := miner.GetStats(ctx) - cancel() // Release context resources immediately + defer cancel() // Ensure context is released after all operations + stats, err := miner.GetStats(ctx) if err != nil { logging.Error("failed to get miner stats", logging.Fields{"miner": minerName, "error": err}) return @@ -606,8 +606,8 @@ func (m *Manager) collectSingleMinerStats(miner Miner, minerType string, now tim Timestamp: point.Timestamp, Hashrate: point.Hashrate, } - // Use nil context to let InsertHashratePoint use its default timeout - if err := database.InsertHashratePoint(nil, minerName, minerType, dbPoint, database.ResolutionHigh); err != nil { + // Use the same context for DB writes so they respect timeout/cancellation + if err := database.InsertHashratePoint(ctx, minerName, minerType, dbPoint, database.ResolutionHigh); err != nil { logging.Warn("failed to persist hashrate", logging.Fields{"miner": minerName, "error": err}) } } diff --git a/pkg/mining/service.go b/pkg/mining/service.go index 0e547eb..88867fd 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -82,6 +82,31 @@ func respondWithError(c *gin.Context, status int, code string, message string, d c.JSON(status, apiErr) } +// respondWithMiningError sends a structured error response from a MiningError. +// This allows using pre-built error constructors from errors.go. +func respondWithMiningError(c *gin.Context, err *MiningError) { + details := "" + if err.Cause != nil { + details = err.Cause.Error() + } + if err.Details != "" { + if details != "" { + details += "; " + } + details += err.Details + } + + apiErr := APIError{ + Code: err.Code, + Message: err.Message, + Details: details, + Suggestion: err.Suggestion, + Retryable: err.Retryable, + } + + c.JSON(err.StatusCode(), apiErr) +} + // isRetryableError determines if an error status code is retryable func isRetryableError(status int) bool { return status == http.StatusServiceUnavailable || @@ -606,7 +631,7 @@ func (s *Service) handleStartMinerWithProfile(c *gin.Context) { profileID := c.Param("id") profile, exists := s.ProfileManager.GetProfile(profileID) if !exists { - respondWithError(c, http.StatusNotFound, ErrCodeProfileNotFound, "profile not found", "") + respondWithMiningError(c, ErrProfileNotFound(profileID)) return } @@ -653,7 +678,7 @@ func (s *Service) handleGetMinerStats(c *gin.Context) { minerName := c.Param("miner_name") miner, err := s.Manager.GetMiner(minerName) if err != nil { - respondWithError(c, http.StatusNotFound, ErrCodeMinerNotFound, "miner not found", err.Error()) + respondWithMiningError(c, ErrMinerNotFound(minerName).WithCause(err)) return } stats, err := miner.GetStats(c.Request.Context()) @@ -694,7 +719,7 @@ func (s *Service) handleGetMinerLogs(c *gin.Context) { minerName := c.Param("miner_name") miner, err := s.Manager.GetMiner(minerName) if err != nil { - respondWithError(c, http.StatusNotFound, ErrCodeMinerNotFound, "miner not found", err.Error()) + respondWithMiningError(c, ErrMinerNotFound(minerName).WithCause(err)) return } logs := miner.GetLogs() diff --git a/pkg/mining/ttminer.go b/pkg/mining/ttminer.go index 01fbc51..647e854 100644 --- a/pkg/mining/ttminer.go +++ b/pkg/mining/ttminer.go @@ -2,11 +2,8 @@ package mining import ( "bytes" - "encoding/json" "errors" "fmt" - "io" - "net/http" "os" "os/exec" "path/filepath" @@ -90,24 +87,7 @@ func getTTMinerConfigPath() (string, error) { // GetLatestVersion fetches the latest version of TT-Miner from the GitHub API. func (m *TTMiner) GetLatestVersion() (string, error) { - resp, err := getHTTPClient().Get("https://api.github.com/repos/TrailingStop/TT-Miner-release/releases/latest") - 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 - return "", fmt.Errorf("failed to get latest release: unexpected status code %d", resp.StatusCode) - } - - var release struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", err - } - return release.TagName, nil + return FetchLatestGitHubVersion("TrailingStop", "TT-Miner-release") } // Install determines the correct download URL for the latest version of TT-Miner diff --git a/pkg/mining/version.go b/pkg/mining/version.go index d374805..96282d0 100644 --- a/pkg/mining/version.go +++ b/pkg/mining/version.go @@ -1,5 +1,12 @@ package mining +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + var ( version = "dev" commit = "none" @@ -20,3 +27,33 @@ func GetCommit() string { func GetBuildDate() string { return date } + +// GitHubRelease represents the structure of a GitHub release response. +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` +} + +// FetchLatestGitHubVersion fetches the latest release version from a GitHub repository. +// It takes the repository owner and name (e.g., "xmrig", "xmrig") and returns the tag name. +func FetchLatestGitHubVersion(owner, repo string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + + resp, err := getHTTPClient().Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch version: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) // Drain body to allow connection reuse + return "", fmt.Errorf("failed to get latest release: unexpected status code %d", resp.StatusCode) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to decode release: %w", err) + } + + return release.TagName, nil +} diff --git a/pkg/mining/xmrig.go b/pkg/mining/xmrig.go index 5e77e45..3aacd9a 100644 --- a/pkg/mining/xmrig.go +++ b/pkg/mining/xmrig.go @@ -2,10 +2,8 @@ package mining import ( "bytes" - "encoding/json" "errors" "fmt" - "io" "net/http" "os" "os/exec" @@ -91,24 +89,7 @@ var getXMRigConfigPath = func(instanceName string) (string, error) { // GetLatestVersion fetches the latest version of XMRig from the GitHub API. func (m *XMRigMiner) GetLatestVersion() (string, error) { - resp, err := getHTTPClient().Get("https://api.github.com/repos/xmrig/xmrig/releases/latest") - 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 - return "", fmt.Errorf("failed to get latest release: unexpected status code %d", resp.StatusCode) - } - - var release struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", err - } - return release.TagName, nil + return FetchLatestGitHubVersion("xmrig", "xmrig") } // Install determines the correct download URL for the latest version of XMRig diff --git a/pkg/mining/xmrig_start.go b/pkg/mining/xmrig_start.go index c873d1a..6ca22aa 100644 --- a/pkg/mining/xmrig_start.go +++ b/pkg/mining/xmrig_start.go @@ -113,11 +113,17 @@ func (m *XMRigMiner) Start(config *Config) error { // Normal exit case <-time.After(5 * time.Minute): // Process didn't exit after 5 minutes - force cleanup - logging.Warn("miner process wait timeout, forcing cleanup") + logging.Warn("miner process wait timeout, forcing cleanup", logging.Fields{"miner": m.Name}) if cmd.Process != nil { cmd.Process.Kill() } - <-done // Wait for the inner goroutine to finish + // Wait with timeout to prevent goroutine leak if Wait() never returns + select { + case <-done: + // Inner goroutine completed + case <-time.After(10 * time.Second): + logging.Error("process cleanup timed out after kill", logging.Fields{"miner": m.Name}) + } } m.mu.Lock() diff --git a/pkg/node/transport.go b/pkg/node/transport.go index 1765a33..fe5f299 100644 --- a/pkg/node/transport.go +++ b/pkg/node/transport.go @@ -117,7 +117,7 @@ func (t *Transport) Start() error { err = t.server.ListenAndServe() } if err != nil && err != http.ErrServerClosed { - // Log error + logging.Error("HTTP server error", logging.Fields{"error": err, "addr": t.config.ListenAddr}) } }() @@ -135,12 +135,14 @@ func (t *Transport) Stop() error { } t.mu.Unlock() - // Shutdown HTTP server - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + // Shutdown HTTP server if it was started + if t.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - if err := t.server.Shutdown(ctx); err != nil { - return fmt.Errorf("server shutdown error: %w", err) + if err := t.server.Shutdown(ctx); err != nil { + return fmt.Errorf("server shutdown error: %w", err) + } } t.wg.Wait()