Compare commits

...

11 Commits

Author SHA1 Message Date
Laurent Trinques 0067ba1dca Merge pull request #905 from ispyisail/harden/crash-reporter
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m1s
Harden the crash reporter: keep every dump, and say what crashed
2026-09-18 04:59:45 +02:00
ispyisail 16220af012 Merge remote-tracking branch 'upstream/master' into harden/crash-reporter 2026-09-18 14:31:54 +12:00
ispyisail f8c1b5206a Address review on #905: offered-list semantics, dump ordering, FreeBSD, legacy dumps
Five things raised in review, plus tests for the parts that were only
described in prose.

clearPendingCrashDump() did not do what its comment said. It called
pendingCrashDumpFiles() again at clear time, so it deleted whatever was
in the directory then, not what had been offered. The offer sits inside a
modal dialog that stays open as long as the user reads it, and
SingleApplication keys its socket on the binary path, so a second
QElectroTech build running alongside is a separate process that can crash
and write a dump in that window. Re-listing deleted that dump unseen --
the exact failure this change exists to fix. The list is now taken once
in QETApp::checkCrashDump() and passed to both
pendingCrashDumpContents() and clearPendingCrashDump().

The ring is now written before the backtrace. backtrace() unwinds through
libgcc, which calls dl_iterate_phdr and takes the loader lock; warming it
in install() removes the allocation but not the lock. Crashing inside
dlopen() (Qt plugin loading), or on a corrupted stack, could therefore
hang or re-fault the handler at the backtrace and lose the ring with it.
Order is now header, signal, ring, backtrace, so the cheapest and most
valuable part is already on disk before anything that can block. The
class comment claimed the handler takes no locks; that was not strictly
true and now says so.

QET_CRASH_BACKTRACE comes from find_package(Backtrace) rather than
__has_include(<execinfo.h>). The header exists on FreeBSD but backtrace()
lives in libexecinfo there, so the probe compiled and the link failed.

A crash_dump.log left by a pre-#905 version is migrated into crashes/ at
startup, named from its own mtime. Otherwise upgrading stranded it: the
new code never looks at that path, so the dump from the crash that
prompted the upgrade would sit there unoffered forever.

Also from the review: dumps are capped at the 10 newest, so a crash loop
cannot fill the log directory before any dialog is shown; crashDumpDir()
no longer creates the directory as a side effect of a const getter
(ensureCrashDumpDir() does that for the callers that write); and redact()
now masks an AppImage's per-run /tmp/.mount_XXXXXX prefix, which
backtrace_symbols_fd() writes into every frame.

Two test executables, both of which were checked to fail against the
behaviour they replace:

- tst_crashhandler covers CrashHandler::formatInt(), which had no
  coverage at all despite running only inside a signal handler, where
  nothing can assert: zero, negatives, INT_MIN (negated through unsigned,
  since -INT_MIN is UB), INT_MAX, truncation and a zero-sized buffer,
  each checked against a sentinel-filled buffer so a write past the
  reported length fails.
- tst_crashdumps covers the bookkeeping: ordering, empty dumps, the
  exclusion of this run's own path, the cap, concatenation of every
  offered dump, that clearing deletes only what was offered, and what
  redact() masks. qetlogger.cpp needs exactly one symbol from the
  application, QETApp::dataDir(), which the test supplies itself.

Not addressed here: the timestamp in crash_<timestamp>_<pid> is the
launch time, not the crash time -- correct as observed, and the commit
message that implied otherwise was the thing that was wrong. Resolvable
QET frames for AppImage/Flatpak/Snap/Debian need -rdynamic and archived
debug symbols, which is a packaging discussion, not this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:31:49 +12:00
Laurent Trinques 4d800de26a Merge pull request #908 from ispyisail/fix/904-quit-during-modal
Refuse to close an editor while a modal dialog is running
2026-09-18 04:15:03 +02:00
ispyisail d3c8544fd9 Stop the quit-during-modal test taking its full watchdog period every run
The test bounded gdb with a backgrounded watchdog:

    ( sleep 120; kill -9 "$GDB_PID" 2>/dev/null ) &
    WATCHDOG_PID=$!
    wait "$GDB_PID"
    kill "$WATCHDOG_PID" 2>/dev/null

sleep runs as a child of the subshell, so killing the subshell leaves the
sleep orphaned -- and the orphan still holds the write end of whatever this
script's stdout is. Run from a terminal that costs nothing. Run through a
pipe, which is how CTest invokes it, the reader sees no EOF until the sleep
expires, so every run lasted the full 120 seconds regardless of how fast
gdb finished. gdb itself takes ten.

That made modal_quit_regression 92% of the runtime of the entire ctest
suite (122 s of 133 s) and left it 60 s short of its own 180 s CTest
timeout -- near enough that a loaded CI machine could have turned it into
a flaky failure in somebody else's build.

Use timeout(1) instead, which leaves nothing behind, with a plain gdb call
as a fallback where it is unavailable. Suite time drops to 12 s.

Verified on the merged branch: fixed build passes both the .qet and the
read-only .elmt scenario, and with the fix commit reverted both still fail
with signal 6, under ~QETDiagramEditor() and ~QETElementEditor()
respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:53:16 +12:00
ispyisail eea939c31c Merge remote-tracking branch 'upstream/master' into fix/904-quit-during-modal 2026-09-18 13:34:58 +12:00
ispyisail 599228fe6e Register the quit-during-modal test with CTest, and fix two bugs in it
The test added with the #904 fix was run by hand. Register it so it runs
with the rest of the suite, and fix two defects found while extending it
to the element editor -- both of which made it report success it had not
earned.

nm -C "$BINARY" | grep -q <sym> under `set -o pipefail`: grep exits at the
first match, nm dies of SIGPIPE, and the pipeline reports failure. The
symbol check therefore skipped the test on every build that could actually
run it. Read nm's output into a variable once and match with `case`.

The input file was copied to a hardcoded "$SANDBOX/project.qet".
QElectroTech picks the editor from the extension, so passing a .elmt gave
a failed project load in the diagram editor rather than an element editor
-- the run still found a dialog, still called quitQET(), and still
reported a result, just for the wrong window. Preserve the basename.

With that fixed, the element-editor path is exercised on its own: a
read-only .elmt opens a message box, and on a build without the fix the
run aborts with "double free or corruption" under ~QETElementEditor(),
where before it named ~QETDiagramEditor(). The guard QETElementEditor
calls from its closeEvent() covers a real crash, not a theoretical one.

Registered on Linux only, and it reports 77 -- CTest's SKIP_RETURN_CODE --
when it cannot run at all: no gdb, a gdb built without Python, or a
stripped binary whose QETApp symbols it cannot call. A release build that
this test cannot drive is not a failing build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:34:45 +12:00
Laurent Trinques 3ff02e528d Merge pull request #912 from Kellermorph/fix-checkbox-pdf-export
Fix PDF export checkbox state not being remembered
2026-09-18 01:49:39 +02:00
Kellermorph 520f4245b3 Fix PDF export checkbox state not being remembered
- Save and restore the state of m_component_info_cb, m_fit_in_page_cb,
  and m_use_full_page_cb via QSettings, in addition to the existing
  ExportProperties-based checkboxes (border, titleblock, terminals, etc.)
- Add savePrintProperties() to persist all checkbox states after
  print/export under the "print/default" settings prefix
- Remove unused QPrinter(HighResolution) local variable in launchDialog()
  that caused an unnecessary ~2s CUPS round-trip on Linux
2026-09-17 21:33:05 +02:00
ispyisail 8623dd4c6f Refuse to close an editor while a modal dialog is running (#904)
openAndAddProject() shows BackupDialog as a stack object parented to the
editor and exec()s it; every QET::QetMessageBox does the same. exec() runs a
nested event loop, and closing the editor during it turns WA_DeleteOnClose
into a deleteLater() that the nested loop processes: ~QWidget() deletes the
editor's children, the stack-allocated dialog among them, and the process
aborts. Reported on macOS, where File > Quit lives in the application menu
and stays usable while the backup question is up.

The close is now refused while any modal widget is active, and the dialog is
raised so the refused quit is not silent. It is done in QETMainWindow::event()
rather than in closeEvent(), because QETDiagramEditor::closeEvent() starts
closing projects before it decides whether to accept. That covers the diagram
and title-block editors; QETElementEditor is a plain QMainWindow, so its
closeEvent() calls the same helper before canClose(), which itself opens a
modal. QETApp::quitQET() needs nothing: closeEveryEditor() goes through each
editor's close(), and quitQET() already only quits when every close succeeded.

Rejected alternatives, both suggested on the issue:

- Giving the dialog no parent stops the abort but not the deletion. One
  caller of openAndAddProject() is the editor's own constructor, which goes
  on to open the next file and call slot_updateActions() on this -- a loud
  abort would become a silent use-after-free.
- Guarding only QETApp::closeEveryEditor(), which I first recommended on the
  issue, misses the reported route entirely: File > Quit is connected to
  QETDiagramEditor::close(), not to quitQET().

Verified on Linux, where there is nothing to click (the menu bar belongs to
the blocked window, and Qt ignores window-manager close requests for it), by
calling close() from gdb while the dialog's loop was running -- both
QETApp::quitQET() and QWidget::close() on the editor. Unfixed, both abort
with "free(): invalid size" in QObjectPrivate::deleteChildren() under
~QETDiagramEditor(), matching the report frame for frame; fixed, close()
returns false, the editor and the dialog stay up, and after answering the
dialog Ctrl+Q exits normally. The element-editor guard is the same helper
but was not exercised separately.

tests/modal-quit-regression/ turns that into a gate: it breaks on
QDialog::exec(), interrupts inside the nested loop, calls quitQET() and
checks the process survives. It matches no window titles (translated) and no
window ids, runs on the offscreen platform, and needs only gdb with Python.
Checked both ways: exit 1 with the backtrace above on a build without this
change, exit 0 with it.

ctest 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:19:59 +12:00
ispyisail 0646f9ca4f Harden the crash reporter: keep every dump, and say what crashed
Three weaknesses, all visible in ChuckNr11's report on #898 -- "the report
appeared only once despite there being 10 or more crashes".

One dump per run instead of one per install
-------------------------------------------
crashDumpPath() was a single fixed crash_dump.log, and the handler opens it
O_TRUNC, so each crash destroyed the evidence from the one before. Ten
crashes left one dump. Dumps now go to a crashes/ directory named
crash_<timestamp>_<pid>.log, and every pending one is offered together,
newest first, with a banner saying how many there are. A crash that repeats
is exactly the case where the earlier dumps matter, because the difference
between them is the evidence.

The name is built in normal context and handed to CrashHandler::install(),
which copies it into a preallocated buffer as before -- the handler still
writes to one fixed path, so its no-allocation invariant is untouched.

The dump now says which signal fired
------------------------------------
The header is built once at install(), so every dump looked identical no
matter what killed the process -- and SIGSEGV and SIGABRT point at very
different bugs. Written with an async-signal-safe integer formatter into a
stack buffer, since snprintf is not on the POSIX safe list.

...and where it was
-------------------
The ring said what the program was doing; nothing said where it died. The
dump now carries a backtrace. backtrace() is warmed once in install() so
its first-call lazy resolution cannot allocate inside the handler, and
backtrace_symbols_fd() writes straight to the fd -- unlike
backtrace_symbols(), which mallocs and must never be used here. Guarded on
__has_include(<execinfo.h>) so platforms without it are unaffected.

QET's own frames currently resolve as offsets rather than names, since the
binary does not export its dynamic symbols. They are still resolvable
offline: the header records the exact git SHA. Building with -rdynamic
would give names directly, but that is a build-flag decision for its own
change.

Deliberately unchanged: the four invariants in crashhandler.h. Nothing
added here allocates, blocks, takes a lock, or swallows the crash.

Verified: three consecutive SIGSEGVs now leave three separate dumps, each
carrying "Signal: 11" and a backtrace with resolved Qt frames; launching
afterwards offers all three in one dialog, newest first, and clears them
once shown. ctest 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:44:59 +12:00
18 changed files with 1300 additions and 40 deletions
+15
View File
@@ -101,6 +101,21 @@ else()
message(STATUS "Qt Qml module not available: JavaScript scripting (--run) disabled")
endif()
# The crash handler writes a backtrace into the dump. Detecting this with
# __has_include(<execinfo.h>) is not enough: the header is present on FreeBSD
# too, but backtrace() lives in a separate libexecinfo there, so the compile
# succeeds and the link fails. FindBacktrace resolves both the header and
# whichever library actually provides the symbol, so gate on it instead - see
# the QET_CRASH_BACKTRACE guard in sources/logging/crashhandler.cpp.
find_package(Backtrace QUIET)
if(Backtrace_FOUND)
list(APPEND QET_PRIVATE_LIBRARIES ${Backtrace_LIBRARIES})
include_directories(${Backtrace_INCLUDE_DIRS})
add_compile_definitions(QET_CRASH_BACKTRACE)
else()
message(STATUS "backtrace() not available: crash dumps will carry the log ring without a backtrace")
endif()
find_package(SQLite3 REQUIRED)
# CMake < 4.3 only creates the SQLite::SQLite3 target (no SQLite3::SQLite3
+8
View File
@@ -23,6 +23,7 @@
#include "../elementview.h"
#include "../../qetmessagebox.h"
#include "../../qetapp.h"
#include "../../qetmainwindow.h"
#include "../../recentfiles.h"
#include "../graphicspart/customelementpart.h"
#include "../elementitemeditor.h"
@@ -905,6 +906,13 @@ void QETElementEditor::openElement(const QString &filepath)
*/
void QETElementEditor::closeEvent(QCloseEvent *qce)
{
//This editor is a plain QMainWindow, not a QETMainWindow, so the
//guard QETMainWindow::event() applies to the other editors is
//applied here instead -- before canClose(), which itself opens a
//modal dialog.
if (QETMainWindow::refuseCloseWhileModal(qce)) {
return;
}
if (canClose()) {
writeSettings();
setAttribute(Qt::WA_DeleteOnClose);
+101 -2
View File
@@ -36,6 +36,13 @@
#include <csignal>
#include <fcntl.h>
#include <unistd.h>
// QET_CRASH_BACKTRACE is defined by CMake, via find_package(Backtrace),
// not by probing for the header here. <execinfo.h> exists on FreeBSD as
// well, but backtrace() is in a separate libexecinfo there, so a header
// probe compiles and then fails to link.
#ifdef QET_CRASH_BACKTRACE
#include <execinfo.h>
#endif
#endif
namespace {
@@ -64,6 +71,14 @@ char g_altstack[65536];
const int kHandledSignals[] = {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL};
#ifdef QET_CRASH_BACKTRACE
// Preallocated here for the same reason as everything else in this block:
// backtrace() fills a caller-supplied array, so it needs no heap of its
// own, and backtrace_symbols_fd() writes straight to the fd (unlike
// backtrace_symbols(), which mallocs and is therefore unusable here).
void *g_backtrace_frames[64];
#endif
void restoreDefaultAndReraise(int sig)
{
struct sigaction sa {};
@@ -85,16 +100,63 @@ void signalHandler(int sig)
return;
}
// open/write/close are all on the POSIX async-signal-safe function
// list; nothing else is called here.
// open/write/close, and backtrace_symbols_fd, are all on the POSIX
// async-signal-safe function list; nothing else is called here.
//
// Async-signal-safe is not the same as lock-free, which is why the
// order below matters. backtrace() unwinds through libgcc, which calls
// dl_iterate_phdr and takes the loader lock. Warming it in install()
// removes the allocation, not the lock -- so a crash that happens
// inside dlopen() (Qt plugin loading), or on a corrupted heap or
// stack, can leave this handler deadlocked or faulting a second time
// at the backtrace. Everything cheaper and more valuable is therefore
// written and flushed first: header, signal, then the log ring. If the
// backtrace never completes, the dump is still there and still useful.
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<size_t>(g_header_len));
}
// Which signal killed it. The header is built once at install()
// and is therefore identical for every crash, so without this the
// dump never said what actually happened -- SIGSEGV and SIGABRT
// point at very different bugs.
char line[64];
int len = 0;
const char kSignalLabel[] = "Signal: ";
for (unsigned i = 0 ; i < sizeof(kSignalLabel) - 1 ; ++i) {
line[len++] = kSignalLabel[i];
}
len += CrashHandler::formatInt(line + len, static_cast<int>(sizeof(line)) - len - 1, sig);
line[len++] = '\n';
::write(fd, line, static_cast<size_t>(len));
// The ring first: it is the part that says what the program was
// doing, it costs one write, and it takes no lock.
const char kRingLabel[] = "--- log ---\n";
::write(fd, kRingLabel, sizeof(kRingLabel) - 1);
if (g_ring) {
g_ring->dumpToFd(fd);
}
#ifdef QET_CRASH_BACKTRACE
// Then where it was when it died. Last, deliberately: see the
// note above about the loader lock. backtrace() is warmed in
// install() so its first-call lazy resolution cannot allocate
// here, and backtrace_symbols_fd() writes to the fd without
// allocating -- unlike backtrace_symbols(), which mallocs and
// must not be used.
const char kBacktraceLabel[] = "--- backtrace ---\n";
::write(fd, kBacktraceLabel, sizeof(kBacktraceLabel) - 1);
const int frames = ::backtrace(g_backtrace_frames,
static_cast<int>(sizeof(g_backtrace_frames)
/ sizeof(g_backtrace_frames[0])));
if (frames > 0) {
::backtrace_symbols_fd(g_backtrace_frames, frames, fd);
}
#endif
::close(fd);
}
@@ -134,6 +196,34 @@ LONG WINAPI windowsExceptionFilter(EXCEPTION_POINTERS *)
} // namespace
// Async-signal-safe decimal formatting: write() takes a buffer, and there
// is no snprintf on the POSIX async-signal-safe list. Writes into a
// caller-owned buffer (stack, not heap) and returns the length used.
//
// Defined as CrashHandler::formatInt rather than a file-local helper only
// so tst_crashhandler can reach it; it is not called anywhere else.
int CrashHandler::formatInt(char *buffer, int size, int value)
{
if (size <= 0) return 0;
if (value == 0) {
buffer[0] = '0';
return 1;
}
char scratch[16];
int n = 0;
bool negative = value < 0;
unsigned int v = negative ? static_cast<unsigned int>(-(value + 1)) + 1u
: static_cast<unsigned int>(value);
while (v > 0 && n < static_cast<int>(sizeof(scratch))) {
scratch[n++] = static_cast<char>('0' + (v % 10));
v /= 10;
}
int len = 0;
if (negative && len < size) buffer[len++] = '-';
while (n > 0 && len < size) buffer[len++] = scratch[--n];
return len;
}
void CrashHandler::install(const LogRing *ring, const QString &dump_path)
{
g_ring = ring;
@@ -159,6 +249,15 @@ void CrashHandler::install(const LogRing *ring, const QString &dump_path)
ss.ss_flags = 0;
sigaltstack(&ss, nullptr);
#ifdef QET_CRASH_BACKTRACE
// Warm the unwinder. backtrace()'s *first* call resolves dynamic
// linker state and may allocate; every call after that does not. Doing
// it here, in normal context, is what lets the handler call it without
// breaking invariant 2. The result is deliberately discarded.
void *warmup[4];
(void) ::backtrace(warmup, 4);
#endif
struct sigaction sa {};
sa.sa_handler = signalHandler;
sigemptyset(&sa.sa_mask);
+22 -5
View File
@@ -33,11 +33,15 @@ class LogRing;
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.
1. The handler must never block. It takes no locks of its own --
LogRing 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. The one exception is deliberate and comes last:
backtrace() unwinds through libgcc, which takes the loader lock,
so it is written after the ring rather than before it. A crash
inside dlopen() then costs the backtrace, not the whole 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
@@ -78,6 +82,19 @@ class CrashHandler
/// touches QString.
static void install(const LogRing *ring, const QString &dump_path);
/// Writes `value` as decimal into `buffer`, at most `size`
/// bytes, and returns how many were written. The handler
/// needs this because write() takes a buffer and snprintf()
/// is not on the async-signal-safe list; `buffer` is caller-
/// owned (the handler's stack), so nothing is allocated.
/// Truncates rather than overflowing when `size` is too
/// small, and writes nothing for `size <= 0`.
///
/// Public only so tests can reach it -- see
/// tests/qttest/tst_crashhandler.cpp. Nothing else in the
/// application calls it.
static int formatInt(char *buffer, int size, int value);
private:
CrashHandler() = delete;
};
+236 -16
View File
@@ -21,10 +21,12 @@
#include "../qetapp.h"
#include "../qetversion.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QSysInfo>
#include <cctype>
#include <cstdio>
namespace {
@@ -104,12 +106,166 @@ void QetLogger::installCrashHandler()
if (m_disabled) {
return;
}
CrashHandler::install(&m_ring, crashDumpPath());
//Both run here, in normal startup context, before this run's own
//dump path is fixed: a dump left by a pre-#905 version is moved
//in so it can still be offered, and any backlog is trimmed.
migrateLegacyCrashDump();
pruneCrashDumps();
//Fixed for the life of the process: the handler copies it into a
//preallocated buffer, and pendingCrashDumpFiles() needs to know
//which file is this run's own so it doesn't offer it back.
m_crash_dump_path = buildCrashDumpPath();
CrashHandler::install(&m_ring, m_crash_dump_path);
}
QString QetLogger::crashDumpPath() const
/**
@brief QetLogger::crashDumpDir
@return the directory holding crash dumps.
A directory rather than a single file, because dumps are per-run and
several can be waiting at once. Creates nothing: a getter that made a
directory as a side effect surprised a reviewer on #905, and the
readers here (listing, pruning) have no business creating it.
*/
QString QetLogger::crashDumpDir() const
{
return m_log_dir % QStringLiteral("/crash_dump.log");
return m_log_dir % QStringLiteral("/crashes");
}
/**
@brief QetLogger::ensureCrashDumpDir
@return crashDumpDir(), created if missing.
For the callers that are about to write into it.
*/
QString QetLogger::ensureCrashDumpDir() const
{
const QString dir = crashDumpDir();
QDir().mkpath(dir);
return dir;
}
/**
@brief QetLogger::migrateLegacyCrashDump
Before #905 the handler wrote to a single m_log_dir/crash_dump.log.
After upgrading, nothing looks at that path any more: the dump of the
crash that quite possibly prompted the upgrade would sit there unseen
and undeleted forever. Move it into crashes/ under a name the
crash_*.log filter matches, so it is offered exactly once like any
other. Named from its own mtime, so it sorts by when it was written
rather than when it was moved.
*/
void QetLogger::migrateLegacyCrashDump() const
{
const QFileInfo legacy(m_log_dir % QStringLiteral("/crash_dump.log"));
if (!legacy.exists() || !legacy.isFile() || legacy.size() <= 0) {
return;
}
const QString target = ensureCrashDumpDir()
% QStringLiteral("/crash_")
% legacy.lastModified().toString(QStringLiteral("yyyyMMdd-hhmmss"))
% QStringLiteral("_legacy.log");
if (QFile::exists(target)) {
//Migrated already by an earlier run of this version.
QFile::remove(legacy.absoluteFilePath());
return;
}
QFile::rename(legacy.absoluteFilePath(), target);
}
/**
@brief QetLogger::pruneCrashDumps
A crash that repeats on startup would otherwise write one dump per
attempt without limit, since nothing is deleted until a dialog is
actually shown and answered. Keep the newest kMaxPendingCrashDumps --
enough to see a pattern, bounded however long the loop runs.
*/
void QetLogger::pruneCrashDumps() const
{
QDir dir(crashDumpDir());
if (!dir.exists()) {
return;
}
dir.setNameFilters({QStringLiteral("crash_*.log")});
dir.setFilter(QDir::Files);
dir.setSorting(QDir::Time);
const QFileInfoList entries = dir.entryInfoList();
for (int i = kMaxPendingCrashDumps ; i < entries.size() ; ++i) {
QFile::remove(entries.at(i).absoluteFilePath());
}
}
/**
@brief QetLogger::buildCrashDumpPath
@return where this run would write a crash dump.
One file per run, rather than a single fixed crash_dump.log. That old
scheme opened one path with O_TRUNC, so a second crash overwrote the
first: someone who crashed ten times still ended up with exactly one
dump, the most recent. Reported on #898 -- "the report appeared only
once despite there being 10 or more crashes" -- where losing the
earlier dumps mattered as much as never being shown them.
Built here in normal context and handed to CrashHandler::install(),
which copies it into a preallocated buffer, so the handler still
writes to one fixed path and its no-allocation invariant is untouched.
*/
QString QetLogger::buildCrashDumpPath() const
{
return ensureCrashDumpDir()
% QStringLiteral("/crash_")
% QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss"))
% QStringLiteral("_")
% QString::number(QCoreApplication::applicationPid())
% QStringLiteral(".log");
}
/**
@brief QetLogger::pendingCrashDumpFiles
@return dumps left by previous runs, newest first, at most
kMaxPendingCrashDumps of them.
This run's own path is excluded: it does not exist yet unless this run
is itself crashing, and a handler mid-crash is in no position to be
offered a dialog.
Callers take this list once and pass it on to
pendingCrashDumpContents() and clearPendingCrashDump(), rather than
each of those re-reading the directory. See clearPendingCrashDump().
*/
QStringList QetLogger::pendingCrashDumpFiles() const
{
QDir dir(crashDumpDir());
if (!dir.exists()) {
return QStringList();
}
dir.setNameFilters({QStringLiteral("crash_*.log")});
dir.setFilter(QDir::Files);
dir.setSorting(QDir::Time);
QStringList files;
const QFileInfoList entries = dir.entryInfoList();
for (const QFileInfo &info : entries)
{
if (info.size() <= 0) {
continue;
}
if (!m_crash_dump_path.isEmpty()
&& info.absoluteFilePath() == QFileInfo(m_crash_dump_path).absoluteFilePath()) {
continue;
}
files << info.absoluteFilePath();
if (files.size() >= kMaxPendingCrashDumps) {
break;
}
}
return files;
}
QString QetLogger::currentLogFilePath() const
@@ -362,22 +518,67 @@ bool QetLogger::hasPendingCrashDump() const
if (m_disabled) {
return false;
}
const QFileInfo info(crashDumpPath());
return info.exists() && info.isFile() && info.size() > 0;
return !pendingCrashDumpFiles().isEmpty();
}
QByteArray QetLogger::pendingCrashDumpContents() const
/**
@brief QetLogger::pendingCrashDumpContents
@param files the list from pendingCrashDumpFiles()
@return those dumps, in the order given, concatenated.
All of them rather than only the latest: a crash that repeats is the
case where the earlier dumps are most worth having, since the
difference between them is the evidence. They are separated by a
banner so a reader can tell where one ends and the next begins, and
the whole thing is redacted as a single pass.
*/
QByteArray QetLogger::pendingCrashDumpContents(const QStringList &files) const
{
QFile file(crashDumpPath());
if (!file.open(QIODevice::ReadOnly)) {
if (files.isEmpty()) {
return QByteArray();
}
return redact(file.readAll());
QByteArray all;
if (files.size() > 1) {
all += QByteArray("QET: ") + QByteArray::number(files.size())
+ " crash dumps pending, newest first.\n\n";
}
for (const QString &path : files)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
continue;
}
all += "===== " + QFileInfo(path).fileName().toUtf8() + " =====\n";
all += file.readAll();
if (!all.endsWith('\n')) {
all += '\n';
}
all += '\n';
}
return redact(all);
}
void QetLogger::clearPendingCrashDump()
/**
@brief QetLogger::clearPendingCrashDump
@param files exactly the dumps that were offered
Deletes the list it is given rather than re-reading the directory.
The offer sits inside a modal dialog that can stay open for as long
as the user cares to read it, and dumps are per-run: a second
QElectroTech -- SingleApplication keys its socket on the binary path,
so a different build is a separate instance -- can crash and write a
new dump while that dialog is up. Re-listing at this point would
delete that fresh dump without anyone ever having seen it, which is
the failure this whole change is about.
*/
void QetLogger::clearPendingCrashDump(const QStringList &files)
{
QFile::remove(crashDumpPath());
for (const QString &path : files) {
QFile::remove(path);
}
}
QByteArray QetLogger::buildDiagnosticsReport() const
@@ -421,11 +622,30 @@ QByteArray QetLogger::buildDiagnosticsReport() const
*/
QByteArray QetLogger::redact(const QByteArray &input)
{
const QByteArray home = QDir::homePath().toUtf8();
if (home.isEmpty()) {
return input;
}
QByteArray out = input;
out.replace(home, QByteArrayLiteral("~"));
const QByteArray home = QDir::homePath().toUtf8();
if (!home.isEmpty()) {
out.replace(home, QByteArrayLiteral("~"));
}
//backtrace_symbols_fd() writes the absolute path of each module,
//which for an AppImage is the per-run mount point
///tmp/.mount_QElectXXXXXX. Not identifying on its own, but it is
//noise in a bug report and it is a path the user never typed, so
//fold it to a stable name. Done after the home replacement above
//because the mount point is not under $HOME.
const QByteArray mount_prefix("/tmp/.mount_");
int at = out.indexOf(mount_prefix);
while (at >= 0)
{
int end = at + mount_prefix.size();
while (end < out.size() && out.at(end) != '/' && !isspace(static_cast<unsigned char>(out.at(end)))) {
++end;
}
out.replace(at, end - at, QByteArrayLiteral("<appimage>"));
at = out.indexOf(mount_prefix, at + 10);
}
return out;
}
+41 -13
View File
@@ -76,6 +76,7 @@ class QetLogger
static constexpr qint64 kMaxFileBytes = 2 * 1024 * 1024; // 2 MiB per file
static constexpr int kRotationKeep = 4; // .1.log .. .4.log
static constexpr int kMaxMessageBytes = 4096; // per-message truncation
static constexpr int kMaxPendingCrashDumps = 10; // newest kept, rest pruned
static QetLogger &instance();
@@ -106,15 +107,24 @@ class QetLogger
/// 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;
/// Dumps left by previous runs, newest first, capped at
/// kMaxPendingCrashDumps. This run's own dump path is never
/// included. Take this list once and pass the same list to
/// pendingCrashDumpContents() and clearPendingCrashDump(): that
/// is what makes "only what was offered gets deleted" true,
/// rather than re-reading the directory at each step and
/// deleting a dump that arrived in between unseen.
QStringList pendingCrashDumpFiles() 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();
/// Raw contents of `files`, newest first, concatenated and
/// redacted. Deletes nothing -- pass the same list to
/// clearPendingCrashDump() once it has been offered.
QByteArray pendingCrashDumpContents(const QStringList &files) const;
/// Deletes exactly `files`, nothing else. Call after the user
/// has been offered them (whether they chose to save them or
/// not) so they are never offered a second time.
void clearPendingCrashDump(const QStringList &files);
/// Builds a redacted diagnostics bundle from the *current* session
/// (header + this session's log file so far) for the manual
@@ -122,10 +132,13 @@ class QetLogger
/// 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.
/// Replaces occurrences of the user's home directory with "~",
/// and an AppImage's per-run /tmp/.mount_XXXXXX prefix with
/// "<appimage>" -- the latter because backtrace_symbols_fd()
/// writes absolute module paths into the dump. 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:
@@ -136,7 +149,18 @@ class QetLogger
void rotateLocked();
void writeToFile(const QByteArray &line, QtMsgType type);
QString rotatedPath(int index) const;
QString crashDumpPath() const;
/// Pure path getter: creates nothing. Callers that are about
/// to write there call ensureCrashDumpDir() instead.
QString crashDumpDir() const;
QString ensureCrashDumpDir() const;
QString buildCrashDumpPath() const;
/// Moves a crash_dump.log left by a pre-#905 version into
/// crashes/, so upgrading does not strand it unoffered.
void migrateLegacyCrashDump() const;
/// Keeps the newest kMaxPendingCrashDumps dumps and deletes
/// the rest, so a crash loop cannot fill the log directory
/// before anyone gets the chance to see a dialog.
void pruneCrashDumps() const;
QString currentLogFilePath() const;
static QByteArray sanitize(const QByteArray &input);
@@ -147,6 +171,10 @@ class QetLogger
QString m_log_dir;
QString m_base_name; // e.g. "20260803", resolved once in init()
/// This run's own dump path, fixed at installCrashHandler():
/// the handler writes here, and it is excluded when collecting
/// dumps left by previous runs.
QString m_crash_dump_path;
QMutex m_file_mutex;
QFile m_file;
+16 -1
View File
@@ -56,7 +56,6 @@
void ProjectPrintWindow::launchDialog(QETProject *project, QPrinter::OutputFormat format, QWidget *parent)
{
auto printer_ = new QPrinter();
QPrinter printer(QPrinter::HighResolution);
printer_->setDocName(ProjectPrintWindow::docName(project));
printer_->setPageOrientation(QPageLayout::Landscape);
@@ -150,6 +149,11 @@ ProjectPrintWindow::ProjectPrintWindow(QETProject *project, QPrinter *printer, Q
ui->m_draw_terminal_names_cb->setChecked(exp.draw_terminal_names);
ui->m_keep_conductor_color_cb->setChecked(exp.draw_colored_conductors);
QSettings settings;
ui->m_component_info_cb->setChecked(settings.value("print/default/componentinfo", false).toBool());
ui->m_fit_in_page_cb->setChecked(settings.value("print/default/fitinpage", true).toBool());
ui->m_use_full_page_cb->setChecked(settings.value("print/default/fullpage", false).toBool());
ui->m_date_cb->blockSignals(true);
ui->m_date_cb->setDate(QDate::currentDate());
ui->m_date_cb->blockSignals(false);
@@ -657,6 +661,16 @@ void ProjectPrintWindow::loadPageSetupForCurrentPrinter()
settings.endGroup();
}
void ProjectPrintWindow::savePrintProperties()
{
QSettings settings;
exportProperties().toSettings(settings, "print/default");
settings.setValue("print/default/componentinfo", ui->m_component_info_cb->isChecked());
settings.setValue("print/default/fitinpage", ui->m_fit_in_page_cb->isChecked());
settings.setValue("print/default/fullpage", ui->m_use_full_page_cb->isChecked());
settings.sync();
}
void ProjectPrintWindow::savePageSetupForCurrentPrinter()
{
QSettings settings;
@@ -842,6 +856,7 @@ void ProjectPrintWindow::print()
// is created/destroyed inside that call
savePageSetupForCurrentPrinter();
savePrintProperties();
if (isPdf && !pdfFile.isEmpty()) {
// Defer post-processing and window close to the next event-loop
+1
View File
@@ -91,6 +91,7 @@ class ProjectPrintWindow : public QMainWindow
void setUpDiagramList();
QString settingsSectionName(const QPrinter *printer);
void loadPageSetupForCurrentPrinter();
void savePrintProperties();
void savePageSetupForCurrentPrinter();
void saveReloadDiagramParameters(Diagram *diagram, const ExportProperties &options, bool save);
QList<Diagram *> selectedDiagram() const;
+8 -3
View File
@@ -2759,11 +2759,16 @@ void QETApp::offerBackupFiles(const QList<KAutoSaveFile *> &stale_files)
void QETApp::checkCrashDump()
{
QetLogger &logger = QetLogger::instance();
if (!logger.hasPendingCrashDump()) {
// Listed once, then used both to build the contents and to delete
// below. Re-listing after the dialog closes would delete a dump
// written while it was open, unseen -- see clearPendingCrashDump().
const QStringList offered = logger.pendingCrashDumpFiles();
if (offered.isEmpty()) {
return;
}
const QByteArray content = logger.pendingCrashDumpContents();
const QByteArray content = logger.pendingCrashDumpContents(offered);
DiagnosticsReportDialog dialog(
tr("Rapport de plantage"),
@@ -2775,7 +2780,7 @@ void QETApp::checkCrashDump()
// Offered once, then marked retrieved -- regardless of whether the
// user chose to save it -- so it is never offered a second time.
logger.clearPendingCrashDump();
logger.clearPendingCrashDump(offered);
}
/**
+42
View File
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include <QAction>
#include <QApplication>
#include <QWhatsThis>
#include <QMenu>
#include <QMenuBar>
@@ -290,6 +291,9 @@ void QETMainWindow::activateMenuBar() {
}
bool QETMainWindow::event(QEvent *e) {
if (e -> type() == QEvent::Close && refuseCloseWhileModal(e)) {
return(true);
}
if (e -> type() == QEvent::WindowStateChange) {
updateFullScreenAction();
} else if (first_activation_ && e -> type() == QEvent::WindowActivate) {
@@ -299,6 +303,44 @@ bool QETMainWindow::event(QEvent *e) {
return(QMainWindow::event(e));
}
/**
@brief QETMainWindow::refuseCloseWhileModal
Refuse to close an editor window while any modal dialog is running.
A modal dialog's exec() runs a nested event loop. If a window is closed
during it, the window's WA_DeleteOnClose turns into a deleteLater() that
the *nested* loop processes: the window is destroyed while code that
belongs to it -- often the very function that opened the dialog -- is
still on the stack. Most of QET's dialogs are stack objects parented to
the window (BackupDialog, and every QET::QetMessageBox), so ~QWidget()
then deletes a stack object and the process aborts (issue #904). Even a
dialog without a parent would only trade that abort for a silent
use-after-free in the caller.
Qt already ignores window-manager close requests for a window blocked by
a modal, so this is only reachable through close() called directly: the
File > Quit action, which macOS moves into the application menu where it
stays usable during a modal, and QETApp::quitQET() from the system tray.
Handled in event(), before closeEvent() runs, because the editors'
closeEvent() starts closing projects before it decides whether to accept.
The dialog is raised so a refused quit is not silent.
@param e : the QEvent::Close being delivered
@return true if the close was refused and must not be processed further
*/
bool QETMainWindow::refuseCloseWhileModal(QEvent *e)
{
QWidget *modal = QApplication::activeModalWidget();
if (!modal) {
return(false);
}
modal -> raise();
modal -> activateWindow();
e -> ignore();
return(true);
}
/**
Base implementation of firstActivation (does nothing).
*/
+2
View File
@@ -30,6 +30,8 @@ class QETMainWindow : public QMainWindow {
public:
QETMainWindow(QWidget * = nullptr, Qt::WindowFlags = Qt::Widget);
~QETMainWindow() override;
static bool refuseCloseWhileModal(QEvent *e);
// methods
protected:
+2
View File
@@ -29,5 +29,7 @@ message(". Add sub directory googletest")
add_subdirectory(googletest)
message(". Add sub directory googlemock")
add_subdirectory(googlemock)
message(". Add sub directory modal-quit-regression")
add_subdirectory(modal-quit-regression)
message(". Add sub directory qttest")
add_subdirectory(qttest)
@@ -0,0 +1,30 @@
# Copyright 2006 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 <http://www.gnu.org/licenses/>.
# Issue #904 -- see run.sh for what this guards and how.
#
# Driven by gdb, so it is registered on Linux only. It reports 77 (SKIP)
# rather than failing when it cannot run at all: no gdb, a gdb without
# Python, or a stripped binary whose symbols it cannot call.
if(UNIX AND NOT APPLE)
message(". Add test modal_quit_regression")
add_test(
NAME modal_quit_regression
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/run.sh --binary $<TARGET_FILE:qelectrotech>)
set_tests_properties(modal_quit_regression PROPERTIES
SKIP_RETURN_CODE 77
TIMEOUT 180)
endif()
+71
View File
@@ -0,0 +1,71 @@
# Quit-during-modal regression test
Guards the abort reported in #904.
`QETDiagramEditor::openAndAddProject()` shows `BackupDialog` as a stack object
parented to the editor and `exec()`s it, and `QET::QetMessageBox` does the same
for every message box. `exec()` runs a nested event loop. Closing the editor
during that loop turns `WA_DeleteOnClose` into a `deleteLater()` that the nested
loop processes, so `~QWidget()` deletes the stack-allocated dialog and the
process aborts. `QETMainWindow::refuseCloseWhileModal()` refuses such a close.
## Running it
```bash
tests/modal-quit-regression/run.sh --binary build/qelectrotech
```
Needs `gdb` with Python. It runs on the offscreen platform, so no X server or
window manager is required. Takes about ten seconds.
It is also registered with CTest on Linux (`ctest -R modal_quit_regression`).
Exit codes: `0` survived, `1` crashed, `2` the scenario did not happen,
`77` it could not run here.
That last one matters for a release build. The scenario is driven by calling
`QETApp::instance()` and `QETApp::quitQET()` through gdb, so those symbols have
to survive into the binary: against a **stripped** build there is nothing to
call, and the same applies without gdb or with a gdb built without Python. All
three report 77, which is CTest's `SKIP_RETURN_CODE`, so a build this test
cannot drive is skipped rather than failed.
## How it works, and why this way
On Linux there is nothing to click: the menu bar belongs to the window the
dialog blocks, and Qt ignores window-manager close requests for a blocked
window. The reported route is macOS, where File > Quit lives in the application
menu and stays usable during a modal — and what it does is call `close()` while
the dialog's loop is running. The test does the same thing through gdb:
1. break on `QDialog::exec()`;
2. let the dialog's loop run, then interrupt it, so the main thread is inside
the nested loop — the only place the bug exists, because a `deleteLater()`
posted *before* `exec()` started is not processed by that loop;
3. call `QETApp::quitQET()`, which closes every editor;
4. let it run: an unfixed build aborts within a second.
It matches no window titles and no window ids. Titles are translated, and
`tests/ipc-regression` once shipped a pass that could not fail because of that.
## Covering the element editor too
`--project` decides which editor is under test, because QElectroTech picks the
editor from the file extension. Pass a `.qet` and the run exercises
`QETDiagramEditor`; pass a **read-only `.elmt`** and it exercises
`QETElementEditor`, which shows a "file is read-only" message box on open and so
reaches the same nested loop by a different route:
```bash
chmod -w some.elmt
tests/modal-quit-regression/run.sh --binary build/qelectrotech --project some.elmt
```
That distinction is why the file name is preserved when it is copied into the
sandbox. Renaming a `.elmt` to `project.qet` would make the run silently test
the diagram editor again, and still report PASS.
Checked both ways, in both editors, before being committed: without the fix the
diagram-editor run aborts with signal 6 under `~QETDiagramEditor()` and the
element-editor run with `double free or corruption` under
`~QETElementEditor()`; with the fix both survive.
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
#
# Quit-during-modal regression gate -- issue #904.
#
# tests/modal-quit-regression/run.sh --binary build/qelectrotech
# tests/modal-quit-regression/run.sh --binary build/qelectrotech \
# --project read-only.elmt
#
# --project also picks the editor under test: QElectroTech chooses it from the
# extension, so a .qet exercises QETDiagramEditor and a read-only .elmt
# exercises QETElementEditor (which opens a message box of its own).
#
# WHAT IT GUARDS
#
# QETDiagramEditor::openAndAddProject() shows BackupDialog as a stack object
# parented to the editor and exec()s it; QET::QetMessageBox does the same for
# every message box. exec() runs a NESTED event loop. If the editor is closed
# during that loop, WA_DeleteOnClose becomes a deleteLater() that the nested
# loop processes: ~QWidget() deletes the editor's children, the stack-allocated
# dialog among them, and the process aborts ("free(): invalid size").
# QETMainWindow::refuseCloseWhileModal() refuses such a close.
#
# HOW IT TRIGGERS THE BUG WITHOUT A MOUSE
#
# On Linux the menu bar belongs to the window the modal blocks, and Qt ignores
# window-manager close requests for a blocked window, so there is nothing to
# click. The reported route is macOS, where File > Quit moves to the
# application menu and stays usable. What that route does is call close()
# programmatically while the dialog's loop is spinning, and that is what this
# test does, through gdb:
#
# 1. break on QDialog::exec(), i.e. the moment the first dialog is shown;
# 2. let it run for a moment, then interrupt it -- the main thread is now
# inside the dialog's nested loop, which is the only place the bug lives
# (a deleteLater() posted before exec() started is not processed by it);
# 3. call QETApp::quitQET(), which closes every editor;
# 4. let it run again: the unfixed build aborts within a second, the fixed
# one keeps running until the test interrupts it.
#
# Nothing here matches a window title or a window id. A title is a locale:
# tests/ipc-regression once shipped a pass that could not fail because the
# dialog it filtered by name was translated differently in Docker.
#
# It runs on the offscreen platform, so no X server or window manager is
# needed -- only gdb with Python.
#
# RESULT
# exit 0 PASS quitQET() ran inside the modal loop and the process survived
# exit 1 FAIL the process died of a signal after quitQET()
# exit 2 ERROR the scenario never happened (no dialog, gdb problem, ...)
# exit 77 SKIP cannot run here: no gdb, a gdb without Python, or a
# stripped binary. 77 is CTest's SKIP_RETURN_CODE, so a
# build this test cannot drive is reported as skipped
# rather than failed.
#
set -uo pipefail
BINARY=""
PROJECT=""
SETTLE=2 # seconds inside the dialog loop before interrupting
OBSERVE=5 # seconds to wait for a crash after quitQET()
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="$2"; shift 2 ;;
--project) PROJECT="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "usage: $0 --binary <qet> [--project <file.qet>]" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "not executable: $BINARY" >&2; exit 2; }
BINARY="$(readlink -f "$BINARY")"
if [ -z "$PROJECT" ]; then
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT="$(ls "$SCRIPT_DIR"/../../examples/*.qet 2>/dev/null | head -1)"
fi
[ -f "$PROJECT" ] || { echo "no project found; pass --project" >&2; exit 2; }
skip() { echo "SKIP: $1"; exit 77; }
command -v gdb >/dev/null || skip "gdb is not installed"
gdb -batch -ex 'python import gdb' >/dev/null 2>&1 \
|| skip "this gdb has no Python support"
# The scenario is driven by calling QETApp::instance() and QETApp::quitQET()
# through gdb, so their symbols have to survive into the binary. A stripped
# release build cannot be driven at all -- that is a property of the build,
# not a failure of the code under test, so report it as skipped.
#
# Read nm's output once into a variable rather than piping it into grep -q:
# grep -q exits at the first match, nm dies of SIGPIPE, and under `set -o
# pipefail` the pipeline reports failure even though the symbol was found --
# which would skip this test on every build that can actually run it.
if command -v nm >/dev/null; then
SYMBOLS="$(nm -C "$BINARY" 2>/dev/null || true)"
for sym in "QETApp::instance()" "QETApp::quitQET()"; do
case "$SYMBOLS" in
*"$sym"*) ;;
*) skip "binary has no symbol for $sym (stripped build?)" ;;
esac
done
fi
SANDBOX="$(mktemp -d /tmp/qet-modal-quit.XXXXXX)"
cleanup() { [ "${KEEP_LOGS:-0}" = "1" ] || rm -rf "$SANDBOX"; }
trap cleanup EXIT
# A unique binary path gives this run its own SingleApplication socket, so it
# can neither be captured by nor capture a QElectroTech already running. A
# symlink would not do: applicationFilePath() resolves it back to the real path.
TEST_BINARY="$SANDBOX/qelectrotech-modalquit"
cp "$BINARY" "$TEST_BINARY" || { echo "could not copy binary" >&2; exit 2; }
# Keep the original file name. QElectroTech decides what to open from the
# extension, so copying a .elmt to "project.qet" would quietly turn an
# element-editor run into a failed project load -- and the scenario would
# still "work", against the wrong window.
SANDBOX_PROJECT="$SANDBOX/$(basename "$PROJECT")"
cp "$PROJECT" "$SANDBOX_PROJECT" || { echo "could not copy input file" >&2; exit 2; }
export HOME="$SANDBOX/home"
export XDG_CONFIG_HOME="$HOME/.config"
export XDG_DATA_HOME="$HOME/.local/share"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
export QT_QPA_PLATFORM=offscreen
cat > "$SANDBOX/scenario.gdb" <<EOF
set debuginfod enabled off
set pagination off
set confirm off
set breakpoint pending on
handle SIGINT stop print nopass
python
import subprocess
def qet_interrupt_later(seconds):
pid = gdb.selected_inferior().pid
subprocess.Popen(["sh", "-c", "sleep %d; kill -INT %d" % (seconds, pid)])
end
break QDialog::exec
run
delete
printf "QET_TEST: dialog exec() entered\n"
python qet_interrupt_later($SETTLE)
continue
thread 1
set \$app = ((void* (*)(void))'QETApp::instance()')()
call ((void (*)(void*))'QETApp::quitQET()')(\$app)
printf "QET_TEST: quitQET() returned\n"
python qet_interrupt_later($OBSERVE)
continue
printf "QET_TEST: final signal %d\n", \$_siginfo.si_signo
bt 20
kill
EOF
# Bound the run with timeout(1) rather than a backgrounded watchdog subshell.
# A "( sleep N; kill ) &" watchdog runs sleep as a child of the subshell, so
# killing the subshell orphans the sleep -- and the orphan keeps this script's
# stdout open. Read through a pipe, as CTest does, that makes every run last
# the full watchdog period no matter how fast gdb finished: ten seconds of
# work reported as two minutes, a stone's throw from the CTest timeout.
GDB_TIMEOUT=120
if command -v timeout >/dev/null; then
timeout --signal=KILL "$GDB_TIMEOUT" \
gdb -batch -x "$SANDBOX/scenario.gdb" --args "$TEST_BINARY" "$SANDBOX_PROJECT" \
> "$SANDBOX/gdb.log" 2>&1
else
gdb -batch -x "$SANDBOX/scenario.gdb" --args "$TEST_BINARY" "$SANDBOX_PROJECT" \
> "$SANDBOX/gdb.log" 2>&1
fi
log="$SANDBOX/gdb.log"
if ! grep -q "QET_TEST: dialog exec() entered" "$log"; then
echo "ERROR: no dialog was ever shown -- the scenario did not happen"
KEEP_LOGS=1; echo "log kept: $log"; exit 2
fi
if ! grep -q "QET_TEST: quitQET() returned" "$log"; then
echo "ERROR: quitQET() was not called inside the dialog loop"
KEEP_LOGS=1; echo "log kept: $log"; exit 2
fi
sig="$(sed -n 's/^QET_TEST: final signal \([0-9]*\)$/\1/p' "$log" | tail -1)"
case "$sig" in
2)
echo "PASS: closing the editor during a modal dialog was refused; the process survived"
exit 0 ;;
"")
echo "ERROR: could not tell how the run ended"
KEEP_LOGS=1; echo "log kept: $log"; exit 2 ;;
*)
echo "FAIL: the process died of signal $sig after quitQET() ran inside the dialog loop (issue #904)"
grep -m1 -E "free\(\)|double free|corrupted" "$log" | sed 's/^/ /'
sed -n '/^QET_TEST: final signal/,$p' "$log" | grep -E '^#[0-9]+ ' | sed 's/^/ /'
KEEP_LOGS=1; echo "log kept: $log"; exit 1 ;;
esac
+47
View File
@@ -48,6 +48,18 @@ find_package(
Test
REQUIRED)
# tst_crashhandler compiles crashhandler.cpp, which on a non-Windows build
# may pull in backtrace(). Resolved the same way as in the top-level
# CMakeLists so this directory also configures standalone -- on FreeBSD the
# symbol lives in libexecinfo, not libc.
if(NOT DEFINED Backtrace_FOUND)
find_package(Backtrace QUIET)
if(Backtrace_FOUND)
include_directories(${Backtrace_INCLUDE_DIRS})
add_compile_definitions(QET_CRASH_BACKTRACE)
endif()
endif()
include(../../cmake/fetch_kdeaddons.cmake)
include(../../cmake/fetch_singleapplication.cmake)
include(../../cmake/fetch_pugixml.cmake)
@@ -126,6 +138,41 @@ add_test(NAME tst_qetstrings COMMAND tst_qetstrings)
target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml)
# CrashHandler::formatInt() -- the async-signal-safe decimal formatter the
# signal handler uses for the "Signal: N" line of a crash dump. Compiles
# crashhandler.cpp and logring.cpp alongside; the handler deliberately
# depends on nothing heavier than QByteArray.
add_executable(
tst_crashhandler
tst_crashhandler.cpp
${QET_DIR}/sources/logging/crashhandler.cpp
${QET_DIR}/sources/logging/logring.cpp
${QET_DIR}/sources/qetversion.cpp)
add_test(NAME tst_crashhandler COMMAND tst_crashhandler)
target_include_directories(tst_crashhandler PRIVATE ${QET_DIR}/sources)
# Qt::Xml because crashhandler.cpp includes qetversion.h for the header it
# builds at install() time, and that pulls in QDomElement.
target_link_libraries(tst_crashhandler PRIVATE Qt::Test Qt::Xml ${Backtrace_LIBRARIES})
# The crash-dump bookkeeping from #905: which dumps get listed, offered and
# deleted, and what redact() masks. qetlogger.cpp needs exactly one symbol
# from the application (QETApp::dataDir()), which the test supplies itself,
# so this links the logging sources rather than the whole program.
add_executable(
tst_crashdumps
tst_crashdumps.cpp
${QET_DIR}/sources/logging/qetlogger.cpp
${QET_DIR}/sources/logging/crashhandler.cpp
${QET_DIR}/sources/logging/logring.cpp
${QET_DIR}/sources/qetversion.cpp)
add_test(NAME tst_crashdumps COMMAND tst_crashdumps)
target_include_directories(tst_crashdumps PRIVATE
${QET_DIR}
${QET_DIR}/sources
${QET_DIR}/sources/NameList
${QET_DIR}/pugixml/src)
target_link_libraries(tst_crashdumps PRIVATE Qt::Test Qt::Widgets Qt::Xml ${Backtrace_LIBRARIES})
add_executable(
tst_menubarkeyboard
tst_menubarkeyboard.cpp)
+312
View File
@@ -0,0 +1,312 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#include "logging/qetlogger.h"
#include "qetapp.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <QTest>
/**
QetLogger::init() asks QETApp for the data directory, and that is the
only thing it needs from the application. Standing in for it here is
what lets the crash-dump bookkeeping be tested without linking (or
starting) the whole of QElectroTech.
*/
namespace {
QString g_data_dir;
}
QString QETApp::dataDir()
{
return g_data_dir;
}
/**
@brief The tst_CrashDumps class
Covers the crash-dump bookkeeping added in #905: one file per run
instead of a single crash_dump.log that each crash overwrote (#898),
and the rules about which of those files get offered and deleted.
*/
class tst_CrashDumps : public QObject
{
Q_OBJECT
private slots:
void init();
void listsNothingWhenThereHasBeenNoCrash();
void listsDumpsNewestFirst();
void skipsEmptyDumps();
void excludesThisRunsOwnDump();
void capsTheListAtTenDumps();
void concatenatesEveryOfferedDump();
void clearsOnlyWhatWasOffered();
void redactsTheHomeDirectory();
void redactsAnAppImageMountPoint();
void redactsBothInOneString();
private:
QTemporaryDir m_dir;
QString crashesDir() const {return g_data_dir + QStringLiteral("/crashes");}
void writeDump(const QString &name, const QByteArray &body,
const QDateTime &when = QDateTime());
};
void tst_CrashDumps::init()
{
QVERIFY(m_dir.isValid());
// A fresh subdirectory per test: QetLogger is a singleton, so the
// tests share one instance and must not share its files.
static int n = 0;
g_data_dir = m_dir.path() + QStringLiteral("/run") + QString::number(++n);
QVERIFY(QDir().mkpath(g_data_dir));
QVERIFY(QDir().mkpath(crashesDir()));
}
void tst_CrashDumps::writeDump(const QString &name, const QByteArray &body,
const QDateTime &when)
{
const QString path = crashesDir() + QStringLiteral("/") + name;
{
QFile f(path);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(body);
f.close();
}
if (!when.isValid()) {
return;
}
// Separately, and only once the write is closed: setFileTime() needs
// an open handle, but closing a file that was just written sets the
// modification time to now, which would undo it.
QFile stamp(path);
QVERIFY(stamp.open(QIODevice::ReadWrite));
QVERIFY(stamp.setFileTime(when, QFileDevice::FileModificationTime));
stamp.close();
}
void tst_CrashDumps::listsNothingWhenThereHasBeenNoCrash()
{
QetLogger::instance().init();
QVERIFY(QetLogger::instance().pendingCrashDumpFiles().isEmpty());
QVERIFY(!QetLogger::instance().hasPendingCrashDump());
}
void tst_CrashDumps::listsDumpsNewestFirst()
{
const QDateTime base = QDateTime::currentDateTime();
writeDump(QStringLiteral("crash_a.log"), "oldest", base.addSecs(-300));
writeDump(QStringLiteral("crash_b.log"), "middle", base.addSecs(-200));
writeDump(QStringLiteral("crash_c.log"), "newest", base.addSecs(-100));
QetLogger::instance().init();
const QStringList files = QetLogger::instance().pendingCrashDumpFiles();
QCOMPARE(files.size(), 3);
QVERIFY(files.at(0).endsWith(QStringLiteral("crash_c.log")));
QVERIFY(files.at(1).endsWith(QStringLiteral("crash_b.log")));
QVERIFY(files.at(2).endsWith(QStringLiteral("crash_a.log")));
}
/**
A zero-length dump means the handler opened the file and died before
writing anything. There is nothing to show, and offering an empty
report would be worse than offering none.
*/
void tst_CrashDumps::skipsEmptyDumps()
{
writeDump(QStringLiteral("crash_empty.log"), QByteArray());
writeDump(QStringLiteral("crash_real.log"), "something");
QetLogger::instance().init();
const QStringList files = QetLogger::instance().pendingCrashDumpFiles();
QCOMPARE(files.size(), 1);
QVERIFY(files.at(0).endsWith(QStringLiteral("crash_real.log")));
}
/**
The dump this run would write if it crashed must never appear in the
list of dumps from *previous* runs -- otherwise a process that crashed
could be offered its own dump, mid-crash.
*/
void tst_CrashDumps::excludesThisRunsOwnDump()
{
writeDump(QStringLiteral("crash_previous.log"), "from a previous run");
QetLogger &logger = QetLogger::instance();
logger.init();
logger.installCrashHandler(); // this is what fixes our own path
// installCrashHandler() picked crash_<yyyyMMdd-hhmmss>_<pid>.log for
// this process but has not created it -- the handler only writes when
// the process actually dies. Standing in for that here: fill in every
// name it could have chosen, so whichever one it picked now exists
// and is non-empty. The exact second does not have to be guessed.
const qint64 pid = QCoreApplication::applicationPid();
const QDateTime now = QDateTime::currentDateTime();
for (int offset = -2 ; offset <= 0 ; ++offset) {
writeDump(QStringLiteral("crash_%1_%2.log")
.arg(now.addSecs(offset).toString(QStringLiteral("yyyyMMdd-hhmmss")))
.arg(pid),
"this run's own dump, mid-crash");
}
// Exactly one of those three is the path install() actually chose,
// and exactly that one must be missing from the list. The other two
// are ordinary files as far as the logger is concerned.
const QDir dir(crashesDir());
const int on_disk = dir.entryList({QStringLiteral("crash_*_") + QString::number(pid)
+ QStringLiteral(".log")},
QDir::Files).size();
QCOMPARE(on_disk, 3);
const QStringList files = logger.pendingCrashDumpFiles();
int offered_own = 0;
for (const QString &path : files) {
if (path.contains(QString::number(pid))) {
++offered_own;
}
}
QCOMPARE(offered_own, 2); // one of the three was excluded
QCOMPARE(files.size(), 3); // those two, plus crash_previous.log
QVERIFY(files.last().endsWith(QStringLiteral("crash_previous.log")));
}
/**
A crash loop writes one dump per restart. The list is capped so the
dialog cannot be handed an unbounded amount of text.
*/
void tst_CrashDumps::capsTheListAtTenDumps()
{
const QDateTime base = QDateTime::currentDateTime();
for (int i = 0 ; i < 15 ; ++i) {
writeDump(QStringLiteral("crash_%1.log").arg(i, 2, 10, QChar('0')),
QByteArray("dump ") + QByteArray::number(i),
base.addSecs(-1000 + i));
}
QetLogger::instance().init();
QCOMPARE(QetLogger::instance().pendingCrashDumpFiles().size(), 10);
// The ten kept are the newest ten, i.e. 05..14.
const QStringList files = QetLogger::instance().pendingCrashDumpFiles();
QVERIFY(files.at(0).endsWith(QStringLiteral("crash_14.log")));
QVERIFY(files.at(9).endsWith(QStringLiteral("crash_05.log")));
}
/**
#898: every dump is offered, not just the most recent one. The whole
point of keeping them is that a repeating crash is where the earlier
dumps carry the most information.
*/
void tst_CrashDumps::concatenatesEveryOfferedDump()
{
const QDateTime base = QDateTime::currentDateTime();
writeDump(QStringLiteral("crash_one.log"), "FIRST CRASH BODY", base.addSecs(-200));
writeDump(QStringLiteral("crash_two.log"), "SECOND CRASH BODY", base.addSecs(-100));
QetLogger &logger = QetLogger::instance();
logger.init();
const QStringList offered = logger.pendingCrashDumpFiles();
const QByteArray content = logger.pendingCrashDumpContents(offered);
QVERIFY(content.contains("FIRST CRASH BODY"));
QVERIFY(content.contains("SECOND CRASH BODY"));
QVERIFY(content.contains("crash_one.log"));
QVERIFY(content.contains("crash_two.log"));
QVERIFY(content.contains("2 crash dumps pending"));
}
/**
The reason clearPendingCrashDump() takes the list rather than looking
the directory up again: the offer sits in a modal dialog, and a dump
that arrives while it is open has never been seen by anybody.
*/
void tst_CrashDumps::clearsOnlyWhatWasOffered()
{
writeDump(QStringLiteral("crash_offered.log"), "offered to the user");
QetLogger &logger = QetLogger::instance();
logger.init();
const QStringList offered = logger.pendingCrashDumpFiles();
QCOMPARE(offered.size(), 1);
// ... the dialog is open, and a second instance crashes.
writeDump(QStringLiteral("crash_arrived_later.log"), "nobody has seen this yet");
logger.clearPendingCrashDump(offered);
const QStringList left = logger.pendingCrashDumpFiles();
QCOMPARE(left.size(), 1);
QVERIFY(left.at(0).endsWith(QStringLiteral("crash_arrived_later.log")));
}
void tst_CrashDumps::redactsTheHomeDirectory()
{
const QByteArray home = QDir::homePath().toUtf8();
QVERIFY(!home.isEmpty());
const QByteArray in = home + "/projects/secret.qet failed to load";
const QByteArray out = QetLogger::redact(in);
QVERIFY(!out.contains(home));
QVERIFY(out.startsWith("~/projects/secret.qet"));
}
/**
backtrace_symbols_fd() writes absolute module paths, which for an
AppImage is a per-run mount point under /tmp/.mount_. Raised in review
on #905.
*/
void tst_CrashDumps::redactsAnAppImageMountPoint()
{
const QByteArray in =
"/tmp/.mount_QElect6Yh2Kz/usr/bin/qelectrotech(+0x9b098e) [0x5ecf]\n"
"/tmp/.mount_QElect6Yh2Kz/usr/lib/libQt6Core.so.6(+0x1234) [0x7cfa]\n";
const QByteArray out = QetLogger::redact(in);
QVERIFY(!out.contains(".mount_QElect6Yh2Kz"));
QVERIFY(out.contains("<appimage>/usr/bin/qelectrotech"));
QVERIFY(out.contains("<appimage>/usr/lib/libQt6Core.so.6"));
// The frame offsets are the useful part and must survive.
QVERIFY(out.contains("(+0x9b098e) [0x5ecf]"));
}
void tst_CrashDumps::redactsBothInOneString()
{
const QByteArray home = QDir::homePath().toUtf8();
const QByteArray in = home + "/Documents/a.qet\n/tmp/.mount_AbCdEf/usr/bin/qet\n";
const QByteArray out = QetLogger::redact(in);
QVERIFY(!out.contains(home));
QVERIFY(!out.contains(".mount_AbCdEf"));
QVERIFY(out.contains("~/Documents/a.qet"));
QVERIFY(out.contains("<appimage>/usr/bin/qet"));
}
QTEST_MAIN(tst_CrashDumps)
#include "tst_crashdumps.moc"
+149
View File
@@ -0,0 +1,149 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#include "logging/crashhandler.h"
#include <QTest>
#include <climits>
#include <csignal>
#include <cstring>
/**
@brief The tst_CrashHandler class
Covers CrashHandler::formatInt(), the decimal formatter the signal
handler uses to write "Signal: 11" into a crash dump.
It is worth a test out of proportion to its size. It cannot use
snprintf(), which is not on the POSIX async-signal-safe list, so it is
hand-rolled; it runs only inside a signal handler, where nothing can
assert and a fault produces no diagnostic; and it is exercised only
when the application is already crashing, so a defect here would
corrupt or truncate exactly the dumps that matter and would never be
noticed in ordinary use.
The negation is the interesting part: -value on INT_MIN is undefined
behaviour, so the implementation goes through unsigned.
*/
class tst_CrashHandler : public QObject
{
Q_OBJECT
private slots:
void formatsZero();
void formatsPositive();
void formatsTheHandledSignals();
void formatsNegative();
void formatsIntMinWithoutOverflow();
void formatsIntMax();
void truncatesRatherThanOverflowing();
void writesNothingWhenThereIsNoRoom();
private:
/// Formats into a buffer poisoned with a sentinel, and fails if
/// anything past the returned length was touched.
static QByteArray format(int value, int size = 32);
};
QByteArray tst_CrashHandler::format(int value, int size)
{
char buffer[64];
memset(buffer, '\xAB', sizeof(buffer));
const int len = CrashHandler::formatInt(buffer, size, value);
// Nothing may be written past what was reported, nor past `size`.
for (int i = qMax(len, 0) ; i < static_cast<int>(sizeof(buffer)) ; ++i) {
if (buffer[i] != '\xAB') {
return QByteArray("WROTE PAST END at ") + QByteArray::number(i);
}
}
if (len < 0 || len > size) {
return QByteArray("BAD LENGTH ") + QByteArray::number(len);
}
return QByteArray(buffer, len);
}
void tst_CrashHandler::formatsZero()
{
QCOMPARE(format(0), QByteArray("0"));
}
void tst_CrashHandler::formatsPositive()
{
QCOMPARE(format(1), QByteArray("1"));
QCOMPARE(format(9), QByteArray("9"));
QCOMPARE(format(10), QByteArray("10"));
QCOMPARE(format(1234567), QByteArray("1234567"));
}
/**
The values this actually sees in the field: kHandledSignals, as
written into the "Signal: N" line of every dump.
*/
void tst_CrashHandler::formatsTheHandledSignals()
{
QCOMPARE(format(SIGSEGV), QByteArray::number(SIGSEGV));
QCOMPARE(format(SIGABRT), QByteArray::number(SIGABRT));
QCOMPARE(format(SIGBUS), QByteArray::number(SIGBUS));
QCOMPARE(format(SIGFPE), QByteArray::number(SIGFPE));
QCOMPARE(format(SIGILL), QByteArray::number(SIGILL));
}
void tst_CrashHandler::formatsNegative()
{
QCOMPARE(format(-1), QByteArray("-1"));
QCOMPARE(format(-42), QByteArray("-42"));
}
/**
-INT_MIN is undefined behaviour; the implementation negates through
unsigned instead. A build that got this wrong would either trap under
-ftrapv/UBSan or silently print the wrong number.
*/
void tst_CrashHandler::formatsIntMinWithoutOverflow()
{
QCOMPARE(format(INT_MIN), QByteArray::number(INT_MIN));
}
void tst_CrashHandler::formatsIntMax()
{
QCOMPARE(format(INT_MAX), QByteArray::number(INT_MAX));
}
/**
A buffer too small must be filled and stopped at, never run past --
the handler passes a fixed 64-byte stack buffer and subtracts what it
has already used.
*/
void tst_CrashHandler::truncatesRatherThanOverflowing()
{
QCOMPARE(format(12345, 3), QByteArray("123"));
QCOMPARE(format(-12345, 3), QByteArray("-12"));
QCOMPARE(format(7, 1), QByteArray("7"));
}
void tst_CrashHandler::writesNothingWhenThereIsNoRoom()
{
QCOMPARE(format(123, 0), QByteArray());
QCOMPARE(format(0, 0), QByteArray());
QCOMPARE(format(-5, 0), QByteArray());
}
QTEST_APPLESS_MAIN(tst_CrashHandler)
#include "tst_crashhandler.moc"