diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 6532563af..014273dec 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -116,10 +116,14 @@ set(QET_RES_FILES set(QET_SRC_FILES ${QET_DIR}/sources/cli_export.cpp ${QET_DIR}/sources/cli_export.h + ${QET_DIR}/sources/logging/crashhandler.cpp + ${QET_DIR}/sources/logging/crashhandler.h ${QET_DIR}/sources/logging/logring.cpp ${QET_DIR}/sources/logging/logring.h ${QET_DIR}/sources/logging/qetlogger.cpp ${QET_DIR}/sources/logging/qetlogger.h + ${QET_DIR}/sources/logging/ui/diagnosticsreportdialog.cpp + ${QET_DIR}/sources/logging/ui/diagnosticsreportdialog.h ${QET_DIR}/sources/pdf_links.cpp ${QET_DIR}/sources/pdf_links.h ${QET_DIR}/sources/import/edz/edzarchive.cpp diff --git a/qelectrotech.pro b/qelectrotech.pro index 028266bb0..2d2c38fef 100644 --- a/qelectrotech.pro +++ b/qelectrotech.pro @@ -174,7 +174,8 @@ HEADERS += $$files(sources/*.h) \ $$files(sources/svg/*.h) \ $$files(sources/import/edz/*.h) \ $$files(sources/import/edz/lzma/*.h) \ - $$files(sources/logging/*.h) + $$files(sources/logging/*.h) \ + $$files(sources/logging/ui/*.h) SOURCES += $$files(sources/*.cpp) \ $$files(sources/editor/*.cpp) \ @@ -221,7 +222,8 @@ SOURCES += $$files(sources/*.cpp) \ $$files(sources/svg/*.cpp) \ $$files(sources/import/edz/*.cpp) \ $$files(sources/import/edz/lzma/*.c) \ - $$files(sources/logging/*.cpp) + $$files(sources/logging/*.cpp) \ + $$files(sources/logging/ui/*.cpp) # Needed for use promote QTreeWidget in terminalstripeditor.ui diff --git a/sources/logging/crashhandler.cpp b/sources/logging/crashhandler.cpp new file mode 100644 index 000000000..1a66e3a0d --- /dev/null +++ b/sources/logging/crashhandler.cpp @@ -0,0 +1,171 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "crashhandler.h" + +#include "logring.h" +#include "../qetversion.h" + +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#include +#include +#include +#include +#else +#include +#include +#include +#include +#endif + +namespace { + +// Everything the handler touches is preallocated here and filled in by +// install() (normal context, runs once at startup) -- nothing under the +// actual signal/exception path may allocate or touch QString/Qt. +const LogRing *g_ring = nullptr; +char g_dump_path[1024] = {}; +char g_header[1024] = {}; +int g_header_len = 0; + +// Guards against two threads crashing at once, or the handler itself +// faulting while dumping: only the first crash writes a dump. See +// crashhandler.h invariant 4. +std::atomic g_already_dumped{false}; + +#ifndef Q_OS_WIN + +// A stack-overflow SIGSEGV leaves no usable stack for a handler to run +// on at all, hence the alternate signal stack (invariant: sized well +// above any known SIGSTKSZ so this doesn't depend on +// sysconf(_SC_SIGSTKSZ), which some libc versions require at runtime +// rather than offering as a compile-time constant). +char g_altstack[65536]; + +const int kHandledSignals[] = {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}; + +void restoreDefaultAndReraise(int sig) +{ + struct sigaction sa {}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(sig, &sa, nullptr); + raise(sig); +} + +void signalHandler(int sig) +{ + if (g_already_dumped.exchange(true, std::memory_order_acq_rel)) { + // Not the first crash (concurrent fault on another thread, or + // this handler faulting while dumping): skip straight to + // restore-and-re-raise rather than risk a second, interleaved + // write to the same file. + restoreDefaultAndReraise(sig); + return; + } + + // open/write/close are all on the POSIX async-signal-safe function + // list; nothing else is called here. + const int fd = ::open(g_dump_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + if (g_header_len > 0) { + ::write(fd, g_header, static_cast(g_header_len)); + } + if (g_ring) { + g_ring->dumpToFd(fd); + } + ::close(fd); + } + + restoreDefaultAndReraise(sig); +} + +#else // Q_OS_WIN + +LONG WINAPI windowsExceptionFilter(EXCEPTION_POINTERS *) +{ + bool expected = false; + if (!g_already_dumped.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { + return EXCEPTION_CONTINUE_SEARCH; + } + + int fd = -1; + errno_t err = _sopen_s(&fd, g_dump_path, + _O_WRONLY | _O_CREAT | _O_TRUNC | _O_BINARY, + _SH_DENYWR, _S_IREAD | _S_IWRITE); + if (err == 0 && fd >= 0) { + if (g_header_len > 0) { + _write(fd, g_header, g_header_len); + } + if (g_ring) { + g_ring->dumpToFd(fd); + } + _close(fd); + } + + // Do not suppress Windows Error Reporting / an attached debugger -- + // same invariant as re-raising on POSIX (see crashhandler.h, + // invariant 3). + return EXCEPTION_CONTINUE_SEARCH; +} + +#endif + +} // namespace + +void CrashHandler::install(const LogRing *ring, const QString &dump_path) +{ + g_ring = ring; + + const QByteArray path_utf8 = dump_path.toUtf8(); + std::strncpy(g_dump_path, path_utf8.constData(), sizeof(g_dump_path) - 1); + + const QByteArray header = QByteArray("QET crash dump\n") + + "Version: " + QetVersion::displayedVersion().toUtf8() + "\n" + + "Git: " GIT_COMMIT_SHA "\n" + + "OS: " + QSysInfo::prettyProductName().toUtf8() + " (" + QSysInfo::currentCpuArchitecture().toUtf8() + ")\n" + + "Qt: " QT_VERSION_STR "\n" + + "---\n"; + g_header_len = qMin(header.size(), static_cast(sizeof(g_header)) - 1); + std::memcpy(g_header, header.constData(), static_cast(g_header_len)); + +#ifdef Q_OS_WIN + SetUnhandledExceptionFilter(windowsExceptionFilter); +#else + stack_t ss; + ss.ss_sp = g_altstack; + ss.ss_size = sizeof(g_altstack); + ss.ss_flags = 0; + sigaltstack(&ss, nullptr); + + struct sigaction sa {}; + sa.sa_handler = signalHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_ONSTACK; + + for (int sig : kHandledSignals) { + sigaction(sig, &sa, nullptr); + } +#endif +} diff --git a/sources/logging/crashhandler.h b/sources/logging/crashhandler.h new file mode 100644 index 000000000..9baf96d62 --- /dev/null +++ b/sources/logging/crashhandler.h @@ -0,0 +1,85 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef CRASHHANDLER_H +#define CRASHHANDLER_H + +#include + +class LogRing; + +/** + @brief The CrashHandler class + Discussion #644, step 4: on a fatal crash, flush the in-memory + LogRing to a fixed file before the process dies, so the last N log + lines leading up to the crash survive it -- today they only exist in + memory and are lost with the process. + + This is the highest-risk piece of the whole logging rework (the + discussion's own words: "lands last, behind its own switch"), so its + invariants are worth restating plainly: + + 1. The handler must never block. It takes no locks -- LogRing itself + is lock-free for exactly this reason (see logring.h). A handler + that can hang is worse than no handler: it turns a clean crash + (which at least produces a core dump) into a hung process that has + to be force-killed, producing neither a core dump nor a ring dump. + 2. The handler must never allocate. Under heap corruption -- a + plausible *cause* of the very crash being handled -- malloc may + itself deadlock or fault. Every buffer this code touches at crash + time (the dump path, the header, the ring's own storage) is + preallocated by install(), which runs once at startup in normal + (non-signal) context. + 3. The handler must not swallow the crash. After writing the dump it + restores the default disposition for the signal and re-raises, so + the OS still produces a core dump (POSIX) / Windows Error + Reporting still sees the exception. A handler that "fixed" the + crash by not re-raising would destroy the post-mortem evidence a + core dump provides. + 4. Only the *first* crash writes a dump. An atomic test-and-set + guards against two threads faulting simultaneously (or the handler + itself faulting while dumping) producing an interleaved or + truncated file; every crash after the first goes straight to + restore-and-re-raise. + + Tested in this environment: POSIX/Linux only (sigaction, sigaltstack, + SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL). The Windows path + (SetUnhandledExceptionFilter) and macOS-specific behaviour (signal + handling itself is POSIX and shares the Linux code path, but sandbox + profiles can affect where the dump file may be written) are + implemented per the discussion's guidance but could not be exercised + here -- there is no Windows or macOS build available in this sandbox. + Please sanity-check both before relying on them in the field. +*/ +class CrashHandler +{ + public: + /// Installs the crash handler. Must be called from normal + /// (non-signal) startup code, after the LogRing it will dump + /// exists, and only once. `ring` must outlive the process (in + /// practice: the LogRing owned by QetLogger's function-local + /// static instance, which is never destroyed before exit). + /// `dump_path` is resolved and copied into a fixed-size internal + /// buffer here; nothing under the actual signal/exception path + /// touches QString. + static void install(const LogRing *ring, const QString &dump_path); + + private: + CrashHandler() = delete; +}; + +#endif // CRASHHANDLER_H diff --git a/sources/logging/logring.cpp b/sources/logging/logring.cpp index 91eb7583c..a367e2527 100644 --- a/sources/logging/logring.cpp +++ b/sources/logging/logring.cpp @@ -17,75 +17,136 @@ */ #include "logring.h" -#include #include -/** - @brief LogRing::LogRing - Preallocates all kCapacityEntries slots up front -- the only - allocation this class ever does. -*/ -LogRing::LogRing() -{ - m_entries.resize(kCapacityEntries); -} +#ifdef Q_OS_WIN +#include +#else +#include +#include +#endif + +static_assert(std::atomic::is_always_lock_free, + "LogRing::Entry::length must be a lock-free atomic -- " + "dumpToFd() reads it from a signal handler and must never block."); + +namespace { /** - @brief LogRing::append - @param line one already-formatted log line (no further formatting - is done here). Truncated to kEntryBytes - 1 bytes if longer, with a - trailing marker, so the stored entry is always a complete, - independently-readable line. + @brief writeAllSignalSafe + Loops until length bytes have been written to fd or an unrecoverable + error occurs. write(2) may write fewer bytes than requested and may + return EINTR -- both are *more* likely from inside a signal handler + than in normal code, so a single write() call is not enough here. + Async-signal-safe: only calls write(2)/errno, nothing else. */ -void LogRing::append(const QByteArray &line) +void writeAllSignalSafe(int fd, const char *data, int length) noexcept +{ + int remaining = length; + const char *p = data; + + while (remaining > 0) { +#ifdef Q_OS_WIN + const int n = _write(fd, p, static_cast(remaining)); + if (n <= 0) { + return; + } +#else + const ssize_t n = ::write(fd, p, static_cast(remaining)); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return; // unrecoverable -- give up silently, never block/throw + } + if (n == 0) { + return; + } +#endif + p += n; + remaining -= static_cast(n); + } +} + +} // namespace + +LogRing::LogRing() : + // The sized constructor value-initialises each Entry in place; unlike + // resize(), it doesn't require Entry to be move/copy-constructible, + // which std::atomic deliberately never is. The only allocation + // this class ever does. + m_entries(kCapacityEntries) +{ +} + +void LogRing::append(const QByteArray &line) noexcept { static const char kMarker[] = "...[ring-truncated]\n"; const int marker_len = static_cast(sizeof(kMarker)) - 1; - QMutexLocker locker(&m_mutex); + const quint64 idx = m_write_cursor.fetch_add(1, std::memory_order_relaxed); + Entry &slot = m_entries[static_cast(idx % static_cast(kCapacityEntries))]; - Entry &slot = m_entries[static_cast(m_next_index)]; + // Zero the length first so a concurrent reader landing on this exact + // slot mid-copy sees "not ready" rather than the previous lap's + // (now-being-overwritten) content at a stale length. + slot.length.store(0, std::memory_order_relaxed); + int len; if (line.size() < kEntryBytes) { std::memcpy(slot.data, line.constData(), static_cast(line.size())); - slot.length = line.size(); + len = line.size(); } else { const int keep = kEntryBytes - marker_len; std::memcpy(slot.data, line.constData(), static_cast(keep)); std::memcpy(slot.data + keep, kMarker, static_cast(marker_len)); - slot.length = kEntryBytes; + len = kEntryBytes; } - m_next_index = (m_next_index + 1) % kCapacityEntries; - if (m_count < kCapacityEntries) { - ++m_count; - } + slot.length.store(len, std::memory_order_release); } -/** - @brief LogRing::snapshot - @return the entries currently held, oldest first. Safe to call from - normal (non-signal) code only. -*/ QVector LogRing::snapshot() const { - QMutexLocker locker(&m_mutex); + const quint64 cursor = m_write_cursor.load(std::memory_order_acquire); + const quint64 cap = static_cast(kCapacityEntries); + const quint64 count = (cursor < cap) ? cursor : cap; + const quint64 start = (cursor < cap) ? 0 : (cursor - cap); QVector result; - result.reserve(m_count); + result.reserve(static_cast(count)); - const int start = (m_count < kCapacityEntries) ? 0 : m_next_index; - for (int i = 0; i < m_count; ++i) { - const Entry &slot = m_entries[static_cast((start + i) % kCapacityEntries)]; - result.append(QByteArray(slot.data, slot.length)); + for (quint64 i = 0; i < count; ++i) { + const Entry &slot = m_entries[static_cast((start + i) % cap)]; + const int len = slot.length.load(std::memory_order_acquire); + if (len > 0) { + result.append(QByteArray(slot.data, len)); + } } return result; } +void LogRing::dumpToFd(int fd) const noexcept +{ + const quint64 cursor = m_write_cursor.load(std::memory_order_acquire); + const quint64 cap = static_cast(kCapacityEntries); + const quint64 count = (cursor < cap) ? cursor : cap; + const quint64 start = (cursor < cap) ? 0 : (cursor - cap); + + for (quint64 i = 0; i < count; ++i) { + const Entry &slot = m_entries[static_cast((start + i) % cap)]; + const int len = slot.length.load(std::memory_order_acquire); + if (len > 0) { + writeAllSignalSafe(fd, slot.data, len); + } + } +} + void LogRing::clear() { - QMutexLocker locker(&m_mutex); - m_next_index = 0; - m_count = 0; + m_write_cursor.store(0, std::memory_order_relaxed); + for (auto &entry : m_entries) { + entry.length.store(0, std::memory_order_relaxed); + } } diff --git a/sources/logging/logring.h b/sources/logging/logring.h index 26f0d492e..809046c32 100644 --- a/sources/logging/logring.h +++ b/sources/logging/logring.h @@ -19,31 +19,33 @@ #define LOGRING_H #include -#include #include +#include #include /** @brief The LogRing class Fixed-capacity, always-on in-memory ring of the most recent log - lines. Discussion #644 (step 3): the ring exists as forward-compatible - infrastructure for a future crash-flush (step 4, not implemented - here) as well as an on-demand "what just happened" snapshot, so its - entries are stored pre-formatted as plain bytes in storage - preallocated once at construction -- append() never allocates. + lines, preallocated once at construction -- append() never + allocates. - Entries are fixed-size slots rather than a byte-packed ring: with - kCapacityEntries * kEntryBytes chosen to land exactly on the 2 MiB - budget, this keeps wraparound trivial (whole-slot overwrite, so a - slot is always either fully the old entry or fully the new one -- - no torn entries) at the cost of truncating any single line to - kEntryBytes, independently of the logger's own (larger) per-message - truncation. + Lock-free by construction, not just "thread-safe": step 4 (see + crashhandler.h) reads this ring from inside a POSIX signal handler, + where taking any lock is unsafe -- if the crashing thread happens to + be the one that already holds it (or any other thread does and never + gets scheduled again), the handler hangs forever, and you lose both + the ring dump *and* the core dump. So there is no mutex here at all: + append() claims a slot with a single atomic fetch-add, and + dumpToFd()/snapshot() read the preallocated entries directly. - Thread-safe via a plain QMutex. This is *not* the lock-free design - discussion #644 specifies for a signal-handler crash path (step 4) - -- no signal handler is installed by this code, so nothing calls - into the ring from inside a signal context. + Accepted tradeoff: if dumpToFd() runs while another thread is + mid-append into the exact slot being read (only possible in the + crash-handler case, and only for at most one slot), that one entry + may be read torn -- part old content, part new. Every other entry is + unaffected. This is deliberate: the alternative (a seqlock or similar + to detect and retry torn reads) adds real complexity for a window + that, per discussion #644, is not worth trading "the handler must + never block" against. */ class LogRing { @@ -54,24 +56,30 @@ class LogRing LogRing(); /// Append one already-formatted, already-truncated log line. - /// Bytes beyond kEntryBytes - 1 are dropped with a truncation marker. - void append(const QByteArray &line); + /// Bytes beyond kEntryBytes are dropped with a truncation marker. + /// Never allocates, never blocks. Safe to call from any normal + /// (non-signal) thread concurrently. + void append(const QByteArray &line) noexcept; - /// Snapshot of the entries currently held, oldest first. + /// Snapshot of the entries currently held, oldest first. Normal + /// (non-signal) context only. QVector snapshot() const; + /// Async-signal-safe: writes every entry currently held to fd via + /// write(2) only -- no allocation, no Qt, no locks. May write a + /// torn entry under the rare race described above; never blocks. + void dumpToFd(int fd) const noexcept; + void clear(); private: struct Entry { - char data[kEntryBytes] = {}; - int length = 0; + char data[kEntryBytes]; + std::atomic length{0}; // 0 = not yet written this lap }; - mutable QMutex m_mutex; std::vector m_entries; // preallocated once, capacity fixed - int m_next_index = 0; - int m_count = 0; + std::atomic m_write_cursor{0}; // monotonically increasing }; #endif // LOGRING_H diff --git a/sources/logging/qetlogger.cpp b/sources/logging/qetlogger.cpp index df4dd6736..88bf4d4d9 100644 --- a/sources/logging/qetlogger.cpp +++ b/sources/logging/qetlogger.cpp @@ -17,11 +17,14 @@ */ #include "qetlogger.h" +#include "crashhandler.h" #include "../qetapp.h" +#include "../qetversion.h" #include #include #include +#include #include namespace { @@ -96,6 +99,24 @@ void QetLogger::init() m_file_output_ok = ensureFileOpenLocked(); } +void QetLogger::installCrashHandler() +{ + if (m_disabled) { + return; + } + CrashHandler::install(&m_ring, crashDumpPath()); +} + +QString QetLogger::crashDumpPath() const +{ + return m_log_dir % QStringLiteral("/crash_dump.log"); +} + +QString QetLogger::currentLogFilePath() const +{ + return m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log"); +} + /** @brief QetLogger::ensureFileOpenLocked Caller must hold m_file_mutex. Opens the current session's log file @@ -110,7 +131,7 @@ bool QetLogger::ensureFileOpenLocked() QDir().mkpath(m_log_dir); - const QString path = m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log"); + const QString path = currentLogFilePath(); const QFileInfo info(path); if (info.exists() && info.isSymLink()) { @@ -143,7 +164,7 @@ void QetLogger::rotateLocked() { m_file.close(); - const QString base_path = m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log"); + const QString base_path = currentLogFilePath(); for (int i = kRotationKeep; i >= 1; --i) { const QString from = (i == 1) ? base_path : rotatedPath(i - 1); @@ -333,3 +354,78 @@ void QetLogger::pruneOldLogFiles(int days) } } } + +// --- Step 5: getting the data back out ---------------------------------- + +bool QetLogger::hasPendingCrashDump() const +{ + if (m_disabled) { + return false; + } + const QFileInfo info(crashDumpPath()); + return info.exists() && info.isFile() && info.size() > 0; +} + +QByteArray QetLogger::pendingCrashDumpContents() const +{ + QFile file(crashDumpPath()); + if (!file.open(QIODevice::ReadOnly)) { + return QByteArray(); + } + return redact(file.readAll()); +} + +void QetLogger::clearPendingCrashDump() +{ + QFile::remove(crashDumpPath()); +} + +QByteArray QetLogger::buildDiagnosticsReport() const +{ + QByteArray header; + header += "QElectroTech diagnostics report\n"; + header += "Generated: " % QDateTime::currentDateTime().toString(Qt::ISODate) % "\n"; + header += "Version: " % QetVersion::displayedVersion() % "\n"; + header += "Git: " GIT_COMMIT_SHA "\n"; + header += "OS: " % QSysInfo::prettyProductName() % " (" % QSysInfo::currentCpuArchitecture() % ")\n"; + header += "Qt: " QT_VERSION_STR "\n"; + header += "---\n"; + + QByteArray body; + QFile file(currentLogFilePath()); + if (file.open(QIODevice::ReadOnly)) { + body = file.readAll(); + } else { + // Fall back to the in-memory ring if the file itself can't be + // read (e.g. file output already failed this session). + for (const QByteArray &line : m_ring.snapshot()) { + body += line; + } + } + + return redact(header + body); +} + +/** + @brief QetLogger::redact + Replaces the user's home directory with "~" wherever it appears. + Applied before a crash dump or a diagnostics report is ever shown to + the user: both are destined to be attached to a public bug tracker, + and an absolute path under the home directory leaks the account name + (discussion #644's privacy section: "/home/laurent/... leaks a + username"). This is the one redaction implemented here; the + discussion's fancier "optionally redact project filenames too" is + not attempted -- reliably telling a project path apart from + arbitrary log text is a much fuzzier problem than a literal prefix + match against a known directory. +*/ +QByteArray QetLogger::redact(const QByteArray &input) +{ + const QByteArray home = QDir::homePath().toUtf8(); + if (home.isEmpty()) { + return input; + } + QByteArray out = input; + out.replace(home, QByteArrayLiteral("~")); + return out; +} diff --git a/sources/logging/qetlogger.h b/sources/logging/qetlogger.h index eb576bec2..a4596096a 100644 --- a/sources/logging/qetlogger.h +++ b/sources/logging/qetlogger.h @@ -48,11 +48,22 @@ - Step 3: every formatted line is also appended to an in-memory LogRing (see logring.h) -- always on, fixed capacity, allocation- free on the hot path. + - Step 4: installCrashHandler() wires the ring up to CrashHandler + (see crashhandler.h), so a SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL (or, + on Windows, an unhandled structured exception) flushes the ring to + a fixed crash-dump file before the process dies. + - Step 5: hasPendingCrashDump()/pendingCrashDumpContents()/ + clearPendingCrashDump() let startup code (see QETApp::checkBackupFiles()) + notice and offer an unretrieved crash dump from the *previous* run; + buildDiagnosticsReport() is the equivalent for a manual "save a + report right now" action on the *current*, still-running session. + Both go through redact() before ever reaching the user, since both + are destined for a public bug tracker. - Deliberately NOT included in this step (see discussion #644): no - signal handler / crash-flush (step 4), no diagnostics export UI - (step 5), no log categories, no session header, no repeat collapsing - or rate limiting. Those are independent, separately-scoped follow-ups. + Deliberately NOT included: log categories, a full session header + beyond what the crash dump/report already carry, repeat collapsing, + rate limiting. Those are listed in discussion #644 under "best + practices worth building in", not part of the numbered steps. Escape hatch: if QET_LOG_DISABLE=1 is set in the environment at init() time, this class does nothing beyond a minimal, independent @@ -73,6 +84,11 @@ class QetLogger /// session's log filename, and opens the file. void init(); + /// Step 4: installs the crash handler (see crashhandler.h). Must + /// be called after init() (the ring and the dump path must exist + /// first) and, like init(), only once. + void installCrashHandler(); + /// The function installed via qInstallMessageHandler() forwards here. void handleMessage(QtMsgType type, const QMessageLogContext &context, const QString &msg); @@ -81,10 +97,37 @@ class QetLogger /// file names. void pruneOldLogFiles(int days); - /// Snapshot of the in-memory ring, oldest first. For future use - /// (e.g. a diagnostics export action) -- not wired to any UI here. + /// Snapshot of the in-memory ring, oldest first. QVector ringSnapshot() const {return m_ring.snapshot();} + // --- Step 5: getting the data back out ------------------------- + + /// True if a previous run's crash handler left an unretrieved + /// dump behind. + bool hasPendingCrashDump() const; + + /// Raw contents of the pending crash dump, or an empty array if + /// there isn't one. Does not delete it -- call + /// clearPendingCrashDump() once it has been offered to the user. + QByteArray pendingCrashDumpContents() const; + + /// Deletes the pending crash dump file. Call after the user has + /// been offered it (whether they chose to save it or not) so it + /// is never offered a second time. + void clearPendingCrashDump(); + + /// Builds a redacted diagnostics bundle from the *current* session + /// (header + this session's log file so far) for the manual + /// "Save report" action -- as opposed to pendingCrashDumpContents(), + /// which is about a *previous*, already-terminated session. + QByteArray buildDiagnosticsReport() const; + + /// Replaces occurrences of the user's home directory with "~". + /// Applied to both the crash dump and buildDiagnosticsReport() + /// before they are ever shown to the user, since both are + /// destined for a public bug tracker. + static QByteArray redact(const QByteArray &input); + private: QetLogger() = default; QetLogger(const QetLogger &) = delete; @@ -93,6 +136,8 @@ class QetLogger void rotateLocked(); void writeToFile(const QByteArray &line, QtMsgType type); QString rotatedPath(int index) const; + QString crashDumpPath() const; + QString currentLogFilePath() const; static QByteArray sanitize(const QByteArray &input); static QByteArray truncateMessage(const QByteArray &input, int max_bytes); diff --git a/sources/logging/ui/diagnosticsreportdialog.cpp b/sources/logging/ui/diagnosticsreportdialog.cpp new file mode 100644 index 000000000..538b289f8 --- /dev/null +++ b/sources/logging/ui/diagnosticsreportdialog.cpp @@ -0,0 +1,91 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "diagnosticsreportdialog.h" + +#include "../../qetmessagebox.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DiagnosticsReportDialog::DiagnosticsReportDialog( + const QString &title, + const QString &intro, + const QByteArray &content, + QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(title); + resize(700, 500); + + auto *layout = new QVBoxLayout(this); + + auto *intro_label = new QLabel(intro, this); + intro_label->setWordWrap(true); + layout->addWidget(intro_label); + + auto *preview = new QPlainTextEdit(this); + preview->setReadOnly(true); + preview->setLineWrapMode(QPlainTextEdit::NoWrap); + preview->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + preview->setPlainText(QString::fromUtf8(content)); + layout->addWidget(preview); + + auto *buttons = new QDialogButtonBox(this); + QPushButton *save_button = buttons->addButton(tr("Enregistrer..."), QDialogButtonBox::ActionRole); + buttons->addButton(QDialogButtonBox::Close); + connect(save_button, &QPushButton::clicked, this, &DiagnosticsReportDialog::saveToFile); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(buttons->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &QDialog::accept); + layout->addWidget(buttons); + + // Stash the content for saveToFile(); the preview widget already + // holds a QString copy but we save the original UTF-8 bytes to avoid + // any round-trip surprises. + setProperty("qet_report_content", content); +} + +void DiagnosticsReportDialog::saveToFile() +{ + const QString path = QFileDialog::getSaveFileName( + this, + tr("Enregistrer le rapport de diagnostic"), + QStringLiteral("qet-diagnostic-report.txt"), + tr("Fichiers texte (*.txt);;Tous les fichiers (*)")); + + if (path.isEmpty()) { + return; + } + + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + QET::QetMessageBox::critical( + this, + tr("Erreur"), + tr("Impossible d'écrire dans le fichier « %1 ».").arg(path)); + return; + } + + file.write(property("qet_report_content").toByteArray()); + file.close(); +} diff --git a/sources/logging/ui/diagnosticsreportdialog.h b/sources/logging/ui/diagnosticsreportdialog.h new file mode 100644 index 000000000..9eaf2ebdd --- /dev/null +++ b/sources/logging/ui/diagnosticsreportdialog.h @@ -0,0 +1,51 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef DIAGNOSTICSREPORTDIALOG_H +#define DIAGNOSTICSREPORTDIALOG_H + +#include + +/** + @brief The DiagnosticsReportDialog class + Discussion #644, step 5: "Show what's in it before saving -- the user + is about to attach this to a public tracker." Used for both the + after-a-crash offer (QETApp::checkBackupFiles()) and the manual + "Help > Diagnostics > Save report" action -- the only difference + between the two is the intro text and where the content comes from + (QetLogger::pendingCrashDumpContents() vs. buildDiagnosticsReport()). + + The content passed in is expected to already be redacted + (QetLogger::redact()) -- this dialog just displays and optionally + saves whatever it's given. +*/ +class DiagnosticsReportDialog : public QDialog +{ + Q_OBJECT + + public: + explicit DiagnosticsReportDialog( + const QString &title, + const QString &intro, + const QByteArray &content, + QWidget *parent = nullptr); + + private slots: + void saveToFile(); +}; + +#endif // DIAGNOSTICSREPORTDIALOG_H diff --git a/sources/main.cpp b/sources/main.cpp index a605f593c..1cce54e8e 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -152,6 +152,12 @@ QGuiApplication::setHighDpiScaleFactorRoundingPolicy(QetSettings::hdpiScaleFacto // went to stderr, which is invisible in a Windows GUI session. QetLogger::instance().init(); qInstallMessageHandler(qetLogMessageHandler); + // Step 4 (discussion #644): flush the ring to a crash-dump file if + // the process dies from here on. Installed right after the ring + // exists (init() just constructed it) and as early as reasonably + // possible, so it also covers whatever runs between here and + // QETApp's own construction below. + QetLogger::instance().installCrashHandler(); SingleApplication app(argc, argv, true); #ifdef Q_OS_MACOS diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 8545f99e7..852eac383 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -40,6 +40,8 @@ #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" +#include "logging/qetlogger.h" +#include "logging/ui/diagnosticsreportdialog.h" #include #include @@ -2575,6 +2577,10 @@ void QETApp::checkBackupFiles() } if (stale_files.isEmpty()) { + // Only offer an unretrieved crash dump when there's no project + // to recover this run -- discussion #644 step 5 is explicit + // that the two prompts must never both show at once. + checkCrashDump(); return; } @@ -2628,6 +2634,53 @@ void QETApp::checkBackupFiles() } } +/** + @brief QETApp::checkCrashDump + Discussion #644, step 5: if the crash handler (step 4) left an + unretrieved dump from a previous run, offer it to the user. Only + called from checkBackupFiles() when there was no stale project file + to recover this run, so the two prompts never both show at once. +*/ +void QETApp::checkCrashDump() +{ + QetLogger &logger = QetLogger::instance(); + if (!logger.hasPendingCrashDump()) { + return; + } + + const QByteArray content = logger.pendingCrashDumpContents(); + + DiagnosticsReportDialog dialog( + tr("Rapport de plantage"), + tr("QElectroTech ne s'est pas fermé correctement lors de sa dernière exécution.\n" + "Voici les derniers messages enregistrés avant l'arrêt -- vous pouvez les " + "enregistrer pour les joindre à un rapport de bug."), + content); + dialog.exec(); + + // Offered once, then marked retrieved -- regardless of whether the + // user chose to save it -- so it is never offered a second time. + logger.clearPendingCrashDump(); +} + +/** + @brief QETApp::showDiagnosticsReport + Discussion #644, step 5: the manual "Help > Diagnostics > Save + report" action. Unlike checkCrashDump(), this is about the *current*, + still-running session, not a previous one. +*/ +void QETApp::showDiagnosticsReport() +{ + const QByteArray content = QetLogger::instance().buildDiagnosticsReport(); + + DiagnosticsReportDialog dialog( + tr("Rapport de diagnostic"), + tr("Ceci contient les derniers messages de journalisation de cette session. " + "Vérifiez le contenu avant de le joindre à un rapport de bug public."), + content); + dialog.exec(); +} + /** @brief QETApp::fetchWindowStats Updates the booleans concerning the state of the windows diff --git a/sources/qetapp.h b/sources/qetapp.h index 512fff961..bc06ff550 100644 --- a/sources/qetapp.h +++ b/sources/qetapp.h @@ -271,6 +271,7 @@ class QETApp : public QObject void openTitleBlockTemplateFiles(const QStringList &); void configureQET(); void aboutQET(); + void showDiagnosticsReport(); void receiveMessage(int instanceId, QByteArray message); private: @@ -287,6 +288,7 @@ class QETApp : public QObject void initSystemTray(); void buildSystemTrayMenu(); void checkBackupFiles(); + void checkCrashDump(); void fetchWindowStats( const QList &, const QList &, diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index 6061e99d8..8635653d2 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -136,6 +136,12 @@ void QETMainWindow::initCommonActions() about_qt_ = new QAction(QET::Icons::QtLogo, tr("À propos de &Qt"), this); about_qt_ -> setStatusTip(tr("Affiche des informations sur la bibliothèque Qt", "status bar tip")); connect(about_qt_, SIGNAL(triggered()), qApp, SLOT(aboutQt())); + + diagnostics_action_ = new QAction(QET::Icons::DialogInformation, tr("Enregistrer un rapport de diagnostic..."), this); + diagnostics_action_ -> setStatusTip(tr("Génère un rapport avec les derniers messages de journalisation, pour l'inclure dans un rapport de bug", "status bar tip")); + connect(diagnostics_action_, &QAction::triggered, this, []() { + QETApp::instance()->showDiagnosticsReport(); + }); } /** @@ -158,6 +164,8 @@ void QETMainWindow::initCommonMenus() help_menu_ -> addAction(donate_); help_menu_ -> addAction(about_qt_); help_menu_ -> addAction(about_qet_); + help_menu_ -> addSeparator(); + help_menu_ -> addAction(diagnostics_action_); #ifdef Q_OS_WIN32 upgrade_ -> setVisible(true); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index b5a2cdf50..8af17deef 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -60,8 +60,9 @@ class QETMainWindow : public QMainWindow { QAction *youtube_; ///< Launch browser on QElectroTech Youtube channel QAction *upgrade_; ///< Launch browser on QElectroTech Windows Nightly builds QAction *upgrade_M; ///< Launch browser on QElectroTech MAC_OS_X builds - QAction *donate_; ///< Launch browser to donate link + QAction *donate_; ///< Launch browser to donate link QAction *about_qt_; ///< launch the "About Qt" dialog + QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5) QMenu *settings_menu_; ///< Settings menu QMenu *help_menu_; ///< Help menu QMenu *display_toolbars_; ///< Show/hide toolbars/docks