mudlet/test/functional_tests/UnitProcessingDepthTest.cpp
Vadim Peretokin 49dd25f106
infrastructure: take re-entrancy depth counts off with scope guards (#9687)
#### Brief overview of PR changes/additions

- Converts the hand-balanced re-entrancy depth counters to
`qScopeGuard`, matching the pattern already used by `TTimer`, `TAction`,
`TScript` and `Host`: `cTelnet::mDecompressionRecursionDepth` (four
exits across a ~290-line function), `cTelnet::mLoopbackProcessingDepth`,
and `mProcessingDepth` in `AliasUnit`, `TriggerUnit` and `KeyUnit`.
- Strictly behaviour-preserving. Each guard fires exactly where the
manual decrement did, `Q_ASSERT` and the depth-0 drain (`doCleanup()`,
`mRootNodesAddedWhileProcessing.clear()`) included, and the over-limit
cap still trips on the same value and reports the same message. The
recursion cap moves from a file-local constant to
`cTelnet::scmMaxDecompressionRecursion` next to its sibling so a test
can pin the threshold.
- Adds `AliasUnit::processingDepth()` and `KeyUnit::processingDepth()`
(mirroring `TriggerUnit` and `ActionUnit`), a new
`UnitProcessingDepthTest` and a new slot in `cTelnetBufferTest` that
drive each converted exit and assert the count comes back - including an
item that deletes itself mid-pass, so the drain step is covered too.

#### Motivation for adding to Mudlet

The counters are members, so a level leaked by a future early `return`
is permanent for that object rather than for that call. Eight leaks in
`cTelnet::mDecompressionRecursionDepth` and the connection refuses all
further data for the rest of the session, endlessly printing "Too much
data to process at once, some may have been lost" - a sticky, crash-free
hang that no test or sanitizer would catch.
`KeyUnit::processDataStream()` had the same hazard in miniature: it
returned from inside its match loop, so a second copy of the
decrement-assert-drain block had to be kept in step by hand (and it ran
`doCleanup()` while the range-`for` over the list it deletes from was
still in scope). All of these are balanced correctly today; this makes
it impossible for them not to be.

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

Follows up a review note on PR #9677 ("fix: telnet NUL terminator heap
overflow"). No behaviour change, so nothing to demo.

**Test case:** full `ctest` 72/73 and busted twice (2158 successes / 1
failure), the two failures being `TKeySequenceEditTest` and `UI_spec`
`getMainWindowSize`, both reproduced with `src/` reverted to development
so neither is from this change; sabotage check - restoring the
hand-balanced form with the over-limit decrement omitted, the `KeyUnit`
match-exit decrement omitted, and the `AliasUnit` drain omitted turned
`cTelnetBufferTest` and `UnitProcessingDepthTest` red on exactly those
three points ("a recursion level was leaked", "the drain did not run"),
and restoring the guards turned them green.

Assisted-by: Claude:claude-opus-5
2026-08-06 06:05:42 +02:00

253 lines
12 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. *
***************************************************************************/
/*
* AliasUnit and KeyUnit count how deeply their processDataStream() is nested so
* that an item deleted mid-pass (the #9337 self-uninstall pattern) is only freed
* once the outermost pass has finished - see the deferral added in #9383.
*
* The count is a member, so a pass that returns without taking its level back
* off leaves the unit permanently "busy": every later doCleanup() declines to
* run and the deferred deletes are never flushed. Nothing crashes and nothing
* warns, which is why these paths are asserted directly. KeyUnit is the one that
* matters most: it returns from inside its match loop as soon as a key fires,
* which is exactly the shape of exit a hand-written decrement gets forgotten on.
*
* Run with: ctest -R UnitProcessingDepthTest -V
*/
#include <QtTest/QtTest>
#include <QScopeGuard>
#include <QTemporaryDir>
#include "AliasUnit.h"
#include "Host.h"
#include "HostManager.h"
#include "KeyUnit.h"
#include "MudletInstanceCoordinator.h"
#include "TKey.h"
#include "TLuaInterpreter.h"
#include "mudlet.h"
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 initializeQRCResourcesForUnitProcessingDepthTest();
class UnitProcessingDepthTest : public QObject
{
Q_OBJECT
private:
const QString mProfileName = qsl("UnitProcessingDepth-Test");
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
Host* mpHost = nullptr;
// Reads back Lua state through the return value rather than getLuaString(),
// which reports an absolute stack slot and so only answers correctly for the
// first call in a process.
bool luaHolds(const QString& condition) { return mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("assert(%1)").arg(condition)); }
private slots:
void initTestCase()
{
initializeQRCResourcesForUnitProcessingDepthTest();
// Keep the test hermetic: resolve the config dir to a temporary
// directory rather than the user's real profiles.
QVERIFY(mConfigDir.isValid());
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host");
mpHost = mudlet::self()->getHostManager().getHost(mProfileName);
QVERIFY(mpHost);
// A bare Host blocks script compilation until the full profile boot
// would normally clear this; the items below need to compile:
mpHost->mBlockScriptCompile = false;
}
// Applies to every slot, including any added later: a level left on after a
// pass is what silently wedges the unit, so no slot gets to end holding one.
void cleanup()
{
QCOMPARE(mpHost->getKeyUnit()->processingDepth(), 0);
QCOMPARE(mpHost->getAliasUnit()->processingDepth(), 0);
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// A key that fires returns from the middle of the match loop; one that does
// not runs the loop out. Both exits owe the unit its level back, and the
// repeat is what makes a leak visible - one leaked level looks like nothing,
// an accumulating count is what actually wedges cleanup.
void keyProcessingDepthIsHandedBackOnEveryExit()
{
auto* keyUnit = mpHost->getKeyUnit();
QCOMPARE(keyUnit->processingDepth(), 0);
// The exit under test is only taken when this is false, and a match
// reports true either way - so without pinning it, a changed default
// would quietly move this slot onto the fall-through path instead.
QCOMPARE(keyUnit->mRunAllKeyMatches, false);
QString name = qsl("depthProbeKey");
QString parent;
QString script = qsl("keyFireCount = (keyFireCount or 0) + 1");
int keycode = Qt::Key_F7;
int modifier = Qt::NoModifier;
auto [id, message] = mpHost->mLuaInterpreter.startPermKey(name, parent, keycode, modifier, script);
QVERIFY2(id > 0, qPrintable(message));
constexpr int passes = 5;
for (int pass = 1; pass <= passes; ++pass) {
QVERIFY2(keyUnit->processDataStream(Qt::Key_F7, Qt::NoModifier), "the probe key did not match, so the return-from-the-loop exit went untested");
QCOMPARE(keyUnit->processingDepth(), 0);
QVERIFY2(!keyUnit->processDataStream(Qt::Key_F8, Qt::NoModifier), "an unbound key reported a match");
QCOMPARE(keyUnit->processingDepth(), 0);
}
// Proves the matching calls really did run the key's script, so the
// depth assertions above are not passing on a loop that never matched.
QVERIFY2(luaHolds(qsl("keyFireCount == %1").arg(passes)), "the probe key matched but its script did not run once per pass");
}
// With mRunAllKeyMatches set, a match no longer returns early and every key
// gets a turn - the other way through the same function.
void keyProcessingDepthIsHandedBackWhenRunningAllMatches()
{
auto* keyUnit = mpHost->getKeyUnit();
QCOMPARE(keyUnit->processingDepth(), 0);
QList<int> probeIds;
const bool savedRunAllKeyMatches = keyUnit->mRunAllKeyMatches;
keyUnit->mRunAllKeyMatches = true;
// Hands the unit back exactly as it was found - the flag is global to
// the profile and the F9 probes would otherwise fire in later slots.
const auto restoreGuard = qScopeGuard([keyUnit, savedRunAllKeyMatches, &probeIds] {
keyUnit->mRunAllKeyMatches = savedRunAllKeyMatches;
for (const int probeId : probeIds) {
if (auto* pKey = keyUnit->getKey(probeId)) {
pKey->setIsActive(false);
}
}
});
QString parent;
QString script = qsl("allMatchCount = (allMatchCount or 0) + 1");
int keycode = Qt::Key_F9;
int modifier = Qt::NoModifier;
for (const auto& keyName : {qsl("allMatchProbeA"), qsl("allMatchProbeB")}) {
QString name = keyName;
auto [id, message] = mpHost->mLuaInterpreter.startPermKey(name, parent, keycode, modifier, script);
QVERIFY2(id > 0, qPrintable(message));
probeIds.append(id);
}
QVERIFY(keyUnit->processDataStream(Qt::Key_F9, Qt::NoModifier));
QCOMPARE(keyUnit->processingDepth(), 0);
QVERIFY2(luaHolds(qsl("allMatchCount == 2")), "only one of the two keys bound to F9 ran, so the loop did not carry on past the first match");
}
// AliasUnit has the single exit, but the same permanence applies: the level
// has to be back off before the unit is asked to process anything else.
void aliasProcessingDepthIsHandedBackOnEveryExit()
{
auto* aliasUnit = mpHost->getAliasUnit();
QCOMPARE(aliasUnit->processingDepth(), 0);
auto [id, message] = mpHost->mLuaInterpreter.startPermAlias(qsl("depthProbeAlias"), QString(), qsl("^probe$"), qsl("aliasFireCount = (aliasFireCount or 0) + 1"));
QVERIFY2(id > 0, qPrintable(message));
constexpr int passes = 5;
for (int pass = 1; pass <= passes; ++pass) {
QVERIFY2(aliasUnit->processDataStream(qsl("probe")), "the probe alias did not match");
QCOMPARE(aliasUnit->processingDepth(), 0);
QVERIFY2(!aliasUnit->processDataStream(qsl("nothing matches this")), "an unmatched command reported a match");
QCOMPARE(aliasUnit->processingDepth(), 0);
}
QVERIFY2(luaHolds(qsl("aliasFireCount == %1").arg(passes)), "the probe alias matched but its script did not run once per pass");
}
// What the count is actually for. Both items delete themselves from their
// own script, which the unit has to defer while the pass is on the stack and
// then flush - and the flush is the drain step the guard runs at depth 0.
// Nothing else pumps cleanup here: Host::slot_purgeTemps() needs an event
// loop this test never spins, so if the drain does not run the item survives.
void anItemThatKillsItselfMidPassIsFreedByTheDrain()
{
auto* aliasUnit = mpHost->getAliasUnit();
const int aliasId = mpHost->mLuaInterpreter.startTempAlias(qsl("^selfkill$"), qsl("killAlias(tostring(selfKillAliasId))"));
QVERIFY(aliasId > 0);
QVERIFY(mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("selfKillAliasId = %1").arg(aliasId)));
QVERIFY2(aliasUnit->getAlias(aliasId), "the temp alias was not registered");
QVERIFY2(aliasUnit->processDataStream(qsl("selfkill")), "the self-killing alias did not match");
QCOMPARE(aliasUnit->processingDepth(), 0);
QVERIFY2(!aliasUnit->getAlias(aliasId), "the alias killed itself mid-pass but was never freed - the drain did not run");
auto* keyUnit = mpHost->getKeyUnit();
int keycode = Qt::Key_F10;
int modifier = Qt::NoModifier;
const int keyId = mpHost->mLuaInterpreter.startTempKey(modifier, keycode, qsl("killKey(tostring(selfKillKeyId))"));
QVERIFY(keyId > 0);
QVERIFY(mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("selfKillKeyId = %1").arg(keyId)));
QVERIFY2(keyUnit->getKey(keyId), "the temp key was not registered");
QVERIFY2(keyUnit->processDataStream(Qt::Key_F10, Qt::NoModifier), "the self-killing key did not match");
QCOMPARE(keyUnit->processingDepth(), 0);
QVERIFY2(!keyUnit->getKey(keyId), "the key killed itself mid-pass but was never freed - the drain did not run");
}
};
void initializeQRCResourcesForUnitProcessingDepthTest()
{
#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 "UnitProcessingDepthTest.moc"
QTEST_MAIN(UnitProcessingDepthTest)