feat(mining): persistent start/stop error caption + timeout-aware pool status

- A mining start/stop/pool failure now sets a persistent App::mining_last_error_
  (cleared on the next attempt), rendered as a red caption with "Press Mine to
  try again" below the Mine button (plus a Dismiss, and a Windows-Defender
  "How to fix" on an AV block), so a crashed/blocked/failed miner is no longer
  just a button that "did nothing" once its toast faded. Two more hardcoded
  English strings (pool-payout-invalid, AV-blocked) moved to i18n.
- Pool connection status is now a persistent "Status" row in the pool stats
  card (PoolMiningState.running_since / last_error): it flips from "Connecting…"
  to "Can't reach pool — check the URL" after a 30s timeout instead of sitting
  on red forever, and no longer vanishes once logs arrive. The old transient
  banner in mining_tab.cpp was removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 12:09:49 -05:00
co-authored by Claude Opus 4.8
parent 15c1181aef
commit 8fe20f6385
8 changed files with 104 additions and 29 deletions
+21 -2
View File
@@ -1233,14 +1233,18 @@ void App::update()
if (xmrig_manager_ && state_.pool_mining.xmrig_running if (xmrig_manager_ && state_.pool_mining.xmrig_running
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) { && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
state_.pool_mining.xmrig_running = false; state_.pool_mining.xmrig_running = false;
state_.pool_mining.running_since = 0;
state_.pool_mining.last_error.clear();
state_.pool_mining.hashrate_10s = 0.0; state_.pool_mining.hashrate_10s = 0.0;
state_.pool_mining.hashrate_60s = 0.0; state_.pool_mining.hashrate_60s = 0.0;
state_.pool_mining.hashrate_15m = 0.0; state_.pool_mining.hashrate_15m = 0.0;
pool_starting_.store(false, std::memory_order_relaxed); pool_starting_.store(false, std::memory_order_relaxed);
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
const std::string err = xmrig_manager_->getLastError(); const std::string err = xmrig_manager_->getLastError();
ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly") const std::string mmsg = err.empty() ? std::string(TR("appx_miner_stopped_unexpectedly"))
: (std::string(TR("appx_miner_stopped_prefix")) + err)); : (std::string(TR("appx_miner_stopped_prefix")) + err);
setMiningError(mmsg); // persist: a miner that crashed/was killed otherwise just "did nothing"
ui::Notifications::instance().error(mmsg);
} }
// Poll xmrig stats every ~2 seconds (use a simple toggle) // Poll xmrig stats every ~2 seconds (use a simple toggle)
@@ -1273,6 +1277,19 @@ void App::update()
ps.threads_active = xs.threads_active; ps.threads_active = xs.threads_active;
ps.pool_hashrate = xs.pool_hashrate; ps.pool_hashrate = xs.pool_hashrate;
ps.log_lines = xmrig_manager_->getRecentLines(30); ps.log_lines = xmrig_manager_->getRecentLines(30);
if (ps.running_since == 0) ps.running_since = std::time(nullptr);
// While not yet connected, surface the newest error-looking log line so "Connecting" can flip
// to a real "can't reach pool" after a timeout instead of sitting on red forever.
ps.last_error.clear();
if (!ps.connected) {
for (auto it = ps.log_lines.rbegin(); it != ps.log_lines.rend(); ++it) {
std::string l = *it;
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c){ return (char)std::tolower(c); });
if (l.find("error") != std::string::npos || l.find("refused") != std::string::npos ||
l.find("timed out") != std::string::npos || l.find("unreachable") != std::string::npos ||
l.find("dns") != std::string::npos) { ps.last_error = *it; break; }
}
}
// Record hashrate sample for the chart // Record hashrate sample for the chart
ps.hashrate_history.push_back(ps.hashrate_10s); ps.hashrate_history.push_back(ps.hashrate_10s);
@@ -1283,6 +1300,8 @@ void App::update()
} }
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) { } else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
state_.pool_mining.xmrig_running = false; state_.pool_mining.xmrig_running = false;
state_.pool_mining.running_since = 0;
state_.pool_mining.last_error.clear();
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
} }
+9
View File
@@ -389,6 +389,11 @@ public:
void startMining(int threads); void startMining(int threads);
void stopMining(); void stopMining();
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); } bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
// Persistent mining error (last start/stop/pool failure) so a failed miner isn't just a fading toast.
const std::string& miningLastError() const { return mining_last_error_; }
bool miningErrorIsAntivirus() const { return mining_error_av_; }
void clearMiningError() { mining_last_error_.clear(); mining_error_av_ = false; }
void showAntivirusHelp() { pending_antivirus_dialog_ = true; } // Windows-only dialog; inert elsewhere
// Pool mining (xmrig) // Pool mining (xmrig)
void startPoolMining(int threads); void startPoolMining(int threads);
@@ -1745,6 +1750,10 @@ private:
// Mining toggle guard (prevents concurrent setgenerate calls) // Mining toggle guard (prevents concurrent setgenerate calls)
std::atomic<bool> mining_toggle_in_progress_{false}; std::atomic<bool> mining_toggle_in_progress_{false};
// Mining error state (main-thread only — every writer is in a MainCb or render).
std::string mining_last_error_;
bool mining_error_av_ = false;
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the // True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay). // poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
+19 -8
View File
@@ -3529,6 +3529,7 @@ void App::startMining(int threads)
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency()); const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
threads = std::clamp(threads, 1, maxThreads); threads = std::clamp(threads, 1, maxThreads);
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
clearMiningError(); // a fresh attempt wipes any stale error
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
bool ok = false; bool ok = false;
@@ -3548,7 +3549,9 @@ void App::startMining(int threads)
DEBUG_LOGF("Mining started with %d threads\n", threads); DEBUG_LOGF("Mining started with %d threads\n", threads);
} else { } else {
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str()); DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
ui::Notifications::instance().error(std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg)); const std::string merr = std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg);
setMiningError(merr);
ui::Notifications::instance().error(merr);
} }
}; };
}); });
@@ -3559,6 +3562,7 @@ void App::stopMining()
if (!supportsSoloMining()) return; if (!supportsSoloMining()) return;
if (!state_.connected || !rpc_ || !worker_) return; if (!state_.connected || !rpc_ || !worker_) return;
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
clearMiningError();
worker_->post([this]() -> rpc::RPCWorker::MainCb { worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool ok = false; bool ok = false;
@@ -3580,7 +3584,9 @@ void App::stopMining()
} else { } else {
// Don't silently leave generate=true as if it worked: tell the user and let the next // Don't silently leave generate=true as if it worked: tell the user and let the next
// getmininginfo refresh reconcile the true daemon state. (M-07) // getmininginfo refresh reconcile the true daemon state. (M-07)
ui::Notifications::instance().error(std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg)); const std::string merr = std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg);
setMiningError(merr);
ui::Notifications::instance().error(merr);
} }
}; };
}); });
@@ -3634,9 +3640,11 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain). // A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
if (!cfg.worker_name.empty() && cfg.worker_name != "x" && if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
!util::isValidRecipientAddress(cfg.worker_name)) { !util::isValidRecipientAddress(cfg.worker_name)) {
ui::Notifications::instance().error(util::isTransparentAddress(cfg.worker_name) const std::string merr = util::isTransparentAddress(cfg.worker_name)
? TR("mining_payout_needs_shielded") ? std::string(TR("mining_payout_needs_shielded"))
: "Pool payout address is not a valid DragonX address — mining not started."); : std::string(TR("toast_pool_payout_invalid"));
setMiningError(merr);
ui::Notifications::instance().error(merr);
if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates
return; return;
} }
@@ -3667,6 +3675,7 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
if (cfg.wallet_address.empty()) { if (cfg.wallet_address.empty()) {
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
setMiningError(TR("toast_no_address_generate_z"));
ui::Notifications::instance().error(TR("toast_no_address_generate_z")); ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
if (retargetOnly) stopPoolMining(); if (retargetOnly) stopPoolMining();
return; return;
@@ -3715,13 +3724,15 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) { if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
ui::Notifications::instance().error( setMiningError(TR("toast_av_blocked_xmrig"), /*av=*/true);
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); ui::Notifications::instance().error(TR("toast_av_blocked_xmrig"));
#ifdef _WIN32 #ifdef _WIN32
pending_antivirus_dialog_ = true; pending_antivirus_dialog_ = true;
#endif #endif
} else { } else {
ui::Notifications::instance().error(std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err)); const std::string merr = std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err);
setMiningError(merr);
ui::Notifications::instance().error(merr);
} }
} }
}; };
+2
View File
@@ -254,6 +254,8 @@ struct PoolMiningState {
int64_t uptime_sec = 0; int64_t uptime_sec = 0;
double pool_diff = 0; double pool_diff = 0;
bool connected = false; bool connected = false;
std::time_t running_since = 0; // wall-clock the miner was first seen running this session (0 = not running)
std::string last_error; // newest xmrig log line that looks like a connection error ("" = none)
// Memory/thread usage (bytes for memory) // Memory/thread usage (bytes for memory)
int64_t memory_used = 0; int64_t memory_used = 0;
+24
View File
@@ -991,6 +991,30 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
ImGui::Dummy(ImVec2(availWidth, 0)); ImGui::Dummy(ImVec2(availWidth, 0));
// Persistent error caption (a crashed/AV-blocked/failed miner otherwise leaves a button that
// "did nothing" once its toast faded). Normal ImGui flow, OUTSIDE the hand-drawn button card.
const std::string& merr = app->miningLastError();
if (!merr.empty() && !app->isMiningToggleInProgress()) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushTextWrapPos(cardMin.x + availWidth);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::TextWrapped("%s", merr.c_str());
ImGui::PopStyleColor();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
ImGui::TextUnformatted(TR("mining_error_retry_hint"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
#ifdef _WIN32
if (app->miningErrorIsAntivirus()) {
ImGui::SameLine(0, Layout::spacingSm());
if (ImGui::SmallButton(TR("mining_error_av_help"))) app->showAntivirusHelp();
}
#endif
ImGui::SameLine(0, Layout::spacingSm());
if (ImGui::SmallButton(TR("mining_error_dismiss"))) app->clearMiningError();
}
ImGui::Dummy(ImVec2(0, gap)); ImGui::Dummy(ImVec2(0, gap));
} }
+16 -1
View File
@@ -248,6 +248,21 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60)); else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
metricRow(TR("mining_uptime"), uptBuf, OnSurface()); metricRow(TR("mining_uptime"), uptBuf, OnSurface());
} }
// Connection status — replaces the old transient "Connecting" banner, with a timeout so a bad URL /
// blocked port flips from "Connecting" to "Can't reach pool" instead of spinning on red forever.
{
static constexpr int kPoolConnectTimeoutSec = 30;
const bool shares = pm.accepted + pm.rejected > 0;
const bool ok = pm.connected || shares || pm.hashrate_10s > 0;
const bool failed = pm.xmrig_running && !ok && pm.running_since > 0 &&
(std::time(nullptr) - pm.running_since) >= kPoolConnectTimeoutSec;
const char* stateText; ImU32 col;
if (ok) { stateText = TR("mining_status_connected"); col = Success(); }
else if (failed) { stateText = TR("mining_pool_unreachable"); col = Error(); }
else if (pm.xmrig_running) { stateText = TR("mining_status_connecting"); col = Warning(); }
else { stateText = TR("mining_status_stopped"); col = OnSurfaceDisabled(); }
metricRow(TR("mining_status_label"), stateText, col);
}
y += gap * 0.5f; y += gap * 0.5f;
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool. // The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
@@ -397,7 +412,7 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
const float mRowH = sub1->LegacySize + 7 * dp; const float mRowH = sub1->LegacySize + 7 * dp;
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
float leftContentH = pad * 0.75f + toggleH + gap float leftContentH = pad * 0.75f + toggleH + gap
+ mRowH * 4.0f + gap * 0.5f + mRowH * 5.0f + gap * 0.5f // 4 metric rows + the connection-status row
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f; + (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad); float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
+3 -18
View File
@@ -335,24 +335,9 @@ static void RenderMiningTabContent(App* app)
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver); isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
// ================================================================ // ================================================================
// POOL CONNECTION STATUS — inline indicator (pool mode, no log) // (Pool connection status now lives as a persistent, timeout-aware row in RenderLeftPoolCard
// ================================================================ // (mining_stats.cpp) — the old transient "Connecting" banner that vanished once logs arrived and
if (s_pool_mode && state.pool_mining.log_lines.empty() && state.pool_mining.xmrig_running) { // sat on red forever on failure was removed.)
ImFont* iconFont = Type().iconSmall();
const char* dotIcon = ICON_MD_CIRCLE;
ImU32 dotCol = state.pool_mining.connected ? WithAlpha(Success(), 200) : WithAlpha(Error(), 200);
const char* statusText = state.pool_mining.connected
? (state.pool_mining.pool_url.empty() ? TR("mining_connected") : state.pool_mining.pool_url.c_str())
: TR("mining_connecting");
ImVec2 pos = ImGui::GetCursorScreenPos();
ImVec2 dotSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, dotIcon);
dl->AddText(iconFont, iconFont->LegacySize, pos, dotCol, dotIcon);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(pos.x + dotSz.x + 4 * dp, pos.y + (dotSz.y - capFont->LegacySize) * 0.5f),
OnSurfaceMedium(), statusText);
ImGui::Dummy(ImVec2(availWidth, capFont->LegacySize + 4 * dp));
}
} }
} // namespace ui } // namespace ui
+10
View File
@@ -1957,6 +1957,11 @@ void I18n::loadBuiltinEnglish()
strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…"; strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…";
strings_["toast_not_connected_daemon"] = "Not connected to the node."; strings_["toast_not_connected_daemon"] = "Not connected to the node.";
strings_["toast_mining_failed"] = "Couldn't start mining: "; strings_["toast_mining_failed"] = "Couldn't start mining: ";
strings_["toast_pool_payout_invalid"] = "Pool payout address is not a valid DragonX address — mining not started.";
strings_["toast_av_blocked_xmrig"] = "Windows Defender blocked the miner. Add an exclusion for %APPDATA%\\ObsidianDragon.";
strings_["mining_error_retry_hint"] = "Press Mine to try again.";
strings_["mining_error_av_help"] = "How to fix";
strings_["mining_error_dismiss"] = "Dismiss";
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: "; strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: "; strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
strings_["toast_ban_failed"] = "Couldn't ban the peer: "; strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
@@ -2258,6 +2263,11 @@ void I18n::loadBuiltinEnglish()
strings_["mining_click_copy_difficulty"] = "Click to copy difficulty"; strings_["mining_click_copy_difficulty"] = "Click to copy difficulty";
strings_["mining_connected"] = "Connected"; strings_["mining_connected"] = "Connected";
strings_["mining_connecting"] = "Connecting..."; strings_["mining_connecting"] = "Connecting...";
strings_["mining_status_label"] = "Status";
strings_["mining_status_connected"] = "Connected";
strings_["mining_status_connecting"] = "Connecting…";
strings_["mining_status_stopped"] = "Stopped";
strings_["mining_pool_unreachable"] = "Can't reach pool — check the URL";
strings_["mining_difficulty_copied"] = "Difficulty copied"; strings_["mining_difficulty_copied"] = "Difficulty copied";
strings_["mining_est_block"] = "Est. Block"; strings_["mining_est_block"] = "Est. Block";
strings_["mining_est_daily"] = "Est. Daily"; strings_["mining_est_daily"] = "Est. Daily";