mudlet/test/functional_tests/ConnectionDialogCrashTest.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

413 lines
18 KiB
C++
Raw Permalink Normal View History

Fix three crashes in the game selection screen (#9702) #### Brief overview of PR changes/additions - **Right-clicking the games list with nothing selected killed Mudlet.** `dlgConnectionProfiles::slot_profileContextMenu()` dereferenced `currentItem()` unguarded. That line is byte-identical in 4.22.0, so the null deref itself is long-standing and latent - what is new is that it became reachable: "improve: split the games list into My games and All games tabs" (#9452) leaves a user with no saved profiles an empty but still right-clickable "My games" tab, a state 4.22.0's always-populated list never had. About 40 seconds into a fresh install. - **Copying a profile while the list was rebuilt was a use-after-free.** The copy runs on a thread pool and its completion handler kept the `QListWidgetItem*` it had made; clicking the other games tab meanwhile calls `fillout_form()`, which destroys every item. The handler now finds the copy by name, and the `QFutureWatcher` is parented so it cannot outlive the dialog. - **Quitting before the connection dialog had been shown dereferenced null.** The queued `0ms` lambda in `mudlet::slot_showConnectionDialog()` used `mpConnectionDialog`, which `mudlet::closeEvent()` closes (it is `WA_DeleteOnClose`) and clears. #### Motivation for adding to Mudlet All three came out of the 5.0 QA sweep and are confirmed with AddressSanitizer. The first is the serious one - it is the default state of a brand-new install, so a new user can lose Mudlet before they have connected to anything. Scope note on the third: it is **not** a 5.0 regression. It has been there since "Fix: Improve tab indicators and detached window UX" (#7965) and is unchanged in 4.22.0; #9493 only turned the literal `0` into `0ms`. Nor could I reach it by clicking: I drove *Games -> Close profile* followed by quitting at six delays from 0 to 2000 ms and the dialog was always painted first. It reproduces deterministically in-process, and QA reproduced it 2/2 driving the close from Lua. Worth guarding - the pointer is documented to go null - but latent rather than routinely hit. #### Other info (issues closed, discussion etc) Test case: `ctest -R ConnectionDialogCrashTest` - with the fix reverted, four of its tests reproduce the original ASan reports exactly (two SEGVs in `slot_profileContextMenu`, a heap-use-after-free in `slot_itemClicked`, the SEGV in `QWidget::show()` from the lambda); two more are controls that pass either way, one of them pinning that the menu still opens for a selected profile so the guard cannot degenerate into an unconditional early return. Full suite 79/79. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:30 +02:00
/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
* *
* This program 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. *
* *
* This program 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 this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
/*
* Crashes of the connection dialog, driven through the real dialog against an
* isolated config directory. Reaching the end of a test is most of what it
* asserts; the rest pins the behaviour that replaced the crash.
*
* Run with: ctest -R ConnectionDialogCrashTest -V
*/
#include "MudletInstanceCoordinator.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
#include <QtTest/QtTest>
#include <QAbstractScrollArea>
#include <QContextMenuEvent>
#include <QMenu>
#include <QPushButton>
#include <QTabBar>
#include <chrono>
using namespace std::chrono_literals;
extern void qInitResources_mudlet();
extern void qInitResources_qm();
extern void qInitResources_additional_splash_screens();
extern void qInitResources_mudlet_fonts_common();
extern void qInitResources_mudlet_fonts_posix();
static void initializeQRCResources()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
class ConnectionDialogCrashTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir mXdgDir;
QByteArray mSavedXdg;
// not created in initTestCase(): the first test needs a profiles/ with
// nothing in it at all
const QString mProfileName = qsl("ConnDialogCrash-Test");
// what copyProfileWidget() names the copy of a name not ending in a digit
const QString mCopyName = qsl("ConnDialogCrash-Test1");
const QString mQuietProfileName = qsl("ConnDialogCrash-Quiet");
const QString mQuietCopyName = qsl("ConnDialogCrash-Quiet1");
static constexpr int scmMyGamesTab = 0;
static constexpr int scmAllGamesTab = 1;
static constexpr int scmTestMarkerRole = Qt::UserRole + 99;
const QString mProfileUrl = qsl("mudlet.org");
const QString mProfilePort = qsl("23");
// setupConfig() consults portable.txt before the XDG logic, so its presence
// would put this test on the user's real config dir (see ConfigDirOverrideTest)
bool portableMarkerPresent() const
{
return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath()));
}
// by name: QTabWidget gives the dialog a second QTabBar
QTabBar* gamesTabBar(dlgConnectionProfiles* dialog) const { return dialog->findChild<QTabBar*>(qsl("gamesTabBar")); }
// members, not locals of the calling test: with no menu the timer is still
// armed when that method returns
QStringList mMenuActionTexts;
bool mSawMenu = false;
QString mUnexpectedPopup;
// menu.exec() runs its own event loop, so the menu can only be inspected and
// dismissed from inside it; callers stop the timer when no menu appears
QTimer* armMenuCloser()
{
mMenuActionTexts.clear();
mSawMenu = false;
mUnexpectedPopup.clear();
auto* closer = new QTimer(this);
closer->setInterval(20);
connect(closer, &QTimer::timeout, this, [this, closer]() {
auto* popup = QApplication::activePopupWidget();
if (!popup) {
return;
}
auto* menu = qobject_cast<QMenu*>(popup);
if (!menu) {
// menu.exec() waits on whatever holds the popup, so close it
// rather than time the run out
mUnexpectedPopup = QString::fromLatin1(popup->metaObject()->className());
popup->close();
closer->stop();
return;
}
mSawMenu = true;
const auto actions = menu->actions();
for (const auto* action : actions) {
mMenuActionTexts << action->text();
}
menu->close();
closer->stop();
});
closer->start();
return closer;
}
void disarmMenuCloser(QTimer* closer)
{
closer->stop();
closer->deleteLater();
}
QString menuOutcome() const { return mUnexpectedPopup.isEmpty() ? QString() : qsl(" (a %1 took the popup instead)").arg(mUnexpectedPopup); }
// Must go to the viewport: QAbstractScrollArea ignores a mouse-reason
// context menu sent to itself, and its viewportEvent() is what raises
// customContextMenuRequested.
void rightClickBelowTheLastItem(QAbstractScrollArea* view) const
{
auto* viewport = view->viewport();
const QPoint pos(viewport->width() / 2, viewport->height() - 4);
QContextMenuEvent event(QContextMenuEvent::Mouse, pos, viewport->mapToGlobal(pos));
QApplication::sendEvent(viewport, &event);
}
// reports instead of QVERIFYing: a QVERIFY here would only leave the helper
bool makeProfileFolder(const QString& name) const
{
return QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, name)) && mudlet::self()->writeProfileData(name, qsl("url"), mProfileUrl).first
&& mudlet::self()->writeProfileData(name, qsl("port"), mProfilePort).first;
}
private slots:
void initTestCase()
{
if (portableMarkerPresent()) {
QSKIP("portable.txt present - cannot redirect the config dir for this test");
}
initializeQRCResources();
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(mXdgDir.isValid());
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mXdgDir.path()))); // profiles/ = XDG opt-in
Fix three crashes in the game selection screen (#9702) #### Brief overview of PR changes/additions - **Right-clicking the games list with nothing selected killed Mudlet.** `dlgConnectionProfiles::slot_profileContextMenu()` dereferenced `currentItem()` unguarded. That line is byte-identical in 4.22.0, so the null deref itself is long-standing and latent - what is new is that it became reachable: "improve: split the games list into My games and All games tabs" (#9452) leaves a user with no saved profiles an empty but still right-clickable "My games" tab, a state 4.22.0's always-populated list never had. About 40 seconds into a fresh install. - **Copying a profile while the list was rebuilt was a use-after-free.** The copy runs on a thread pool and its completion handler kept the `QListWidgetItem*` it had made; clicking the other games tab meanwhile calls `fillout_form()`, which destroys every item. The handler now finds the copy by name, and the `QFutureWatcher` is parented so it cannot outlive the dialog. - **Quitting before the connection dialog had been shown dereferenced null.** The queued `0ms` lambda in `mudlet::slot_showConnectionDialog()` used `mpConnectionDialog`, which `mudlet::closeEvent()` closes (it is `WA_DeleteOnClose`) and clears. #### Motivation for adding to Mudlet All three came out of the 5.0 QA sweep and are confirmed with AddressSanitizer. The first is the serious one - it is the default state of a brand-new install, so a new user can lose Mudlet before they have connected to anything. Scope note on the third: it is **not** a 5.0 regression. It has been there since "Fix: Improve tab indicators and detached window UX" (#7965) and is unchanged in 4.22.0; #9493 only turned the literal `0` into `0ms`. Nor could I reach it by clicking: I drove *Games -> Close profile* followed by quitting at six delays from 0 to 2000 ms and the dialog was always painted first. It reproduces deterministically in-process, and QA reproduced it 2/2 driving the close from Lua. Worth guarding - the pointer is documented to go null - but latent rather than routinely hit. #### Other info (issues closed, discussion etc) Test case: `ctest -R ConnectionDialogCrashTest` - with the fix reverted, four of its tests reproduce the original ASan reports exactly (two SEGVs in `slot_profileContextMenu`, a heap-use-after-free in `slot_itemClicked`, the SEGV in `QWidget::show()` from the lambda); two more are controls that pass either way, one of them pinning that the menu still opens for a selected profile so the guard cannot degenerate into an unconditional early return. Full suite 79/79. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:30 +02:00
qputenv("XDG_CONFIG_HOME", mXdgDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
QVERIFY(mudlet::getMudletPath(enums::profilesPath).startsWith(mXdgDir.path()));
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
mudlet::self()->startAutoLogin({});
// the dialog is only shown from a queued lambda, so the pointer turning
// up is not enough
QVERIFY(QTest::qWaitFor(
[]() {
return mudlet::self()->mpConnectionDialog && mudlet::self()->mpConnectionDialog->isVisible();
},
5000));
}
void cleanupTestCase()
{
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
delete mudlet::self();
}
void test_rightClickWithNoProfileSelected()
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
QVERIFY2(dialog, "No connection dialog to test against");
auto* skipButton = dialog->findChild<QPushButton*>(qsl("skipToGamesButton"));
QVERIFY2(skipButton, "The first-launch invitation has no skip button any more");
QVERIFY2(skipButton->isVisible(), "This is not a first-launch dialog - the skip button is not shown");
skipButton->click();
QTest::qWait(100ms);
auto* tabBar = gamesTabBar(dialog);
QVERIFY2(tabBar, "The games list has no tab bar");
tabBar->setCurrentIndex(scmMyGamesTab);
QTest::qWait(100ms);
// not an empty list: a debug build still lists the self-test entry, but
// none of it is on disk so fillout_form() makes nothing current
QVERIFY2(!dialog->listWidget_profiles->currentItem(),
qPrintable(
qsl("The 'My games' tab of a fresh install selected something (%1 items listed) - this test no longer covers the reported crash").arg(dialog->listWidget_profiles->count())));
auto* closer = armMenuCloser();
rightClickBelowTheLastItem(dialog->listWidget_profiles);
disarmMenuCloser(closer);
QVERIFY2(!mSawMenu, "A context menu was offered with no profile for it to act on");
QVERIFY2(mUnexpectedPopup.isEmpty(), qPrintable(menuOutcome()));
QVERIFY2(!QApplication::activePopupWidget(), "A popup was left on screen");
}
void test_rightClickOnAnEmptyList()
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
QVERIFY2(dialog, "No connection dialog to test against");
dialog->listWidget_profiles->clear();
QCOMPARE(dialog->listWidget_profiles->count(), 0);
auto* closer = armMenuCloser();
rightClickBelowTheLastItem(dialog->listWidget_profiles);
disarmMenuCloser(closer);
QVERIFY2(!mSawMenu, "A context menu was offered for an empty games list");
QVERIFY2(mUnexpectedPopup.isEmpty(), qPrintable(menuOutcome()));
dialog->fillout_form();
QTest::qWait(100ms);
}
// so that "return early when nothing is current" cannot become "return early"
void test_contextMenuStillOpensForASelectedProfile()
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
QVERIFY2(dialog, "No connection dialog to test against");
auto* tabBar = gamesTabBar(dialog);
QVERIFY(tabBar);
tabBar->setCurrentIndex(scmAllGamesTab);
QTest::qWait(100ms);
QVERIFY2(dialog->listWidget_profiles->count() > 0, "The 'All games' tab lists nothing");
dialog->listWidget_profiles->setCurrentRow(0);
QVERIFY2(dialog->listWidget_profiles->currentItem(), "Could not select a profile to open the menu for");
auto* closer = armMenuCloser();
rightClickBelowTheLastItem(dialog->listWidget_profiles);
disarmMenuCloser(closer);
QVERIFY2(mSawMenu, qPrintable(qsl("No context menu appeared for a selected profile%1").arg(menuOutcome())));
// "Set custom icon" and "Set custom color" for a profile without one
QCOMPARE(mMenuActionTexts.size(), 2);
QVERIFY2(!mMenuActionTexts.first().isEmpty(), "The menu offered a nameless action");
}
void test_copiedProfileSurvivesTheListBeingRebuilt()
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
QVERIFY2(dialog, "No connection dialog to test against");
QVERIFY(makeProfileFolder(mProfileName));
QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).removeRecursively();
auto* tabBar = gamesTabBar(dialog);
QVERIFY(tabBar);
tabBar->setCurrentIndex(scmMyGamesTab);
dialog->fillout_form();
// clear slot_itemClicked()'s 100ms same-profile debounce, which would
// otherwise leave the form blank after fillout_form() cleared it
QTest::qWait(300ms);
const auto items = dialog->findData(*dialog->listWidget_profiles, mProfileName, dlgConnectionProfiles::csmNameRole);
QVERIFY2(!items.isEmpty(), "The test profile is not listed in the dialog");
dialog->listWidget_profiles->setCurrentItem(items.first());
dialog->slot_itemClicked(items.first());
QCOMPARE(dialog->profile_name_entry->text(), mProfileName);
auto* copyAction = dialog->findChild<QAction*>(qsl("copyProfile"));
QVERIFY2(copyAction, "The dialog has no Copy action any more");
dialog->slot_copyProfile();
QVERIFY2(!copyAction->isEnabled(), "The copy did not take the asynchronous path");
// the copy reports back through the event loop, which has not run since,
// so this destroys the copy's item before the handler sees it
tabBar->setCurrentIndex(scmAllGamesTab);
QVERIFY2(QTest::qWaitFor(
[copyAction]() {
return copyAction->isEnabled();
},
15000),
"The copy never completed");
QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).exists(), "The copy has no folder on disk");
QCOMPARE(dialog->readProfileData(mCopyName, qsl("url")), mProfileUrl);
QCOMPARE(dialog->readProfileData(mCopyName, qsl("port")), mProfilePort);
QVERIFY2(!dialog->findData(*dialog->listWidget_profiles, mCopyName, dlgConnectionProfiles::csmNameRole).isEmpty(), "The copy is not listed in the games list");
// No assertions on the form fields: a copy completes inside
// slot_itemClicked()'s 100ms debounce, which swallows the fill and
// leaves Server address and Port blank - a separate bug.
auto* pCurrentItem = dialog->listWidget_profiles->currentItem();
QVERIFY2(pCurrentItem, "Nothing is selected after the copy finished");
QCOMPARE(pCurrentItem->data(dlgConnectionProfiles::csmNameRole).toString(), mCopyName);
QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).removeRecursively();
QDir(mudlet::getMudletPath(enums::profileHomePath, mProfileName)).removeRecursively();
dialog->fillout_form();
QTest::qWait(100ms);
}
// the branch where the copy's item is still there and still current
void test_copiedProfileIsSelectedWhenTheListIsLeftAlone()
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
QVERIFY2(dialog, "No connection dialog to test against");
QVERIFY(makeProfileFolder(mQuietProfileName));
QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).removeRecursively();
auto* tabBar = gamesTabBar(dialog);
QVERIFY(tabBar);
tabBar->setCurrentIndex(scmMyGamesTab);
dialog->fillout_form();
QTest::qWait(300ms);
const auto items = dialog->findData(*dialog->listWidget_profiles, mQuietProfileName, dlgConnectionProfiles::csmNameRole);
QVERIFY2(!items.isEmpty(), "The test profile is not listed in the dialog");
dialog->listWidget_profiles->setCurrentItem(items.first());
dialog->slot_itemClicked(items.first());
QCOMPARE(dialog->profile_name_entry->text(), mQuietProfileName);
auto* copyAction = dialog->findChild<QAction*>(qsl("copyProfile"));
QVERIFY(copyAction);
dialog->slot_copyProfile();
QVERIFY2(!copyAction->isEnabled(), "The copy did not take the asynchronous path");
const auto created = dialog->findData(*dialog->listWidget_profiles, mQuietCopyName, dlgConnectionProfiles::csmNameRole);
QVERIFY2(!created.isEmpty(), "The copy got no entry in the list");
// a mark outlives a rebuild check; a pointer would have been freed by one
created.first()->setData(scmTestMarkerRole, true);
QVERIFY2(QTest::qWaitFor(
[copyAction]() {
return copyAction->isEnabled();
},
15000),
"The copy never completed");
QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).exists(), "The copy has no folder on disk");
QCOMPARE(dialog->readProfileData(mQuietCopyName, qsl("url")), mProfileUrl);
QCOMPARE(dialog->readProfileData(mQuietCopyName, qsl("port")), mProfilePort);
auto* pCurrentItem = dialog->listWidget_profiles->currentItem();
QVERIFY2(pCurrentItem, "Nothing is selected after the copy finished");
QCOMPARE(pCurrentItem->data(dlgConnectionProfiles::csmNameRole).toString(), mQuietCopyName);
QVERIFY2(pCurrentItem->data(scmTestMarkerRole).toBool(), "The list was rebuilt after all - this test no longer covers the undisturbed branch");
QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).removeRecursively();
QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietProfileName)).removeRecursively();
dialog->fillout_form();
QTest::qWait(100ms);
}
// Must stay last: leaves the main window hidden and no connection dialog,
// both of which the other tests need.
void test_connectionDialogClosedBeforeItIsShown()
{
auto* mudletApp = mudlet::self();
if (mudletApp->mpConnectionDialog) {
mudletApp->mpConnectionDialog->close();
mudletApp->mpConnectionDialog = nullptr;
QTest::qWait(200ms);
}
QVERIFY2(!mudletApp->mpConnectionDialog, "Could not get rid of the connection dialog this test starts from");
mudletApp->slot_showConnectionDialog();
QVERIFY2(mudletApp->mpConnectionDialog, "No connection dialog was created");
// what closeEvent() does, with the event loop not having run since
// slot_showConnectionDialog() queued its lambda
QVERIFY2(mudletApp->isVisible(), "The main window has to start out visible for the hide() below to mean anything");
mudletApp->mpConnectionDialog->close();
mudletApp->mpConnectionDialog = nullptr;
mudletApp->hide();
QVERIFY2(!mudletApp->isVisible(), "The main window did not hide");
QTest::qWait(300ms); // the queued lambda gets its turn in here
QVERIFY2(!mudletApp->mpConnectionDialog, "The queued lambda brought the connection dialog back");
QVERIFY2(!mudletApp->isVisible(), "The queued lambda re-showed the main window Mudlet was shutting down");
}
};
QTEST_MAIN(ConnectionDialogCrashTest)
#include "ConnectionDialogCrashTest.moc"