Fix crash safety issues: Unsafe signal connections and tree child access (#4761)

Systematically resolved 30 identified potential crash vectors and established automated regression testing to prevent strict reoccurrence.

Key Changes:
- Fixed 11 instances of unsafe `QObject::connect` calls (missing context object) in srd/Charts/AgendaWindow.cpp, FixSpikes.cpp, src/FileIO/FixSpikes.cpp, src/Gui/Agenda.cpp, src/Gui/BatchProcessingDialog.cpp, and src/Gui/IconManager.cpp. This prevents crashes caused by signals firing after the receiver has been destroyed.
- Fixed 19 instances of unsafe `QTreeWidgetItem` child access in src/Charts/LTMChartParser.cpp, src/Gui/ColorButton.cpp, src/Gui/AthletePages.cpp, and src/Gui/Pages.cpp by adding defensive `nullptr` checks before dereferencing.
- Added Python detection scripts util/check_unsafe_connects.py and util/check_unsafe_tree_child.py to statically analyze the codebase for these specific unsafe patterns.
- Integrated detection scripts into the regression test suite under `unittests/Core/signalSafety`, verifying the fixes and enforcing a strict zero-tolerance policy for future regressions.
- Added `testSplineCrash` to cover edge cases with empty spline lookups.
This commit is contained in:
Magnus Gille
2025-12-17 08:52:38 -08:00
committed by GitHub
parent 2dc2d02e80
commit 9bb90e3737
22 changed files with 549 additions and 46 deletions

6
.gitignore vendored
View File

@@ -61,6 +61,12 @@ build.pro.user
build.pro.user.*
doc/contrib/WASP Packet Protocol.pdf
# unittests
unittests/Core/season/season
unittests/Core/seasonOffset/seasonOffset
unittests/Core/seasonParser/seasonParser
unittests/Core/units/units
unittests/Gui/calendarData/calendarData
# Qt testlib
target_wrapper.sh

View File

@@ -80,7 +80,7 @@ AgendaWindow::AgendaWindow(Context *context)
}
}
});
connect(agendaView, &AgendaView::viewActivity, [this, context](const CalendarEntry &activity) {
connect(agendaView, &AgendaView::viewActivity, this, [this, context](const CalendarEntry &activity) {
for (RideItem *rideItem : context->athlete->rideCache->rides()) {
if (rideItem != nullptr && rideItem->fileName == activity.reference) {
context->notifyRideSelected(rideItem);

View File

@@ -190,10 +190,9 @@ ChartTreeView::dropEvent(QDropEvent* event)
context->notifyPresetsChanged();
clearSelection();
// xxx dgr removed because
// select it!
/*foreach (int idx, idxToList) {
invisibleRootItem()->child(idx)->setSelected(true);
QTreeWidgetItem *item = invisibleRootItem()->child(idx);
if (item) item->setSelected(true);
}*/
}

View File

@@ -72,7 +72,7 @@ class FixSpikesConfig : public DataProcessorConfig
medWinSize->setSuffix(" " + tr("Points"));
// Ensure only odd numbers are set for the median window
connect(medWinSize, QOverload<int>::of(&QSpinBox::valueChanged),
connect(medWinSize, QOverload<int>::of(&QSpinBox::valueChanged), this,
[=](int i) {(i % 2) ? medWinSize->setValue(i) : medWinSize->setValue(i + 1); });
layout->addRow("", algo);

View File

@@ -911,17 +911,17 @@ AgendaView::AgendaView
seasonLabel->setFont(seasonFont);
activityTree = new ActivityTree();
connect(activityTree, &ActivityTree::dayChanged, [this](const QDate &date) { emit dayChanged(date); });
connect(activityTree, &ActivityTree::showInTrainMode, [this](const CalendarEntry &activity) { emit showInTrainMode(activity); });
connect(activityTree, &ActivityTree::viewActivity, [this](const CalendarEntry &activity) { emit viewActivity(activity); });
connect(activityTree, &ActivityTree::dayChanged, this, [this](const QDate &date) { emit dayChanged(date); });
connect(activityTree, &ActivityTree::showInTrainMode, this, [this](const CalendarEntry &activity) { emit showInTrainMode(activity); });
connect(activityTree, &ActivityTree::viewActivity, this, [this](const CalendarEntry &activity) { emit viewActivity(activity); });
phaseTree = new PhaseTree();
connect(phaseTree, &PhaseTree::dayChanged, [this](const QDate &date) { emit dayChanged(date); });
connect(phaseTree, &PhaseTree::editPhaseEntry, [this](const CalendarEntry &phase) { emit editPhaseEntry(phase); });
connect(phaseTree, &PhaseTree::dayChanged, this, [this](const QDate &date) { emit dayChanged(date); });
connect(phaseTree, &PhaseTree::editPhaseEntry, this, [this](const CalendarEntry &phase) { emit editPhaseEntry(phase); });
eventTree = new EventTree();
connect(eventTree, &EventTree::dayChanged, [this](const QDate &date) { emit dayChanged(date); });
connect(eventTree, &EventTree::editEventEntry, [this](const CalendarEntry &event) { emit editEventEntry(event); });
connect(eventTree, &EventTree::dayChanged, this, [this](const QDate &date) { emit dayChanged(date); });
connect(eventTree, &EventTree::editEventEntry, this, [this](const CalendarEntry &event) { emit editEventEntry(event); });
QGridLayout* headLayout = new QGridLayout();
headLayout->setColumnStretch(0, 1);

View File

@@ -803,6 +803,10 @@ MeasuresPage::addClicked()
} else {
rnum = i;
add = measuresTree->invisibleRootItem()->child(rnum);
if (!add) {
// Should not happen as rnum is verified to be in range
add = new QTreeWidgetItem(measuresTree->invisibleRootItem());
}
}
measures[rnum].when = dtEdit->dateTime();
for (k = 0; k < valuesEdit.count(); ++k) {
@@ -816,11 +820,12 @@ MeasuresPage::addClicked()
void
MeasuresPage::deleteClicked()
{
if (measuresTree->currentItem()) {
int index = measuresTree->invisibleRootItem()->indexOfChild(measuresTree->currentItem());
delete measuresTree->invisibleRootItem()->takeChild(index);
measures.removeAt(index);
}
QTreeWidgetItem *item = measuresTree->currentItem();
if (item) {
int index = measuresTree->invisibleRootItem()->indexOfChild(item);
delete measuresTree->invisibleRootItem()->takeChild(index);
measures.removeAt(index);
}
}
void
@@ -1067,9 +1072,11 @@ SchemePage::getScheme()
// read back the details from the table
for (int i=0; i<scheme->invisibleRootItem()->childCount(); i++) {
schemeitem add;
add.name = scheme->invisibleRootItem()->child(i)->data(0, Qt::DisplayRole).toString();
add.desc = scheme->invisibleRootItem()->child(i)->data(1, Qt::DisplayRole).toString();
add.lo = scheme->invisibleRootItem()->child(i)->data(2, Qt::DisplayRole).toInt();
QTreeWidgetItem *item = scheme->invisibleRootItem()->child(i);
if (!item) continue;
add.name = item->data(0, Qt::DisplayRole).toString();
add.desc = item->data(1, Qt::DisplayRole).toString();
add.lo = item->data(2, Qt::DisplayRole).toInt();
table.append(add);
}
@@ -1981,6 +1988,7 @@ CPPage::zonesChanged()
QList<ZoneInfo> zoneinfos;
for (int i=0; i< zones->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = zones->invisibleRootItem()->child(i);
if (!item) continue;
zoneinfos << ZoneInfo(item->data(0, Qt::DisplayRole).toString(),
item->data(1, Qt::DisplayRole).toString(),
item->data(2, Qt::DisplayRole).toInt(),
@@ -2427,10 +2435,12 @@ HrSchemePage::getScheme()
// read back the details from the table
for (int i=0; i<scheme->invisibleRootItem()->childCount(); i++) {
schemeitem add;
add.name = scheme->invisibleRootItem()->child(i)->data(0, Qt::DisplayRole).toString();
add.desc = scheme->invisibleRootItem()->child(i)->data(1, Qt::DisplayRole).toString();
add.lo = scheme->invisibleRootItem()->child(i)->data(2, Qt::DisplayRole).toInt();
add.trimp = scheme->invisibleRootItem()->child(i)->data(3, Qt::DisplayRole).toDouble();
QTreeWidgetItem *item = scheme->invisibleRootItem()->child(i);
if (!item) continue;
add.name = item->data(0, Qt::DisplayRole).toString();
add.desc = item->data(1, Qt::DisplayRole).toString();
add.lo = item->data(2, Qt::DisplayRole).toInt();
add.trimp = item->data(3, Qt::DisplayRole).toDouble();
table.append(add);
}
@@ -2589,7 +2599,8 @@ LTPage::addClicked()
if (ranges->currentItem()) {
rnum = ranges->currentItem()->data(5, Qt::DisplayRole).toInt();
} else if (ranges->invisibleRootItem()->childCount() > 0) {
rnum = ranges->invisibleRootItem()->child(0)->data(5, Qt::DisplayRole).toInt();
QTreeWidgetItem *item = ranges->invisibleRootItem()->child(0);
rnum = item ? item->data(5, Qt::DisplayRole).toInt() : -1;
}
if (rnum >= 0) {
lt = hrZones->getLT(rnum);
@@ -2646,6 +2657,7 @@ LTPage::addClicked()
for (int i = 0; i < ranges->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = ranges->invisibleRootItem()->child(i);
if (!item) continue;
int itemRnum = item->data(5, Qt::DisplayRole).toInt();
if (itemRnum >= rnum) {
item->setData(5, Qt::DisplayRole, itemRnum + 1);
@@ -2679,6 +2691,7 @@ LTPage::deleteClicked()
for (int i = 0; i < ranges->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = ranges->invisibleRootItem()->child(i);
if (!item) continue;
int itemRnum = item->data(5, Qt::DisplayRole).toInt();
if (itemRnum >= rnum) {
item->setData(5, Qt::DisplayRole, itemRnum - 1);
@@ -2868,6 +2881,7 @@ LTPage::zonesChanged()
QList<HrZoneInfo> zoneinfos;
for (int i=0; i< zones->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = zones->invisibleRootItem()->child(i);
if (!item) continue;
zoneinfos << HrZoneInfo(item->data(0, Qt::DisplayRole).toString(),
item->data(1, Qt::DisplayRole).toString(),
item->data(2, Qt::DisplayRole).toInt(),
@@ -3070,9 +3084,11 @@ PaceSchemePage::getScheme()
// read back the details from the table
for (int i=0; i<scheme->invisibleRootItem()->childCount(); i++) {
paceschemeitem add;
add.name = scheme->invisibleRootItem()->child(i)->data(0, Qt::DisplayRole).toString();
add.desc = scheme->invisibleRootItem()->child(i)->data(1, Qt::DisplayRole).toString();
add.lo = scheme->invisibleRootItem()->child(i)->data(2, Qt::DisplayRole).toInt();
QTreeWidgetItem *item = scheme->invisibleRootItem()->child(i);
if (!item) continue;
add.name = item->data(0, Qt::DisplayRole).toString();
add.desc = item->data(1, Qt::DisplayRole).toString();
add.lo = item->data(2, Qt::DisplayRole).toInt();
table.append(add);
}
@@ -3286,6 +3302,7 @@ CVPage::addClicked()
int rnum = paceZones->addZoneRange(date, paceZones->kphFromTime(cv, metricPace), paceZones->kphFromTime(aet, metricPace));
for (int i = 0; i < ranges->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = ranges->invisibleRootItem()->child(i);
if (!item) continue;
int itemRnum = item->data(3, Qt::DisplayRole).toInt();
if (itemRnum >= rnum) {
item->setData(3, Qt::DisplayRole, itemRnum + 1);
@@ -3316,6 +3333,7 @@ CVPage::deleteClicked()
paceZones->deleteRange(rnum);
for (int i = 0; i < ranges->invisibleRootItem()->childCount(); ++i) {
QTreeWidgetItem *item = ranges->invisibleRootItem()->child(i);
if (!item) continue;
int itemRnum = item->data(3, Qt::DisplayRole).toInt();
if (itemRnum >= rnum) {
item->setData(3, Qt::DisplayRole, itemRnum - 1);
@@ -3499,6 +3517,7 @@ CVPage::zonesChanged()
QList<PaceZoneInfo> zoneinfos;
for (int i=0; i< zones->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = zones->invisibleRootItem()->child(i);
if (!item) continue;
QTime time = item->data(2, Qt::DisplayRole).toTime();
double kph = time == QTime(0,0,0) ? 0.0 : paceZones->kphFromTime(time, metricPace);
zoneinfos << PaceZoneInfo(item->data(0, Qt::DisplayRole).toString(),
@@ -3622,7 +3641,8 @@ SeasonsPage::SeasonsPage(QWidget *parent, Context *context) : QWidget(parent), c
add->setText(4, season.id().toString());
}
seasons->setCurrentItem(seasons->invisibleRootItem()->child(0));
if (seasons->invisibleRootItem()->childCount() > 0)
seasons->setCurrentItem(seasons->invisibleRootItem()->child(0));
mainLayout->addLayout(editLayout, 0,0);
mainLayout->addWidget(addButton, 0,1, Qt::AlignTop);
@@ -3740,6 +3760,7 @@ SeasonsPage::saveClicked()
for(int i=0; i<seasons->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = seasons->invisibleRootItem()->child(i);
if (!item) continue;
array[i].setName(item->text(0));
array[i].setType(Season::types.indexOf(item->text(1)));
@@ -3788,7 +3809,8 @@ AutoImportPage::AutoImportPage(Context *context) : context(context)
fields->setEditTriggers( QAbstractItemView::DoubleClicked
| QAbstractItemView::SelectedClicked
| QAbstractItemView::AnyKeyPressed);
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
if (fields->invisibleRootItem()->childCount() > 0)
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
mainLayout->addWidget(fields, 0,0);
mainLayout->addWidget(actionButtons, 1,0);
@@ -3882,8 +3904,10 @@ AutoImportPage::saveClicked()
rules.clear();
for(int i = 0; i < fields->invisibleRootItem()->childCount(); i++) {
RideAutoImportRule rule;
rule.setDirectory(fields->invisibleRootItem()->child(i)->data(0, Qt::DisplayRole).toString());
rule.setImportRule(fields->invisibleRootItem()->child(i)->data(1, Qt::DisplayRole).toInt());
QTreeWidgetItem *item = fields->invisibleRootItem()->child(i);
if (!item) continue;
rule.setDirectory(item->data(0, Qt::DisplayRole).toString());
rule.setImportRule(item->data(1, Qt::DisplayRole).toInt());
rules.append(rule);
}

View File

@@ -276,7 +276,7 @@ processed(0), fails(0), numFilesToProcess(0), metadataCompleter(nullptr) {
QTreeWidgetItem* current = files->invisibleRootItem()->child(i);
connect(static_cast<QCheckBox*>(files->itemWidget(current, 0)),
QOverload<int>::of(&QCheckBox::stateChanged),
QOverload<int>::of(&QCheckBox::stateChanged), this,
[=](int) { this->fileSelected(current); });
}

View File

@@ -155,15 +155,17 @@ GColorDialog::GColorDialog(QColor selected, QWidget *parent, bool all) : QDialog
if (original.red() == 1 && original.green() == 1) {
tabwidget->setCurrentIndex(0);
for(int i=0; i<colorlist->invisibleRootItem()->childCount(); i++) {
if (colorlist->invisibleRootItem()->child(i)->data(0, Qt::UserRole).toInt() == original.blue()) {
colorlist->setCurrentItem(colorlist->invisibleRootItem()->child(i));
QTreeWidgetItem *item = colorlist->invisibleRootItem()->child(i);
if (item && item->data(0, Qt::UserRole).toInt() == original.blue()) {
colorlist->setCurrentItem(item);
break;
}
}
colordialog->setCurrentColor(GColor(original.blue()));
} else {
tabwidget->setCurrentIndex(1);
colorlist->setCurrentItem(colorlist->invisibleRootItem()->child(CPOWER));
QTreeWidgetItem *item = colorlist->invisibleRootItem()->child(CPOWER);
if (item) colorlist->setCurrentItem(item);
colordialog->setCurrentColor(original);
}
// returning what we got
@@ -190,7 +192,8 @@ GColorDialog::searchFilter(QString text)
for(int i=0; i<colorlist->invisibleRootItem()->childCount(); i++) {
if (empty) colorlist->setRowHidden(i, colorlist->rootIndex(), false);
else {
QString text = colorlist->invisibleRootItem()->child(i)->text(0);
QTreeWidgetItem *item = colorlist->invisibleRootItem()->child(i);
QString text = item ? item->text(0) : "";
bool found=false;
foreach(QString tok, toks) {
if (text.contains(tok, Qt::CaseInsensitive)) {
@@ -231,7 +234,8 @@ void
GColorDialog::gcOKClicked()
{
int index = colorlist->invisibleRootItem()->indexOfChild(colorlist->currentItem());
index = colorlist->invisibleRootItem()->child(index)->data(0, Qt::UserRole).toInt();
QTreeWidgetItem *item = colorlist->invisibleRootItem()->child(index);
if (item) index = item->data(0, Qt::UserRole).toInt();
returning = QColor(1,1,index);
accept();
}

View File

@@ -351,7 +351,7 @@ IconManager::downloadUrl
timeoutTimer.setSingleShot(true);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
QObject::connect(&timeoutTimer, &QTimer::timeout, [&]() {
QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&]() {
reply->abort();
loop.quit();
});

View File

@@ -841,7 +841,8 @@ RemotePage::RemotePage(QWidget *parent, Context *context) : QWidget(parent), con
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(fields, 0, Qt::Alignment());
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
if (fields->invisibleRootItem()->childCount() > 0)
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
}
qint32
@@ -851,7 +852,8 @@ RemotePage::saveClicked()
QList<CmdMap> cmdMaps = remote->getMappings(); // Load the remote control mappings
for (int i = 0; i < cmdMaps.size(); i++) {
int cmdIndex = fields->invisibleRootItem()->child(i)->data(1, Qt::DisplayRole).toInt();
QTreeWidgetItem *item = fields->invisibleRootItem()->child(i);
int cmdIndex = item ? item->data(1, Qt::DisplayRole).toInt() : -1;
if (cmdIndex) {
cmdMaps[i].setAntCmdId(antCmds[cmdIndex - 1].getCmdId());
} else {
@@ -1143,7 +1145,8 @@ WorkoutTagManagerPage::WorkoutTagManagerPage
connect(tw->model(), SIGNAL(dataChanged(const QModelIndex&, const QModelIndex&, const QVector<int>&)), this, SLOT(dataChanged(const QModelIndex&, const QModelIndex&, const QVector<int>&)));
connect(dynamic_cast<QObject*>(tagStore), SIGNAL(tagsChanged(int, int, int)), this, SLOT(tagStoreChanged(int, int, int)));
tw->setCurrentItem(tw->invisibleRootItem()->child(0));
if (tw->invisibleRootItem()->childCount() > 0)
tw->setCurrentItem(tw->invisibleRootItem()->child(0));
}
@@ -1442,7 +1445,8 @@ ColorsPage::searchFilter(QString text)
for(int i=0; i<colors->invisibleRootItem()->childCount(); i++) {
if (empty) colors->setRowHidden(i, colors->rootIndex(), false);
else {
QString text = colors->invisibleRootItem()->child(i)->text(1);
QTreeWidgetItem *item = colors->invisibleRootItem()->child(i);
QString text = item ? item->text(1) : "";
bool found=false;
foreach(QString tok, toks) {
if (text.contains(tok, Qt::CaseInsensitive)) {
@@ -1550,6 +1554,7 @@ ColorsPage::saveClicked()
// run down and get the current colors and save
for (int i=0; colorSet[i].name != ""; i++) {
QTreeWidgetItem *current = colors->invisibleRootItem()->child(i);
if (!current) continue;
QColor newColor = ((ColorButton*)colors->itemWidget(current, 2))->getColor();
QString colorstring = QString("%1:%2:%3").arg(newColor.red())
.arg(newColor.green())
@@ -2165,7 +2170,8 @@ KeywordsPage::KeywordsPage(MetadataPage *parent, QList<KeywordDefinition>keyword
});
});
keywords->setCurrentItem(keywords->invisibleRootItem()->child(0));
if (keywords->invisibleRootItem()->childCount() > 0)
keywords->setCurrentItem(keywords->invisibleRootItem()->child(0));
}
void
@@ -2286,6 +2292,7 @@ KeywordsPage::getDefinitions(QList<KeywordDefinition> &keywordList)
for (int idx =0; idx < keywords->invisibleRootItem()->childCount(); idx++) {
KeywordDefinition add;
QTreeWidgetItem *item = keywords->invisibleRootItem()->child(idx);
if (!item) continue;
add.name = item->text(0);
add.color = ((ColorButton*)keywords->itemWidget(item, 1))->getColor();
@@ -2900,7 +2907,8 @@ FieldsPage::FieldsPage(QWidget *parent, QList<FieldDefinition>fieldDefinitions)
});
});
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
if (fields->invisibleRootItem()->childCount() > 0)
fields->setCurrentItem(fields->invisibleRootItem()->child(0));
}
void
@@ -2999,6 +3007,7 @@ FieldsPage::getDefinitions(QList<FieldDefinition> &fieldList)
FieldDefinition add;
QTreeWidgetItem *item = fields->invisibleRootItem()->child(idx);
if (!item) continue;
// silently ignore duplicates
if (checkdups.contains(item->text(1))) continue;
@@ -3104,6 +3113,7 @@ ProcessorPage::saveClicked()
// write away separately
for (int i = 0; i < processorTree->invisibleRootItem()->childCount(); i++) {
QTreeWidgetItem *item = processorTree->invisibleRootItem()->child(i);
if (!item) continue;
QString id = item->data(PROCESSORTREE_COL_ID, Qt::DisplayRole).toString();
if (dps.contains(id)) {
@@ -3266,6 +3276,7 @@ ProcessorPage::reload
QTreeWidgetItem *selItem = nullptr;
for (int i = 0; i < processorTree->invisibleRootItem()->childCount(); ++i) {
QTreeWidgetItem *nextItem = processorTree->invisibleRootItem()->child(i);
if (!nextItem) continue;
if (! nextItem->isHidden()) {
selItem = nextItem;
}
@@ -3285,6 +3296,7 @@ ProcessorPage::reload
QTreeWidgetItem *selItem = nullptr;
for (int i = 0; i <= selectRow && i < processorTree->invisibleRootItem()->childCount(); ++i) {
QTreeWidgetItem *nextItem = processorTree->invisibleRootItem()->child(i);
if (!nextItem) continue;
if (! nextItem->isHidden()) {
selItem = nextItem;
}
@@ -3367,6 +3379,7 @@ ProcessorPage::toggleCoreProcessors
QTreeWidgetItem *firstVisible = nullptr;
for (int i = 0; i < processorTree->invisibleRootItem()->childCount(); ++i) {
QTreeWidgetItem *item = processorTree->invisibleRootItem()->child(i);
if (!item) continue;
bool isCore = item->data(PROCESSORTREE_COL_CORE, Qt::DisplayRole).toBool();
item->setHidden(checked && (! checked || isCore));
if (firstVisible == nullptr && ! item->isHidden()) {
@@ -3490,7 +3503,8 @@ DefaultsPage::DefaultsPage
connect(actionButtons, &ActionButtonBox::addRequested, this, &DefaultsPage::addClicked);
connect(actionButtons, &ActionButtonBox::deleteRequested, this, &DefaultsPage::deleteClicked);
defaults->setCurrentItem(defaults->invisibleRootItem()->child(0));
if (defaults->invisibleRootItem()->childCount() > 0)
defaults->setCurrentItem(defaults->invisibleRootItem()->child(0));
}
void
@@ -3561,6 +3575,7 @@ DefaultsPage::getDefinitions(QList<DefaultDefinition> &defaultList)
DefaultDefinition add;
QTreeWidgetItem *item = defaults->invisibleRootItem()->child(idx);
if (!item) continue;
add.field = sp.internalName(item->text(0));
add.value = item->text(1);

View File

@@ -0,0 +1,30 @@
#include <QTest>
#include "testSignalSafety.cpp"
#include "testPatternDetection.cpp"
#include "testTreeSafety.cpp"
#include <QCoreApplication> // Added for QCoreApplication
int main(int argc, char *argv[])
{
int status = 0;
QCoreApplication app(argc, argv);
{
TestSignalSafety tc;
status |= QTest::qExec(&tc, argc, argv);
}
{
TestPatternDetection tc;
status |= QTest::qExec(&tc, argc, argv);
}
{
TestTreeSafety tc;
status |= QTest::qExec(&tc, argc, argv);
}
return status;
}

View File

@@ -0,0 +1,12 @@
QT += testlib
QT -= gui
TARGET = testSignalSafety
CONFIG += console
CONFIG -= app_bundle
TEMPLATE = app
include(../../unittests.pri)
SOURCES += main.cpp

View File

@@ -0,0 +1,62 @@
#include <QTest>
#include <QProcess>
#include <QCoreApplication>
#include <QDir>
#include <iostream>
class TestPatternDetection : public QObject
{
Q_OBJECT
private slots:
void testUnsafeConnects() {
// Find the script. We assume a relative path from the build dir or source dir.
// The build dir is inside unittests/, so the script is in ../../util/check_unsafe_connects.py
// We need to pass the source root (../../src) to it.
QDir sourceDir(QCoreApplication::applicationDirPath());
// Walk up from .obj/ or similar if needed, but typically we are in unittests/Core/signalSafety
// Let's rely on relative paths from the project root if we launch from there,
// OR construct it relative to the source tree.
// This is a bit brittle depending on where the test is run from.
// However, we can try to find the util directory.
QString scriptPath = "../../../util/check_unsafe_connects.py";
QString srcPath = "../../../src";
// Check if script exists
if (!QFile::exists(scriptPath)) {
// Try another common location (if running from build dir deep structure)
// We configured unittests to build in unittests/
scriptPath = "../../../../util/check_unsafe_connects.py";
srcPath = "../../../../src";
}
if (!QFile::exists(scriptPath)) {
QSKIP("Could not find check_unsafe_connects.py script. Skipping pattern detection test.");
}
QProcess process;
QStringList args;
args << scriptPath << srcPath;
process.start("python3", args);
bool started = process.waitForStarted();
QVERIFY2(started, "Failed to start python3 process");
bool finished = process.waitForFinished(30000); // 30 sec timeout
QVERIFY2(finished, "Pattern detection script timed out");
int exitCode = process.exitCode();
if (exitCode != 0) {
std::cout << process.readAllStandardOutput().toStdString() << std::endl;
std::cerr << process.readAllStandardError().toStdString() << std::endl;
}
QCOMPARE(exitCode, 0);
}
};
#include "testPatternDetection.moc"

Binary file not shown.

View File

@@ -0,0 +1,78 @@
#include <QTest>
#include <QObject>
#include <QSignalSpy>
class Sender : public QObject
{
Q_OBJECT
signals:
void theSignal();
};
class Receiver : public QObject
{
Q_OBJECT
public:
int callCount = 0;
};
class TestSignalSafety : public QObject
{
Q_OBJECT
private slots:
void testMissingReceiver() {
// This test mimics the "missing receiver" bug.
// We want to show that if we DON'T provide a context object, the lambda is connected to the connection handle,
// and if the "conceptual" receiver dies, the lambda still runs.
// NOTE: In a real app this leads to a crash if the lambda captures 'this'.
Sender sender;
int callCount = 0;
{
Receiver receiver;
// UNSAFE PATTERN: No context object.
// The connection is tied to 'sender' life, not 'receiver' life.
connect(&sender, &Sender::theSignal, [&]() {
// accessing 'receiver' here after it dies would use dangling reference/pointer
// For this test we just increment a counter to show it runs.
callCount++;
});
// Emit while receiver is alive -> should run
emit sender.theSignal();
QCOMPARE(callCount, 1);
} // receiver dies here
// Emit after receiver died -> should STILL run (unsafe!)
emit sender.theSignal();
QCOMPARE(callCount, 2);
}
void testFixedPattern() {
// This test mimics the fix.
// We provide the context object (receiver). When it dies, the connection is auto-disconnected.
Sender sender;
int callCount = 0;
{
Receiver receiver;
// SAFE PATTERN: Context object provided as 3rd arg.
connect(&sender, &Sender::theSignal, &receiver, [&]() {
callCount++;
});
// Emit while receiver is alive -> should run
emit sender.theSignal();
QCOMPARE(callCount, 1);
} // receiver dies here -> connection should be removed
// Emit after receiver died -> should NOT run
emit sender.theSignal();
QCOMPARE(callCount, 1);
}
};
#include "testSignalSafety.moc"

View File

@@ -0,0 +1,60 @@
#include <QTest>
#include <QProcess>
#include <QCoreApplication>
#include <QDir>
#include <iostream>
class TestTreeSafety : public QObject
{
Q_OBJECT
private slots:
void testUnsafeChildAccess() {
QDir sourceDir(QCoreApplication::applicationDirPath());
QString scriptPath = "../../../../util/check_unsafe_tree_child.py";
QString srcPath = "../../../../src";
// Adjust paths if needed
if (!QFile::exists(scriptPath)) {
// Try build dir depth = 3
scriptPath = "../../../util/check_unsafe_tree_child.py";
srcPath = "../../../src";
}
if (!QFile::exists(scriptPath)) {
QSKIP("Could not find check_unsafe_tree_child.py. Skipping.");
}
QProcess process;
QStringList args;
args << scriptPath << srcPath;
process.start("python3", args);
bool started = process.waitForStarted();
QVERIFY2(started, "Failed to start scripts");
bool finished = process.waitForFinished(30000);
QVERIFY2(finished, "Script timed out");
int exitCode = process.exitCode();
// The script prints warnings but currently returns 0.
// We should PROBABLY fail if we want to prevent regressions.
// But since there are 19 existing issues, we might want to check if the output count > 0
// to verify it works, but maybe not fail yet?
// Actually the user wants to ADD TESTS.
// The script returns 0 even if found? Let's check the script.
// If we want to prevent NEW ones, we need exclusions or a baseline.
// For now, let's just assert execution and maybe print output.
// Implementing strict failure later.
if (process.readAllStandardOutput().contains("UNSAFE CHAINING")) {
// For now, just warn or print.
// QWARN("Found unsafe tree chaining! See output.");
}
QCOMPARE(exitCode, 0);
}
};
#include "testTreeSafety.moc"

View File

@@ -0,0 +1,14 @@
QT += testlib
QT += gui
TARGET = testSplineCrash
CONFIG += console
CONFIG -= app_bundle
TEMPLATE = app
include(../../unittests.pri)
INCLUDEPATH += ../../../qwt/src
SOURCES += testSplineCrash.cpp \
../../../src/Core/SplineLookup.cpp

Binary file not shown.

View File

@@ -0,0 +1,20 @@
#include <QTest>
#include <QObject>
#include "Core/SplineLookup.h"
class TestSplineCrash : public QObject
{
Q_OBJECT
private slots:
void testEmptySpline() {
SplineLookup spline;
// Verify it is empty (default state)
// Calling valueY(10.0) should return 10.0 and NOT crash.
double val = spline.valueY(10.0);
QCOMPARE(val, 10.0);
}
};
QTEST_MAIN(TestSplineCrash)
#include "testSplineCrash.moc"

View File

@@ -9,6 +9,8 @@ equals(GC_UNITTESTS, active) {
Core/season \
Core/seasonParser \
Core/units \
Core/signalSafety \
Core/splineCrash \
Gui/calendarData
CONFIG += ordered
} else {

View File

@@ -0,0 +1,114 @@
import re
import os
import sys
# List of known legacy violations to exclude.
# Format: "RelativeFilePath:LineNumber" or just "RelativeFilePath" if we want to exclude the whole file.
# We will use "RelativeFilePath:LineNumber" for precision.
EXCLUSIONS = {}
def check_file(filepath, relative_path):
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
unsafe_matches = []
indices = [m.start() for m in re.finditer(r'connect\s*\(', content)]
for idx in indices:
chunk = content[idx:idx+500]
inner = chunk[chunk.find('(')+1:]
args = []
current_arg = ""
paren_depth = 0
in_quote = False
for char in inner:
if char == '"':
in_quote = not in_quote
elif not in_quote:
if char == '(':
paren_depth += 1
elif char == ')':
if paren_depth == 0:
args.append(current_arg.strip())
break
paren_depth -= 1
elif char == ',' and paren_depth == 0:
args.append(current_arg.strip())
current_arg = ""
continue
current_arg += char
if len(args) >= 3:
third_arg = args[2]
if third_arg.startswith('['):
line_no = content[:idx].count('\n') + 1
# Check exclusion
if relative_path in EXCLUSIONS and line_no in EXCLUSIONS[relative_path]:
continue
# We also check if the exclusion is off by one or two lines due to edits?
# For now strict line check.
unsafe_matches.append((line_no, chunk.split('\n')[0]))
return unsafe_matches
def scan_directory(root_dir):
print(f"Scanning {root_dir}...")
count = 0
found_violations = 0
# Normalize path for exclusion check
# We assume we are running from root or passed the src root.
# If root_dir is absolute, we need to handle it.
base_path = os.path.abspath(root_dir)
# If we are given say /.../src, then relative path for exclusion should simply be src/...
# But our exclusions start with 'src/'.
# Let's try to detect the 'src' part.
parent_of_src = os.path.dirname(base_path)
if os.path.basename(base_path) == 'src':
project_root = parent_of_src
else:
project_root = base_path # fallback
for root, dirs, files in os.walk(root_dir):
for file in files:
if file.endswith('.cpp') or file.endswith('.h'):
filepath = os.path.join(root, file)
# Create relative path for exclusion check (e.g., src/Gui/Agenda.cpp)
rel_path = os.path.relpath(filepath, project_root)
matches = check_file(filepath, rel_path)
if matches:
print(f"File: {rel_path}")
for line, text in matches:
print(f" Line {line}: UNSAFE PATTERN (New Violation)")
found_violations += len(matches)
count += 1
if found_violations > 0:
print(f"FAILED: Found {found_violations} new unsafe signal connections.")
sys.exit(1)
else:
print(f"SUCCESS: Scanned {count} files. No new unsafe patterns found.")
sys.exit(0)
if __name__ == "__main__":
if len(sys.argv) > 1:
scan_directory(sys.argv[1])
else:
# Default assume running from repo root
if os.path.exists("src"):
scan_directory("src")
elif os.path.exists("../src"):
scan_directory("../src")
else:
print("Could not find src directory.")
sys.exit(1)

View File

@@ -0,0 +1,63 @@
import re
import os
import sys
def check_file(filepath):
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
# Pattern: invisibleRootItem()->child(index)
# We want to catch cases where this is used directly without checking the result.
# It's hard to prove "without checking" via regex, but we can look for immediate dereference or usage strings.
# e.g. "invisibleRootItem()->child(x)->text(0)" is definitely unsafe if child(x) returns null.
# "QTreeWidgetItem *item = ...->child(x);" is okay IF followed by a check.
# Let's focus on immediate chaining which is the most dangerous:
# ->child(...)->
# or ->child(...).
warnings = []
# Regex for immediate dereference: child(...) ->
regex = re.compile(r'invisibleRootItem\(\)->child\([^)]+\)->')
for i, line in enumerate(content.splitlines()):
if regex.search(line):
warnings.append((i+1, line.strip()))
return warnings
def scan_directory(root_dir):
print(f"Scanning {root_dir}...")
count = 0
found = 0
for root, dirs, files in os.walk(root_dir):
for file in files:
if file.endswith('.cpp') or file.endswith('.h'):
filepath = os.path.join(root, file)
matches = check_file(filepath)
if matches:
print(f"File: {filepath}")
for line, text in matches:
print(f" Line {line}: UNSAFE CHAINING: {text}")
found += len(matches)
count += 1
print(f"Scanned {count} files. Found {found} potential unsafe usages.")
return found
if __name__ == "__main__":
found = 0
if len(sys.argv) > 1:
found = scan_directory(sys.argv[1])
else:
if os.path.exists("src"):
found = scan_directory("src")
elif os.path.exists("../src"):
found = scan_directory("../src")
else:
print("Could not find src directory.")
sys.exit(1)
if found > 0:
sys.exit(1)
sys.exit(0)