mudlet/test/functional_tests/SubCommandLineLifetimeTest.cpp
Vadim Peretokin a140261d68
Fix six command line and console bugs (#9683)
#### Brief overview of PR changes/additions

- Deleting the window that owns a command line no longer leaves a
dangling `TCommandLine*` behind in `mSubCommandLineMap`. A new
`TMainConsole::registerSubCommandLine()` is the single place that map is
written, and it hooks `destroyed()` so the entry goes when the widget
does.
- Seven command line Lua functions located their mandatory string at
`lua_gettop(L)`, which is index `0` when they are called with no
arguments - not a valid Lua stack index, so they silently operated on
whatever an earlier call had left on the stack. The index is now
clamped, and `selectCmdLineText` pushes a real result instead of handing
back a stack leftover.
- `scrollUp`/`scrollDown` report an unknown window instead of raising,
and `prefix()`/`suffix()` only colour the text they add - `suffix()`
also puts it after the last character of the line rather than before it.

#### Motivation for adding to Mudlet

The dangling pointer is the serious one. The command line is a child
widget of the miniconsole, user window or scroll box it lives in, so Qt
frees it along with that parent while the map entry survives.
`TConsole::setFont()` walks the whole map, and that walk is reached from
`Host::setDisplayFont()` - so once any package has created and then
deleted a window carrying a command line, simply changing the display
font, its size or its antialiasing in Settings reads freed memory. No
Lua is involved, and most dereferences land in Qt's text internals, so
crashes from this are likely being filed as unrelated Qt text-layout
bugs.

The rest are Lua API correctness: calls that quietly act on unrelated
data, a return value that is really the C function object, a guard that
never fires, and colour and insert positions that land on the wrong
text.

#### Other info (issues closed, discussion etc)

Closes #9643, closes #9647, closes #9651, closes #9652, closes #9661,
closes #9674

`selectCmdLineText` now returns `true`; its wiki entry needs updating to
match.

**Test case:** with the fix reverted, the new
`SubCommandLineLifetimeTest` fails three assertions and then aborts on
an AddressSanitizer heap-use-after-free in `TCommandLine::console()`
from `TConsole::setFont()`, and 17 of the 20 new and un-pended Lua specs
fail too. With it, busted is 2246/0 twice over and ctest is 72/72
serially.

Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:44 +02:00

340 lines
15 KiB
C++

/***************************************************************************
* 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. *
***************************************************************************/
/*
* Regression test for a dangling TCommandLine* left behind in
* TMainConsole::mSubCommandLineMap when the widget that owns the command line
* is deleted.
*
* A TCommandLine is always a child widget of some other widget (the miniconsole
* it is embedded in, or the user window / scroll box it was created into), so
* Qt's parent-child ownership frees it along with that parent. The map entry
* registered in TConsole::setCmdVisible() / TMainConsole::createCommandLine()
* survived, leaving a non-null pointer to freed memory that every later lookup
* of that name dereferenced.
*
* The last test here is the one that needs no Lua at all: TConsole::setFont()
* walks the whole map and calls console() on every entry, and that walk is
* reached from Host::setDisplayFont(), i.e. from changing the display font in
* Preferences.
*
* Bootstrap mirrors the other functional tests (e.g. TUserWindowTest).
*/
#include <QSignalSpy>
#include <QtTest/QtTest>
#include <chrono>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TCommandLine.h"
#include "TConsole.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
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();
void initializeQRCResourcesForSubCommandLineTest();
class SubCommandLineLifetimeTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mHostname = "SubCommandLine-Test-Host";
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = "localhost";
// The free happens through deleteLater(), so it only lands once control
// returns to the event loop - which is exactly what makes the stale entry
// point at freed memory rather than at a doomed but still live widget.
void runDeferredDeletes()
{
QTest::qWait(50ms);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QCoreApplication::processEvents();
}
private slots:
// Start mudlet and create a profile once for all tests.
void initTestCase()
{
initializeQRCResourcesForSubCommandLineTest();
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
QTimer::singleShot(0ms, qApp, [this]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mHostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mPort);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(1000)) {
QFAIL("Profile took too long to load.");
}
mpHost = mudlet::self()->getActiveHost();
if (!mpHost) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(500)) {
QFAIL("Could not connect with the host.");
}
}
void cleanupTestCase()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
delete mudlet::self();
}
void init()
{
QVERIFY(mpHost);
QVERIFY(mpHost->mpConsole);
}
// Route 1: enableCommandLine() on a miniconsole, then deleteMiniConsole().
// The command line is a child of the miniconsole, so the miniconsole's
// destruction frees it.
void test_miniConsoleCommandLineDeregistersWhenConsoleDeleted()
{
TMainConsole* console = mpHost->mpConsole;
const QString name = qsl("doomedMiniConsole");
TConsole* miniConsole = console->createMiniConsole(QString(), name, 0, 0, 300, 100);
QVERIFY2(miniConsole, "could not create the miniconsole");
miniConsole->setCmdVisible(true); // what Lua enableCommandLine(name) does
QVERIFY2(console->mSubCommandLineMap.contains(name), "command line not registered after enabling it");
auto [deleted, deleteMsg] = console->deleteMiniConsole(name);
QVERIFY2(deleted, qPrintable(deleteMsg));
runDeferredDeletes();
QVERIFY2(!console->mSubCommandLineMap.contains(name), "stale command line entry left behind after deleting the miniconsole that owned it");
// The observable non-crashing symptom of the stale entry: the name still
// looks taken, so a fresh command line of that name cannot be made.
auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
console->deleteCommandLine(name);
runDeferredDeletes();
}
// Route 2: createCommandLine() into a scroll box, then deleteScrollBox().
void test_scrollBoxCommandLineDeregistersWhenScrollBoxDeleted()
{
TMainConsole* console = mpHost->mpConsole;
const QString scrollBoxName = qsl("doomedScrollBox");
const QString cmdLineName = qsl("scrollBoxCmdLine");
QVERIFY2(console->createScrollBox(QString(), scrollBoxName, 0, 0, 300, 200), "could not create the scroll box");
auto [created, createMsg] = console->createCommandLine(scrollBoxName, cmdLineName, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
QVERIFY(console->mSubCommandLineMap.contains(cmdLineName));
auto [deleted, deleteMsg] = console->deleteScrollBox(scrollBoxName);
QVERIFY2(deleted, qPrintable(deleteMsg));
runDeferredDeletes();
QVERIFY2(!console->mSubCommandLineMap.contains(cmdLineName), "stale command line entry left behind after deleting the scroll box that owned it");
// Observable consequence: the name is free again.
auto [recreated, recreateMsg] = console->createCommandLine(QString(), cmdLineName, 0, 0, 100, 30);
QVERIFY2(recreated, qPrintable(recreateMsg));
console->deleteCommandLine(cmdLineName);
runDeferredDeletes();
}
// Route 3: createCommandLine() into a user window, then deleteMiniConsole()
// on that user window - the dock owns the command line's parent widget.
void test_userWindowCommandLineDeregistersWhenWindowDeleted()
{
TMainConsole* console = mpHost->mpConsole;
const QString windowName = qsl("doomedUserWindow");
const QString cmdLineName = qsl("userWindowCmdLine");
auto [opened, openMsg] = mpHost->openWindow(windowName, /*loadLayout=*/false, /*autoDock=*/true, qsl("l"));
QVERIFY2(opened, qPrintable(openMsg));
auto [created, createMsg] = console->createCommandLine(windowName, cmdLineName, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
QVERIFY(console->mSubCommandLineMap.contains(cmdLineName));
auto [deleted, deleteMsg] = console->deleteMiniConsole(windowName);
QVERIFY2(deleted, qPrintable(deleteMsg));
runDeferredDeletes();
QVERIFY2(!console->mSubCommandLineMap.contains(cmdLineName), "stale command line entry left behind after deleting the user window that owned it");
auto [recreated, recreateMsg] = console->createCommandLine(QString(), cmdLineName, 0, 0, 100, 30);
QVERIFY2(recreated, qPrintable(recreateMsg));
console->deleteCommandLine(cmdLineName);
runDeferredDeletes();
}
// deleteCommandLine() must not leave the entry behind either - it takes the
// entry itself, so the destructor has to cope with the name already gone.
void test_deleteCommandLineDeregisters()
{
TMainConsole* console = mpHost->mpConsole;
const QString name = qsl("explicitlyDeletedCmdLine");
auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
auto [deleted, deleteMsg] = console->deleteCommandLine(name);
QVERIFY2(deleted, qPrintable(deleteMsg));
runDeferredDeletes();
QVERIFY2(!console->mSubCommandLineMap.contains(name), "command line entry left behind after deleteCommandLine()");
// Recreating under the same name must work.
auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(recreated, qPrintable(recreateMsg));
console->deleteCommandLine(name);
runDeferredDeletes();
}
// The no-Lua route: changing the display font in Preferences ends up in
// Host::setDisplayFont() -> TConsole::setFont(), which walks the whole
// mSubCommandLineMap and calls console() on every entry. With a stale entry
// present that is a read of freed memory (a clean heap-use-after-free under
// AddressSanitizer). Kept last so the cheaper assertions above report first.
void test_changingDisplayFontAfterDeletedWindowIsSafe()
{
TMainConsole* console = mpHost->mpConsole;
const QString name = qsl("fontWalkMiniConsole");
TConsole* miniConsole = console->createMiniConsole(QString(), name, 0, 0, 300, 100);
QVERIFY2(miniConsole, "could not create the miniconsole");
miniConsole->setCmdVisible(true);
QVERIFY(console->mSubCommandLineMap.contains(name));
auto [deleted, deleteMsg] = console->deleteMiniConsole(name);
QVERIFY2(deleted, qPrintable(deleteMsg));
runDeferredDeletes();
QFont changedFont = mpHost->getDisplayFont();
changedFont.setPointSize(changedFont.pointSize() == 12 ? 14 : 12);
auto [fontSet, fontMsg] = mpHost->setDisplayFont(changedFont);
QVERIFY2(fontSet, qPrintable(fontMsg));
QVERIFY2(!console->mSubCommandLineMap.contains(name), "stale command line entry survived into the setFont() walk");
auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(recreated, qPrintable(recreateMsg));
console->deleteCommandLine(name);
runDeferredDeletes();
}
// Erasure has to be by value, not by name: a replacement registered under the
// same name before the old widget's deferred delete has run must survive it.
void test_recreatingBeforeTheDeferredDeleteKeepsTheNewCommandLine()
{
TMainConsole* console = mpHost->mpConsole;
const QString name = qsl("reusedCmdLineName");
auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
auto [deleted, deleteMsg] = console->deleteCommandLine(name);
QVERIFY2(deleted, qPrintable(deleteMsg));
// Deliberately no event loop turn here - the old widget is still alive.
auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(recreated, qPrintable(recreateMsg));
TCommandLine* replacement = console->mSubCommandLineMap.value(name);
QVERIFY(replacement);
runDeferredDeletes();
QVERIFY2(console->mSubCommandLineMap.value(name) == replacement, "the old command line's deregistration took the replacement with it");
console->deleteCommandLine(name);
runDeferredDeletes();
}
// Kept last on purpose: it leaves a registered command line behind, so that
// cleanupTestCase()'s teardown destroys the console with one still in its
// widget tree. ~TMainConsole has to drop its destroyed() handler first - that
// handler runs from ~QWidget, which is after the console's own members,
// mSubCommandLineMap included, have already been destroyed.
void test_destroyingTheConsoleWithALiveCommandLineIsSafe()
{
TMainConsole* console = mpHost->mpConsole;
const QString name = qsl("outlivesTheConsole");
auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30);
QVERIFY2(created, qPrintable(createMsg));
QVERIFY(console->mSubCommandLineMap.contains(name));
}
};
void initializeQRCResourcesForSubCommandLineTest()
{
#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();
}
#include "SubCommandLineLifetimeTest.moc"
QTEST_MAIN(SubCommandLineLifetimeTest)