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