From 0d5d74531104abcb8d25fd741cd0067fa9de616b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 23 Jun 2026 17:45:39 -0400 Subject: [PATCH] v5.0.1: Windows QtQuick crash fix + node/Tor lifecycle overhaul Windows crash chain (exposed by the embedded-node default): - main.cpp: force the QtQuick software renderer. The Mining tab's QQuickWidget drove the default RHI/GL path and faulted (0xc0000005) on broken-GL hosts (VirtualBox VMSVGA), crashing the app and orphaning the embedded node. Real hardware was unaffected. - ChildProcessGuard + DaemonManager/TorManager: kernel-level die-with-parent (Windows Job Object KILL_ON_JOB_CLOSE / Linux PR_SET_PDEATHSIG) so the embedded wownerod and bundled tor never survive a wowlet exit or crash. - DaemonManager: verify-before-adopt via get_info (nettype/height) instead of trusting a bare open RPC port, so a stale/orphan node is never adopted. Node/Tor lifecycle + remote-node connectivity: - config: torPrivacyLevel default -> allTorExceptNode. NEVER sync blocks over Tor; the wallet's node connection is always clearnet. Tor is only for tx broadcast, so remote nodes work with or without Tor. - nodes: add Nodes::reconnect() (clear failure blocklist + force reconnect); node/Tor toggle handlers use it so a transient outage no longer strands the wallet at "Disconnected". - MainWindow/SettingsDialog: reconnect on node/Tor toggle and on the async daemonStateChanged (closes the start() race; a freshly-toggled local node connects immediately). - TorManager: reset retry/unpack state in init() so a system<->bundled Tor re-toggle can start again; quiesce on stop(). Linux unpackBins() rework (mkpath + presence-based unpack + graceful return-false), getVersion() arg fix, and a missing-binary start guard. - build.yml: derive TOR_VERSION from the embedded tor binary (a hardcoded bundle number made unpackBins rewrite Tor every launch). - depends/funcs.mk: confine cross-cmake find_package to the depends sysroot so host -dev libraries don't leak into the build. --- .github/workflows/build.yml | 7 +- CMakeLists.txt | 2 +- contrib/depends/funcs.mk | 8 ++ src/MainWindow.cpp | 10 ++- src/SettingsDialog.cpp | 2 +- src/main.cpp | 9 +++ src/utils/ChildProcessGuard.cpp | 115 +++++++++++++++++++++++++++++ src/utils/ChildProcessGuard.h | 33 +++++++++ src/utils/DaemonManager.cpp | 125 +++++++++++++++++++++++++++++++- src/utils/DaemonManager.h | 5 ++ src/utils/TorManager.cpp | 81 ++++++++++++++++----- src/utils/config.cpp | 5 +- src/utils/nodes.cpp | 9 +++ src/utils/nodes.h | 3 + 14 files changed, 386 insertions(+), 28 deletions(-) create mode 100644 src/utils/ChildProcessGuard.cpp create mode 100644 src/utils/ChildProcessGuard.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index edc32968..ae77d335 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,12 @@ jobs: cd build # ARCH=x86-64: portable generic baseline. NEVER 'native' for a release — the CI runner's # CPU (often newer than users') would bake in unsupported instructions → SIGILL on launch. - cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION=15.0 .. + # wowlet: TOR_VERSION must EXACTLY match `tor --version` of the embedded binary, else + # TorManager::unpackBins() sees embedded > the unpacked copy and rewrites Tor every launch. + # The tor-expert-bundle's RELEASE number (15.0) is NOT its tor version (0.4.8.x) — derive it. + TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1) + echo "embedded tor version: $TORVER" + cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TORVER" .. cmake --build . -j $(nproc) - name: package AppImage run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index b4be8688..850d89c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.18) project(feather - VERSION "5.0.0" + VERSION "5.0.1" DESCRIPTION "A free Monero desktop wallet" LANGUAGES CXX C ASM ) diff --git a/contrib/depends/funcs.mk b/contrib/depends/funcs.mk index 2af46200..69215532 100644 --- a/contrib/depends/funcs.mk +++ b/contrib/depends/funcs.mk @@ -198,6 +198,14 @@ ifneq ($(host),$(build)) $(1)_cmake += -DCMAKE_SYSTEM_NAME=$($(host_os)_cmake_system) $(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host) $(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host) +# wowlet: confine find_package to the depends sysroot so cross cmake builds don't grab host +# /usr -dev packages (libzip's find_package(ZLIB) was picking up host zlib1g-dev). Mirrors +# toolchain.cmake.in; no-op on clean CI, needed on dev boxes with -dev libs. Upstreamable. +$(1)_cmake += -DCMAKE_FIND_ROOT_PATH=$(host_prefix) +$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER +$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY +$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY +$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY endif endif endef diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 60e2fef6..f1f50133 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -36,6 +36,7 @@ #include "utils/AppData.h" #include "utils/AsyncTask.h" #include "utils/ColorScheme.h" +#include "utils/DaemonManager.h" // wowlet: for daemonStateChanged reconnect (node-toggle race) #include "utils/Icons.h" #include "utils/TorManager.h" #include "utils/WebsocketNotifier.h" @@ -512,6 +513,13 @@ void MainWindow::initWalletContext() { this->onConnectionStatusChanged(status); m_nodes->autoConnect(); }); + // wowlet: the embedded node's RPC port comes up asynchronously after daemonManager()->start(), so a + // freshly-toggled local node would otherwise sit Disconnected until the next status tick. Reconnect the + // instant its RPC is up. (autoConnect's runLocalNode branch routes to 127.0.0.1; start() is idempotent.) + connect(daemonManager(), &DaemonManager::daemonStateChanged, this, [this](bool up){ + if (up && conf()->get(Config::runLocalNode).toBool()) + m_nodes->autoConnect(true); + }); connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle); connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded); @@ -703,7 +711,7 @@ void MainWindow::onWebsocketStatusChanged(bool enabled) { } void MainWindow::onProxySettingsChangedConnect() { - m_nodes->connectToNode(); + m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect after a live Tor/proxy switch (test 2) this->onProxySettingsChanged(); } diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index 9fabd060..0562dedf 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -177,7 +177,7 @@ void Settings::setupNetworkTab() { daemonManager()->start(); else daemonManager()->stop(false); // non-blocking: don't freeze Settings while wownerod flushes - m_nodes->autoConnect(true); + m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect (handles a previously-failed remote) }); } else { m_nodes = new Nodes(this, nullptr); diff --git a/src/main.cpp b/src/main.cpp index 00df4ce3..5008e38c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause // SPDX-FileCopyrightText: The Monero Project +#include #include #include "Application.h" @@ -88,6 +89,14 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round); #endif + // wowlet: force the QtQuick scene graph to the software renderer. The app's only QtQuick surface + // (the Mining tab's QQuickWidget) otherwise drives the default RHI/GL path, which faults (0xc0000005) + // on broken-GL environments — notably VirtualBox's VMSVGA — crashing the whole app and orphaning the + // embedded node. The mining widget is small + static, so software rendering costs nothing meaningful + // and guarantees no GL-init crash on any user's hardware. (Confirmed in a VMSVGA Win11 VM: the software + // backend launches cleanly where the default GL backend crashed.) + QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); + Application app(argc, argv); QApplication::setApplicationName("wowlet"); diff --git a/src/utils/ChildProcessGuard.cpp b/src/utils/ChildProcessGuard.cpp new file mode 100644 index 00000000..c3f9e568 --- /dev/null +++ b/src/utils/ChildProcessGuard.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#include "utils/ChildProcessGuard.h" + +#include + +#if defined(Q_OS_WIN) +#include +#elif defined(Q_OS_UNIX) +#include +#if defined(Q_OS_LINUX) +#include +#include +#endif +#endif + +namespace ChildProcessGuard +{ + +#if defined(Q_OS_WIN) + +// One process-wide job for all wowlet children. Created lazily on first use; the HANDLE is +// intentionally never closed — it must stay open for wowlet's entire lifetime so the job persists. +// On process death (clean OR crash) the kernel closes it, fires KILL_ON_JOB_CLOSE, and reaps the kids. +static HANDLE g_job = nullptr; + +static HANDLE jobHandle() +{ + if (g_job) + return g_job; + + HANDLE job = CreateJobObjectW(nullptr, nullptr); + if (!job) { + qWarning() << "ChildProcessGuard: CreateJobObject failed:" << GetLastError(); + return nullptr; + } + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{}; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info))) { + qWarning() << "ChildProcessGuard: SetInformationJobObject failed:" << GetLastError(); + CloseHandle(job); + return nullptr; + } + + g_job = job; + return g_job; +} + +void installPreStart(QProcess *) { /* no-op on Windows: assignment happens post-start */ } + +void adoptAfterStart(QProcess *process) +{ + HANDLE job = jobHandle(); + if (!job) + return; + + // QProcess::start() is asynchronous: right after it returns the child often has no PID yet. Wait + // (bounded) for it to actually start, else the assign is silently skipped and the child survives a + // parent crash (orphaned) — this is exactly what stranded wownerod in testing. Returns the instant + // the process is created, so the UI-thread cost is tiny. + if (process->state() == QProcess::Starting) + process->waitForStarted(5000); + + const qint64 pid = process->processId(); // 0 if it still isn't running + if (pid == 0) { + qWarning() << "ChildProcessGuard: child has no PID; not assigned to job"; + return; + } + + // AssignProcessToJobObject needs PROCESS_SET_QUOTA | PROCESS_TERMINATE. QProcess does not expose + // the child HANDLE, so re-open it by PID. + HANDLE proc = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, + static_cast(pid)); + if (!proc) { + qWarning() << "ChildProcessGuard: OpenProcess failed for pid" << pid << ":" << GetLastError(); + return; + } + + if (!AssignProcessToJobObject(job, proc)) + qWarning() << "ChildProcessGuard: AssignProcessToJobObject failed:" << GetLastError(); + else + qInfo() << "ChildProcessGuard: bound child pid" << pid << "to kill-on-close job"; + + CloseHandle(proc); // closing this per-child handle does NOT remove the process from the job +} + +#elif defined(Q_OS_UNIX) + +void installPreStart(QProcess *process) +{ +#if defined(Q_OS_LINUX) + // Runs in the forked child between fork() and exec(). Keep it async-signal-safe. + process->setChildProcessModifier([] { + ::prctl(PR_SET_PDEATHSIG, SIGKILL); + // Fork-race guard: if the parent already died before prctl ran, self-terminate. + if (::getppid() == 1) + ::raise(SIGKILL); + }); +#else + Q_UNUSED(process) // macOS/BSD: no PR_SET_PDEATHSIG; rely on aboutToQuit + destructor stop() +#endif +} + +void adoptAfterStart(QProcess *) { /* no-op on Unix: handled pre-start */ } + +#else + +void installPreStart(QProcess *) {} +void adoptAfterStart(QProcess *) {} + +#endif + +} // namespace ChildProcessGuard diff --git a/src/utils/ChildProcessGuard.h b/src/utils/ChildProcessGuard.h new file mode 100644 index 00000000..1f704d87 --- /dev/null +++ b/src/utils/ChildProcessGuard.h @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#ifndef WOWLET_CHILDPROCESSGUARD_H +#define WOWLET_CHILDPROCESSGUARD_H + +#include + +// wowlet: OS-level "die with parent" for spawned child processes (embedded wownerod, bundled tor). +// +// QProcess does NOT kill its child when the parent exits or crashes; it only detaches. Feather's +// TorManager/DaemonManager relied solely on an explicit stop() in the normal-quit path, so an +// unhandled crash (e.g. a 0xc0000005 access violation) orphaned the children. This binds the +// child's lifetime to wowlet's at the kernel level, which survives a hard crash where no C++ +// destructor and no Qt teardown runs. +// +// Windows: a single process-wide Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Assign each +// child after start(); when wowlet's process dies the job's last handle closes and the +// kernel terminates every process still in the job. +// Unix: prctl(PR_SET_PDEATHSIG, SIGKILL) installed in the child (via setChildProcessModifier), +// so the kernel SIGKILLs the child when the parent dies. +namespace ChildProcessGuard +{ + // Unix: arrange for `process` to receive SIGKILL when wowlet dies. Call BEFORE process->start(). + // No-op on Windows (the Job Object handles it post-start). + void installPreStart(QProcess *process); + + // Windows: assign the (now-running) child to wowlet's kill-on-close job. Call right AFTER start() + // returns and the child has a PID. No-op on Unix. + void adoptAfterStart(QProcess *process); +} + +#endif //WOWLET_CHILDPROCESSGUARD_H diff --git a/src/utils/DaemonManager.cpp b/src/utils/DaemonManager.cpp index 7b387d20..e9c4263e 100644 --- a/src/utils/DaemonManager.cpp +++ b/src/utils/DaemonManager.cpp @@ -6,10 +6,21 @@ #include #include #include +#include #include +#include +#include +#include +#include +#include #include +#include +#include "constants.h" +#include "utils/AppData.h" +#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for the embedded node #include "utils/config.h" +#include "utils/networktype.h" #include "utils/TorManager.h" #include "utils/Utils.h" @@ -25,6 +36,11 @@ DaemonManager::DaemonManager(QObject *parent) connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput); connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError); connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged); + + // wowlet: SIGKILL wownerod if wowlet dies (Unix); Windows binds it to a kill-on-close job post-start. + ChildProcessGuard::installPreStart(m_process); + // wowlet: graceful stop on any clean quit, not just the last-window-closed path (flushes LMDB). + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); }); } QPointer DaemonManager::m_instance(nullptr); @@ -51,11 +67,22 @@ void DaemonManager::start() { return; } + // wowlet: don't trust a bare open port. Before adopting whatever is listening on our RPC port, + // verify via get_info that it's a healthy wownero-mainnet wownerod (right nettype, sane height, + // making progress) — not a stale/stuck orphan from a prior crash or an unrelated squatter. if (Utils::portOpen(this->rpcHost, this->rpcPort)) { - // Something is already listening on our RPC port; assume it is a usable local node. - m_alreadyRunning = true; - emit daemonStateChanged(true); - return; + if (this->verifyAdoptableNode()) { + qDebug() << "Adopting existing wownerod on" << this->rpcAddress(); + m_alreadyRunning = true; + emit daemonStateChanged(true); + return; + } + qWarning() << "Refusing to adopt unverified listener on" << this->rpcAddress() + << "- will spawn a managed wownerod instead"; + this->setErrorMessage( + QString("Found an unrecognized process on %1; starting a fresh node.").arg(this->rpcAddress())); + // fall through to spawn; if the port/data-dir is genuinely held, the spawn fails to bind + // and surfaces a clear error via handleProcessError(). } m_restarts += 1; @@ -106,6 +133,7 @@ void DaemonManager::start() { qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" ")); m_process->start(this->daemonPath, arguments); + ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows) m_started = true; } @@ -238,6 +266,95 @@ bool DaemonManager::shouldStartDaemon() { return true; } +// wowlet: synchronous, bounded get_info probe used by start() to decide whether an already-open RPC +// port is a node we can safely adopt. Returns true ONLY for a healthy wownero-mainnet wownerod. +// Self-contained (own QNetworkAccessManager + local QEventLoop with a hard timeout) so it can run +// inside the synchronous start() at boot, before any wallet/DaemonRpc exists. Mirrors TorManager's +// blocking getVersion() probe idiom. +bool DaemonManager::verifyAdoptableNode() { + if (conf()->get(Config::offlineMode).toBool()) + return false; + + QNetworkAccessManager nam; + QNetworkRequest req(QUrl(QString("http://%1/get_info").arg(this->rpcAddress()))); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + + QNetworkReply *reply = nam.post(req, QByteArray("{}")); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + + timeout.start(1500); // hard cap on UI-thread block + loop.exec(); + + if (!reply->isFinished()) { // timed out + reply->abort(); + reply->deleteLater(); + qWarning() << "get_info verify: no response from" << this->rpcAddress() << "within 1.5s"; + return false; + } + + bool netOk = reply->error() == QNetworkReply::NoError; + QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (!netOk || data.isEmpty() || !Utils::validateJSON(data)) { + qWarning() << "get_info verify: not a JSON-RPC daemon on" << this->rpcAddress(); + return false; + } + + QJsonObject obj = QJsonDocument::fromJson(data).object(); + + if (obj.value("status").toString() != "OK") { // 1. real wownerod RPC + qWarning() << "get_info verify: bad status" << obj.value("status").toString(); + return false; + } + + const QString wantNet = constants::networkType == NetworkType::MAINNET ? "mainnet" // 2. nettype + : constants::networkType == NetworkType::TESTNET ? "testnet" + : "stagenet"; + if (obj.value("nettype").toString() != wantNet) { + qWarning() << "get_info verify: wrong nettype" << obj.value("nettype").toString() + << "(want" << wantNet << ")"; + return false; + } + + const quint64 height = obj.value("height").toVariant().toULongLong(); // 3. height sanity + if (height == 0) { + qWarning() << "get_info verify: node reports height 0"; + return false; + } + quint64 knownHeight = 0; + if (appData()->heights.contains(constants::networkType)) + knownHeight = static_cast(appData()->heights[constants::networkType]); + + const quint64 peers = obj.value("outgoing_connections_count").toVariant().toULongLong() + + obj.value("incoming_connections_count").toVariant().toULongLong(); + const bool progressing = obj.value("busy_syncing").toBool() || obj.value("synchronized").toBool(); + + if (knownHeight > 0) { + const quint64 slack = 720; // ~1 day of wownero blocks; live-but-lagging is still adoptable + if (height + slack < knownHeight) { + if (peers == 0 && !progressing) { // 4. stuck-orphan signature: behind AND peerless AND idle + qWarning() << "get_info verify: stale/stuck node — height" << height + << "<< known" << knownHeight << "with 0 peers, not syncing"; + return false; + } + qWarning() << "get_info verify: node behind (" << height << "/" << knownHeight + << ") but live; adopting and letting it catch up"; + } + } + + if (peers == 0) // soft: a freshly-spawned-but-orphaned node sits at 0 peers for minutes + qWarning() << "get_info verify: adopting node with 0 peers (likely still bootstrapping)"; + + qDebug() << "get_info verify: OK — nettype" << wantNet << "height" << height << "peers" << peers; + return true; +} + void DaemonManager::setErrorMessage(const QString &msg) { this->errorMsg = msg; if (!msg.isEmpty()) diff --git a/src/utils/DaemonManager.h b/src/utils/DaemonManager.h index a7c3f6f0..18b3181d 100644 --- a/src/utils/DaemonManager.h +++ b/src/utils/DaemonManager.h @@ -59,6 +59,11 @@ private slots: private: bool shouldStartDaemon(); + // wowlet: verify an already-open RPC port really is a healthy wownero-mainnet wownerod before + // adopting it, instead of trusting a bare TCP port-open probe. Prevents the wallet from silently + // syncing against a stale/stuck orphan (e.g. left by a prior crash) or a process squatting + // 127.0.0.1:34568. Synchronous, bounded (~1.5s), UI-thread-safe. + bool verifyAdoptableNode(); void setErrorMessage(const QString &msg); QProcess *m_process; diff --git a/src/utils/TorManager.cpp b/src/utils/TorManager.cpp index 51e068b6..5b75200c 100644 --- a/src/utils/TorManager.cpp +++ b/src/utils/TorManager.cpp @@ -6,6 +6,7 @@ #include #include +#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for bundled tor #include "utils/config.h" #include "utils/Utils.h" #include "utils/os/tails.h" @@ -37,15 +38,26 @@ TorManager::TorManager(QObject *parent) connect(m_process, &QProcess::readyReadStandardOutput, this, &TorManager::handleProcessOutput); connect(m_process, &QProcess::errorOccurred, this, &TorManager::handleProcessError); connect(m_process, &QProcess::stateChanged, this, &TorManager::stateChanged); + + ChildProcessGuard::installPreStart(m_process); // wowlet: SIGKILL tor if wowlet dies (Unix) + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); }); // wowlet } QPointer TorManager::m_instance(nullptr); void TorManager::init() { + // wowlet: a live proxy/useLocalTor toggle re-runs init(). Reset failure/retry state so switching + // system->bundled Tor after a prior failure (Linux unpack, or a FailedToStart that latched + // m_stopRetries in handleProcessError) can actually start again instead of staying silently dead. + m_restarts = 0; + m_stopRetries = false; + m_unpacked = false; // re-evaluate the unpack on the next start() + m_localTor = !shouldStartTorDaemon(); auto state = m_process->state(); if (m_localTor && (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)) { + m_stopRetries = true; // wowlet: intentional kill (switching to system Tor); don't auto-restart it m_process->kill(); m_started = false; } @@ -54,6 +66,8 @@ void TorManager::init() { } void TorManager::stop() { + m_stopRetries = true; // wowlet: intentional stop — suppress the stateChanged() auto-restart + m_checkConnectionTimer->stop(); // wowlet: stop probing the connection after an intentional stop m_process->kill(); m_started = false; } @@ -82,6 +96,17 @@ void TorManager::start() { this->torPath = alternativeTorFile; } + // wowlet: never hand QProcess a non-existent path (the Linux "empty folder" symptom). If the bundled + // binary isn't on disk, degrade to local/external-Tor mode + surface an error instead of burning the + // 4-retry budget on FailedToStart. With clearnet sync, node sync is unaffected; only broadcast-over-Tor + // is lost, which DaemonManager tolerates via clearnet relay. + if (!QFileInfo(this->torPath).isFile()) { + this->setErrorMessage("Bundled Tor binary not found; using external/local Tor if available: " + this->torPath); + m_localTor = true; + this->checkConnection(); + return; + } + qDebug() << QString("Start process: %1").arg(this->torPath); m_restarts += 1; @@ -102,6 +127,7 @@ void TorManager::start() { qDebug() << QString("%1 %2").arg(this->torPath, arguments.join(" ")); m_process->start(this->torPath, arguments); + ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows) m_started = true; } @@ -201,37 +227,54 @@ bool TorManager::unpackBins() { return true; } - SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION)); - SemanticVersion filesystemVersion = this->getVersion(torPath); - qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString()); - if (SemanticVersion::isValid(filesystemVersion) && (embeddedVersion > filesystemVersion)) { - qInfo() << "Embedded version is newer, overwriting."; - QFile::setPermissions(torPath, QFile::ReadOther | QFile::WriteOther); - if (!QFile::remove(torPath)) { - qWarning() << "Unable to remove old Tor binary"; - return false; - } - } + // wowlet: QFile::copy never creates parent dirs; without this the copy loop can silently no-op on + // a fresh profile, leaving an empty tor/ dir and "tor failed to start" (Lucas/Fedora). main.cpp + // also mkpaths this, but make unpackBins() self-sufficient. Mirrors DaemonManager::unpackBins(). + QDir().mkpath(this->torDir); - if (embeddedVersion > filesystemVersion) { + // wowlet: unpack when the binary is MISSING, not only when a version compare says "newer". Overwrite + // only when the embedded build is a VALID newer version (getVersion() of a non-existent file returns + // an invalid SemanticVersion, so it must not gate the first unpack). + bool present = QFileInfo(this->torPath).isFile(); + SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION)); + SemanticVersion filesystemVersion = present ? this->getVersion(this->torPath) : SemanticVersion(); + qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString()); + bool needsOverwrite = present && SemanticVersion::isValid(filesystemVersion) + && (embeddedVersion > filesystemVersion); + + if (!present || needsOverwrite) { + if (needsOverwrite) { + qInfo() << "Embedded Tor is newer, overwriting."; + QFile::setPermissions(this->torPath, QFile::ReadOther | QFile::WriteOther); + QFile::remove(this->torPath); + } QDirIterator it(":/assets/tor", QDirIterator::Subdirectories); while (it.hasNext()) { QString assetFile = it.next(); - QFileInfo assetFileInfo = QFileInfo(assetFile); + QString filePath = QDir(this->torDir).filePath(QFileInfo(assetFile).fileName()); QFile f(assetFile); - QString filePath = QDir(this->torDir).filePath(assetFileInfo.fileName()); f.copy(filePath); f.close(); } - qInfo() << "Wrote Tor binaries to: " << this->torDir; + qInfo() << "Wrote Tor binaries to:" << this->torDir; } #if defined(Q_OS_UNIX) - QFile tor(this->torPath); - tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther - | QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther); + // wowlet: only chmod if the binary actually landed. + if (QFileInfo(this->torPath).isFile()) { + QFile tor(this->torPath); + tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther + | QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther); + } #endif + // wowlet: do NOT report success if nothing landed — return false so shouldStartTorDaemon() falls + // back to --use-local-tor cleanly (see :297-303) instead of start()ing a missing binary. + if (!QFileInfo(this->torPath).isFile()) { + qWarning() << "Tor was not unpacked (no embedded Tor, or copy failed):" << this->torPath; + return false; + } + m_unpacked = true; return true; } @@ -310,7 +353,7 @@ bool TorManager::shouldStartTorDaemon() { SemanticVersion TorManager::getVersion(const QString &fileName) { QProcess process; process.setProcessChannelMode(QProcess::MergedChannels); - process.start(this->torPath, QStringList() << "--version"); + process.start(fileName, QStringList() << "--version"); // wowlet: use the passed path, not this->torPath process.waitForFinished(-1); QString output = process.readAllStandardOutput(); diff --git a/src/utils/config.cpp b/src/utils/config.cpp index 605995a2..36dc0589 100644 --- a/src/utils/config.cpp +++ b/src/utils/config.cpp @@ -115,7 +115,10 @@ static const QHash configStrings = { // Tor {Config::proxy, {QS("proxy"), Config::Proxy::Tor}}, - {Config::torPrivacyLevel, {QS("torPrivacyLevel"), 1}}, + // wowlet: allTorExceptNode(0) — NEVER sync blocks over Tor. Block sync is ALWAYS clearnet (speed, + // the standard monero model). Tor is ONLY for tx broadcast (via DaemonManager's --tx-proxy), which + // is independent of this. This is what makes remote nodes work with OR without Tor. + {Config::torPrivacyLevel, {QS("torPrivacyLevel"), 0}}, {Config::torOnlyAllowOnion, {QS("torOnlyAllowOnion"), false}}, {Config::socks5Host, {QS("socks5Host"), "127.0.0.1"}}, {Config::socks5Port, {QS("socks5Port"), "9050"}}, diff --git a/src/utils/nodes.cpp b/src/utils/nodes.cpp index f640ebef..5c1e10bc 100644 --- a/src/utils/nodes.cpp +++ b/src/utils/nodes.cpp @@ -330,6 +330,15 @@ void Nodes::autoConnect(bool forceReconnect) { this->updateModels(); } +// wowlet: clear the recent-failure blocklist then force a reconnect. autoConnect(true) alone is not +// enough — pickEligibleNode() still filters the failed node out of m_recentFailures, so a node that +// dropped during a transient Tor/proxy outage would stay blocklisted ("permanent Disconnected"). The +// node/Tor live-toggle handlers call this to recover. +void Nodes::reconnect() { + m_recentFailures.clear(); + this->autoConnect(true); +} + FeatherNode Nodes::pickEligibleNode() { // Pick a node at random to connect to auto rtn = FeatherNode(); diff --git a/src/utils/nodes.h b/src/utils/nodes.h index 76bf8755..ac925eb3 100644 --- a/src/utils/nodes.h +++ b/src/utils/nodes.h @@ -173,6 +173,9 @@ public slots: void onNodeSourceChanged(NodeSource nodeSource); void setCustomNodes(const QList& nodes); void autoConnect(bool forceReconnect = false); + // wowlet: clear the recent-failure blocklist and force-reconnect. Used by the node/Tor live-toggle + // handlers so a node blocklisted during a transient outage (e.g. a Tor toggle) is actually retried. + void reconnect(); private slots: void onWalletRefreshed();