Fix temporary trigger/alias/key/timer cleanup evicting same-named items (#9682)
#### Brief overview of PR changes/additions
- An expired trigger is deactivated before it is queued for deletion, so
a nested `feedTriggers()` pass cannot fire it again while the deferred
delete is still pending.
- Deleting a temporary trigger, alias, key or timer unlinks only that
item from the by-name lookup table instead of every item filed under the
same name, and `killAlias()`/`killKey()`/`killTimer()` scan past a
same-named item they cannot kill rather than report failure over it.
- `AliasUnit` and `KeyUnit` gain the double-free guards `TriggerUnit`
and `TimerUnit` already had; `stopAllNamedTriggers()` and
`IDMgr:emergencyStop()` now stop named regex triggers too.
#### Motivation for adding to Mudlet
The four lookup tables are `QMultiMap`s, so names are not unique, but
the temporary-item branch used the single-argument `remove(key)` and
evicted live same-named items with it: a permanent trigger could stay
alive yet become invisible to `enableTrigger()`, `killTrigger()` and
`exists()` for the rest of the session. The kill-by-name asymmetry is
the same defect one level up - a permanent item restored from the
profile precedes this session's temporaries in the root node list, so it
stranded the temporary behind it.
#### Other info (issues closed, discussion etc)
Closes #9646, closes #9648, closes #9649, closes #9650
Test case: `permRegexTrigger("Health", "", {"^permanent$"},
[[echo("permanent fired\n")]])`, then `tempComplexRegexTrigger("Health",
"^temp$", [[]], 0,0,0,0,0,0,0,0,0,0)`, `killTrigger("Health")` and
`feedTriggers("permanent\n")` - `exists("Health", "trigger")` still
finds the permanent trigger.
New coverage: `test/functional_tests/UnitDeferredDeleteTest.cpp` (17
cases across all four units) plus additions to `Trigger_spec.lua`,
`Alias_spec.lua`, `KeyBinds_spec.lua` and `IDManager_spec.lua`, three of
which were `pending()` markers for these bugs.
Review turned up an adjacent defect deliberately **not** fixed here:
expiry is accounted for after `execute()` runs, so a trigger whose *own*
script re-feeds the matching line overshoots its `expireAfter`. Fixing
that means moving the expiry accounting ahead of `execute()` while
keeping the "return true to extend" contract, so it is left for a
follow-up and recorded as a `pending()` spec in `Trigger_spec.lua`.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:28 +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. *
|
|
|
|
|
***************************************************************************/
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* TriggerUnit, AliasUnit, KeyUnit and TimerUnit each defer the deletion of an
|
|
|
|
|
* item until no script is on the call stack. Two properties of that machinery
|
|
|
|
|
* are checked here for all four units:
|
|
|
|
|
*
|
|
|
|
|
* - freeing a temporary item must unlink only that item from the by-name lookup
|
|
|
|
|
* table, not every item filed under the same name (#9649). The lookup table is
|
|
|
|
|
* a QMultiMap and names are not unique
|
|
|
|
|
* - killing by name must keep scanning past same-named items it cannot kill,
|
|
|
|
|
* rather than report failure over the first one (#9649)
|
|
|
|
|
* - the two deferred-delete containers, mCleanupSet and uninstallList, must never
|
|
|
|
|
* free the same object twice, whichever order it lands in them (#9650)
|
|
|
|
|
*
|
|
|
|
|
* The timer half of #9649 cannot be reached from the busted Lua suite - that runs
|
|
|
|
|
* inside a tempTimer, so TimerUnit's cleanup stays deferred for the whole run -
|
|
|
|
|
* which is why it lives here. The trigger, alias and key halves are covered from
|
|
|
|
|
* Lua as well, in Trigger_spec.lua, Alias_spec.lua and KeyBinds_spec.lua.
|
|
|
|
|
*
|
|
|
|
|
* Note on the ...ContainersStayDisjoint cases: a regression there is a double
|
|
|
|
|
* free, which has no post-condition to read back - the assertions below hold
|
|
|
|
|
* either way and the run aborts instead. That is a real signal because the
|
|
|
|
|
* functional tests always build with the address sanitizer on non-Windows
|
|
|
|
|
* (test/functional_tests/CMakeLists.txt includes EnableSanitizers.cmake, whose
|
|
|
|
|
* USE_SANITIZER defaults to "address"), but it does mean these four cases carry
|
|
|
|
|
* no weight in a build with sanitizers switched off. The trigger and timer
|
|
|
|
|
* variants are pure regression guards: those two units already had the guards on
|
|
|
|
|
* development, and only AliasUnit and KeyUnit gain them here.
|
|
|
|
|
*
|
|
|
|
|
* Run with: ctest -R UnitDeferredDeleteTest -V
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#include <QtTest/QtTest>
|
|
|
|
|
#include <chrono>
|
|
|
|
|
|
|
|
|
|
#include "AliasUnit.h"
|
|
|
|
|
#include "Host.h"
|
|
|
|
|
#include "KeyUnit.h"
|
|
|
|
|
#include "MudletInstanceCoordinator.h"
|
|
|
|
|
#include "TAlias.h"
|
|
|
|
|
#include "TKey.h"
|
|
|
|
|
#include "TLuaInterpreter.h"
|
|
|
|
|
#include "TTimer.h"
|
|
|
|
|
#include "TTrigger.h"
|
|
|
|
|
#include "TelnetServerStub.h"
|
|
|
|
|
#include "TimerUnit.h"
|
|
|
|
|
#include "TriggerUnit.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 initializeQRCResourcesForUnitDeferredDeleteTest();
|
|
|
|
|
|
|
|
|
|
class UnitDeferredDeleteTest : public QObject
|
|
|
|
|
{
|
|
|
|
|
Q_OBJECT
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
TelnetServerStub* mpServer = nullptr;
|
|
|
|
|
Host* mpHost = nullptr;
|
|
|
|
|
const QString mHostname = "UnitDeferredDelete-Test";
|
|
|
|
|
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
|
|
|
|
|
const QString mLocalhost = "localhost";
|
|
|
|
|
const QString mPackageName = "unit deferred delete package";
|
|
|
|
|
|
|
|
|
|
// QMultiMap::count() is a qsizetype; narrow it so QCOMPARE reports a plain
|
|
|
|
|
// number against the int literals below
|
|
|
|
|
static int lookupCount(qsizetype count) { return static_cast<int>(count); }
|
|
|
|
|
|
|
|
|
|
private slots:
|
|
|
|
|
void initTestCase()
|
|
|
|
|
{
|
|
|
|
|
initializeQRCResourcesForUnitDeferredDeleteTest();
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
deleteProfileDirectory(mHostname);
|
|
|
|
|
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
mpHost = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(mpHost, "No active host after profile creation");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void cleanupTestCase()
|
|
|
|
|
{
|
|
|
|
|
mpHost = nullptr;
|
|
|
|
|
delete mpServer;
|
|
|
|
|
mpServer = nullptr;
|
|
|
|
|
deleteProfileDirectory(mHostname);
|
|
|
|
|
delete mudlet::self();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #9649: temporary items are named after their id, so a permanent item called
|
|
|
|
|
// after that number shares the name. tempComplexRegexTrigger() makes the
|
|
|
|
|
// trigger case even easier - it takes a user-supplied name - and that variant
|
|
|
|
|
// is covered from Lua in Trigger_spec.lua.
|
|
|
|
|
void test_triggerLookupKeepsSameNamedPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("lookup_evict_trigger_temp"), QString());
|
|
|
|
|
QVERIFY(tempId > 0);
|
|
|
|
|
const QString sharedName = QString::number(tempId);
|
|
|
|
|
|
|
|
|
|
const QStringList permPatterns{qsl("lookup_evict_trigger_perm")};
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(sharedName, QString(), permPatterns, QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTrigger(sharedName), "the temporary trigger should be the one killed");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1);
|
|
|
|
|
QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTrigger(permId));
|
|
|
|
|
QVERIFY2(unit->enableTrigger(sharedName), "the permanent trigger must still be reachable by name");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_aliasLookupKeepsSameNamedPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getAliasUnit();
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempAlias(qsl("^lookup_evict_alias$"), QString());
|
|
|
|
|
QVERIFY(tempId > 0);
|
|
|
|
|
const QString sharedName = QString::number(tempId);
|
|
|
|
|
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermAlias(sharedName, QString(), qsl("^lookup_evict_alias_perm$"), QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killAlias(sharedName), "the temporary alias should be the one killed");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1);
|
|
|
|
|
QCOMPARE(unit->mLookupTable.value(sharedName), unit->getAlias(permId));
|
|
|
|
|
QVERIFY2(unit->enableAlias(sharedName), "the permanent alias must still be reachable by name");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_timerLookupKeepsSameNamedPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTimerUnit();
|
|
|
|
|
auto [tempId, tempMessage] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false);
|
|
|
|
|
QVERIFY2(tempId > 0, qPrintable(tempMessage));
|
|
|
|
|
const QString sharedName = QString::number(tempId);
|
|
|
|
|
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermTimer(sharedName, QString(), 60.0, QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTimer(sharedName), "the temporary timer should be the one killed");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1);
|
|
|
|
|
QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTimer(permId));
|
|
|
|
|
QVERIFY2(unit->enableTimer(sharedName), "the permanent timer must still be reachable by name");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_keyLookupKeepsSameNamedPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getKeyUnit();
|
|
|
|
|
QString emptyScript;
|
|
|
|
|
int tempModifier = Qt::NoModifier;
|
|
|
|
|
int tempKeyCode = Qt::Key_F5;
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempKey(tempModifier, tempKeyCode, emptyScript);
|
|
|
|
|
QVERIFY(tempId > 0);
|
|
|
|
|
QString sharedName = QString::number(tempId);
|
|
|
|
|
|
|
|
|
|
QString parent;
|
|
|
|
|
int permModifier = Qt::NoModifier;
|
|
|
|
|
int permKeyCode = Qt::Key_F6;
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermKey(sharedName, parent, permKeyCode, permModifier, emptyScript);
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killKey(sharedName), "the temporary key should be the one killed");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1);
|
|
|
|
|
QCOMPARE(unit->mLookupTable.value(sharedName), unit->getKey(permId));
|
|
|
|
|
QVERIFY2(unit->enableKey(sharedName), "the permanent key must still be reachable by name");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #9649, the kill-by-name half: killX(name) walks the root node list, which
|
|
|
|
|
// holds items in creation order, so a permanent item restored from the profile
|
|
|
|
|
// at startup precedes this session's temporaries. Giving up on the first
|
|
|
|
|
// same-named item that cannot be killed strands the killable one and reports a
|
|
|
|
|
// bare false. Each case below renames a freshly created permanent item to the
|
|
|
|
|
// id the next temporary will take, which is exactly the collision a saved
|
|
|
|
|
// profile produces; the QCOMPARE on the temporary's id makes the test fail
|
|
|
|
|
// loudly rather than silently stop testing anything if ids stop being handed
|
|
|
|
|
// out in sequence.
|
|
|
|
|
void test_triggerKillByNameScansPastPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const QStringList permPatterns{qsl("kill_order_trigger_perm")};
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("kill order placeholder"), QString(), permPatterns, QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
const QString sharedName = QString::number(permId + 1);
|
|
|
|
|
unit->getTrigger(permId)->setName(sharedName);
|
|
|
|
|
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("kill_order_trigger_temp"), QString());
|
|
|
|
|
QCOMPARE(tempId, permId + 1);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTrigger(sharedName), "killTrigger must scan past the permanent trigger to the temporary one");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTrigger(tempId), "the temporary trigger should have been freed");
|
|
|
|
|
QVERIFY(unit->getTrigger(permId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_aliasKillByNameScansPastPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getAliasUnit();
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermAlias(qsl("kill order placeholder"), QString(), qsl("^kill_order_alias_perm$"), QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
const QString sharedName = QString::number(permId + 1);
|
|
|
|
|
unit->getAlias(permId)->setName(sharedName);
|
|
|
|
|
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempAlias(qsl("^kill_order_alias_temp$"), QString());
|
|
|
|
|
QCOMPARE(tempId, permId + 1);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killAlias(sharedName), "killAlias must scan past the permanent alias to the temporary one");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getAlias(tempId), "the temporary alias should have been freed");
|
|
|
|
|
QVERIFY(unit->getAlias(permId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_timerKillByNameScansPastPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTimerUnit();
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermTimer(qsl("kill order placeholder"), QString(), 60.0, QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
const QString sharedName = QString::number(permId + 1);
|
|
|
|
|
unit->getTimer(permId)->setName(sharedName);
|
|
|
|
|
|
|
|
|
|
auto [tempId, tempMessage] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false);
|
|
|
|
|
QVERIFY2(tempId > 0, qPrintable(tempMessage));
|
|
|
|
|
QCOMPARE(tempId, permId + 1);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTimer(sharedName), "killTimer must scan past the permanent timer to the temporary one");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTimer(tempId), "the temporary timer should have been freed");
|
|
|
|
|
QVERIFY(unit->getTimer(permId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_keyKillByNameScansPastPermanent()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getKeyUnit();
|
|
|
|
|
QString emptyScript;
|
|
|
|
|
QString parent;
|
|
|
|
|
QString placeholder = qsl("kill order placeholder");
|
|
|
|
|
int permModifier = Qt::NoModifier;
|
|
|
|
|
int permKeyCode = Qt::Key_F7;
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermKey(placeholder, parent, permKeyCode, permModifier, emptyScript);
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
QString sharedName = QString::number(permId + 1);
|
|
|
|
|
unit->getKey(permId)->setName(sharedName);
|
|
|
|
|
|
|
|
|
|
int tempModifier = Qt::NoModifier;
|
|
|
|
|
int tempKeyCode = Qt::Key_F8;
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempKey(tempModifier, tempKeyCode, emptyScript);
|
|
|
|
|
QCOMPARE(tempId, permId + 1);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killKey(sharedName), "killKey must scan past the permanent key to the temporary one");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getKey(tempId), "the temporary key should have been freed");
|
|
|
|
|
QVERIFY(unit->getKey(permId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #9649 again, on the non-root removal path: a temporary child goes through
|
|
|
|
|
// removeTrigger() rather than removeTriggerRootNode(), and both got the same
|
|
|
|
|
// exact-match fix.
|
|
|
|
|
void test_temporaryChildTriggerLeavesSameNamedSiblingAlone()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const QStringList parentPatterns{qsl("child_evict_parent")};
|
|
|
|
|
auto [parentId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("Child Eviction Parent"), QString(), parentPatterns, QString());
|
|
|
|
|
QVERIFY2(parentId > 0, qPrintable(message));
|
|
|
|
|
auto* pParent = unit->getTrigger(parentId);
|
|
|
|
|
QVERIFY(pParent);
|
|
|
|
|
|
|
|
|
|
const QString sharedName = qsl("Child Eviction Shared");
|
|
|
|
|
const QStringList childPatterns{qsl("child_evict_perm")};
|
|
|
|
|
auto [permChildId, childMessage] = mpHost->mLuaInterpreter.startPermSubstringTrigger(sharedName, qsl("Child Eviction Parent"), childPatterns, QString());
|
|
|
|
|
QVERIFY2(permChildId > 0, qPrintable(childMessage));
|
|
|
|
|
|
|
|
|
|
auto* pTempChild = new TTrigger(pParent, mpHost);
|
|
|
|
|
pTempChild->setRegexCodeList(QStringList{qsl("child_evict_temp")}, QList<int>{REGEX_SUBSTRING});
|
|
|
|
|
pTempChild->setIsFolder(false);
|
|
|
|
|
pTempChild->setIsActive(true);
|
|
|
|
|
pTempChild->setTemporary(true);
|
|
|
|
|
pTempChild->registerTrigger();
|
|
|
|
|
pTempChild->setName(sharedName);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTrigger(sharedName), "the temporary child should be the one killed");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1);
|
|
|
|
|
QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTrigger(permChildId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #9650: an item can be queued in mCleanupSet and in uninstallList at the same
|
|
|
|
|
// time - uninstall() at a non-zero processing depth leaves its items in
|
|
|
|
|
// uninstallList and drops them from mCleanupSet, and a script killing one of
|
|
|
|
|
// them afterwards puts it back. doCleanup() has to free such an item exactly
|
|
|
|
|
// once. Both containers are populated directly here because reaching the
|
|
|
|
|
// overlap from Lua needs a package-owned temporary item, which no current
|
|
|
|
|
// import path produces.
|
|
|
|
|
void test_triggerDeferredDeleteContainersStayDisjoint()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("double_free_trigger"), QString(), -1);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pTrigger = unit->getTrigger(id);
|
|
|
|
|
QVERIFY(pTrigger);
|
|
|
|
|
|
|
|
|
|
unit->uninstallList.append(pTrigger);
|
|
|
|
|
unit->markCleanup(pTrigger);
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QVERIFY(unit->mCleanupSet.isEmpty());
|
|
|
|
|
QVERIFY(unit->uninstallList.isEmpty());
|
|
|
|
|
QVERIFY2(!unit->getTrigger(id), "the trigger should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #9650: uninstall() at depth 0 deletes straight away, so it also has to drop
|
|
|
|
|
// the item from mCleanupSet - otherwise the next doCleanup() frees a dangling
|
|
|
|
|
// pointer.
|
|
|
|
|
void test_triggerUninstallAtDepthZeroClearsCleanupSet()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("uninstall_trigger"), QString(), -1);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pTrigger = unit->getTrigger(id);
|
|
|
|
|
QVERIFY(pTrigger);
|
|
|
|
|
pTrigger->mPackageName = mPackageName;
|
|
|
|
|
|
|
|
|
|
unit->markCleanup(pTrigger);
|
|
|
|
|
unit->uninstall(mPackageName);
|
|
|
|
|
|
|
|
|
|
// pTrigger is freed by now, so read the set's size rather than look the
|
|
|
|
|
// dangling pointer up in it
|
|
|
|
|
QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the trigger it freed out of the cleanup set");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTrigger(id), "the trigger should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_aliasDeferredDeleteContainersStayDisjoint()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getAliasUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempAlias(qsl("^double_free_alias$"), QString());
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pAlias = unit->getAlias(id);
|
|
|
|
|
QVERIFY(pAlias);
|
|
|
|
|
|
|
|
|
|
unit->uninstallList.append(pAlias);
|
|
|
|
|
unit->markCleanup(pAlias);
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QVERIFY(unit->mCleanupSet.isEmpty());
|
|
|
|
|
QVERIFY(unit->uninstallList.isEmpty());
|
|
|
|
|
QVERIFY2(!unit->getAlias(id), "the alias should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_aliasUninstallAtDepthZeroClearsCleanupSet()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getAliasUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempAlias(qsl("^uninstall_alias$"), QString());
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pAlias = unit->getAlias(id);
|
|
|
|
|
QVERIFY(pAlias);
|
|
|
|
|
pAlias->mPackageName = mPackageName;
|
|
|
|
|
|
|
|
|
|
unit->markCleanup(pAlias);
|
|
|
|
|
unit->uninstall(mPackageName);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the alias it freed out of the cleanup set");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getAlias(id), "the alias should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_timerDeferredDeleteContainersStayDisjoint()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTimerUnit();
|
|
|
|
|
auto [id, message] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false);
|
|
|
|
|
QVERIFY2(id > 0, qPrintable(message));
|
|
|
|
|
auto* pTimer = unit->getTimer(id);
|
|
|
|
|
QVERIFY(pTimer);
|
|
|
|
|
|
|
|
|
|
unit->uninstallList.append(pTimer);
|
|
|
|
|
unit->markCleanup(pTimer);
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QVERIFY(unit->mCleanupSet.isEmpty());
|
|
|
|
|
QVERIFY(unit->uninstallList.isEmpty());
|
|
|
|
|
QVERIFY2(!unit->getTimer(id), "the timer should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_timerUninstallAtDepthZeroClearsCleanupSet()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTimerUnit();
|
|
|
|
|
auto [id, message] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false);
|
|
|
|
|
QVERIFY2(id > 0, qPrintable(message));
|
|
|
|
|
auto* pTimer = unit->getTimer(id);
|
|
|
|
|
QVERIFY(pTimer);
|
|
|
|
|
pTimer->mPackageName = mPackageName;
|
|
|
|
|
|
|
|
|
|
unit->markCleanup(pTimer);
|
|
|
|
|
unit->uninstall(mPackageName);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the timer it freed out of the cleanup set");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTimer(id), "the timer should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_keyDeferredDeleteContainersStayDisjoint()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getKeyUnit();
|
|
|
|
|
QString emptyScript;
|
|
|
|
|
int modifier = Qt::NoModifier;
|
|
|
|
|
int keyCode = Qt::Key_F10;
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempKey(modifier, keyCode, emptyScript);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pKey = unit->getKey(id);
|
|
|
|
|
QVERIFY(pKey);
|
|
|
|
|
|
|
|
|
|
unit->uninstallList.append(pKey);
|
|
|
|
|
unit->markCleanup(pKey);
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
|
|
|
|
|
QVERIFY(unit->mCleanupSet.isEmpty());
|
|
|
|
|
QVERIFY(unit->uninstallList.isEmpty());
|
|
|
|
|
QVERIFY2(!unit->getKey(id), "the key should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_keyUninstallAtDepthZeroClearsCleanupSet()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getKeyUnit();
|
|
|
|
|
QString emptyScript;
|
|
|
|
|
int modifier = Qt::NoModifier;
|
|
|
|
|
int keyCode = Qt::Key_F11;
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempKey(modifier, keyCode, emptyScript);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pKey = unit->getKey(id);
|
|
|
|
|
QVERIFY(pKey);
|
|
|
|
|
pKey->mPackageName = mPackageName;
|
|
|
|
|
|
|
|
|
|
unit->markCleanup(pKey);
|
|
|
|
|
unit->uninstall(mPackageName);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the key it freed out of the cleanup set");
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getKey(id), "the key should have been freed exactly once");
|
|
|
|
|
}
|
|
|
|
|
|
infrastructure: trim the comments left behind by two merged QA fixes (#9708)
#### Brief overview of PR changes/additions
- Comment-only. `git diff origin/development...HEAD` changes no
statement, expression or declaration - every added and removed line is a
comment. 238 comment lines become 98.
- Applies the house standard to the comments added by "fix: a trigger
that re-creates itself freezes Mudlet" (#9697) and "Fix user key
bindings on Ctrl+1 to Ctrl+9 and Ctrl+Tab" (#9703): no historical
passages, and the rest cut to what a reader cannot derive from the code.
- Corrects four claims that were wrong, two of them inherited from those
PRs: a fires-per-line measurement taken with a smaller budget than the
one that shipped, an over-general note on `shortcutInstalledFor()`, a
`KeyUnit::disableKey()` note that had the mechanism backwards, and a
test comment crediting the `isEmpty()` guard for a result it does not
produce.
#### Motivation for adding to Mudlet
Both PRs merged while their comment-reduction pass was still in flight,
so the trim never landed with them.
#### Other info (issues closed, discussion etc)
The gotchas worth keeping survive in shorter form: why the same-line
creation budget is counted per pass rather than sharing the
`feedTriggers()` depth counter, why permanent triggers get
`deactivate()` and not `setIsActive(false)`, why `mCleanupSet` rather
than the deactivation is what stops `enableTrigger()` resurrecting a
spent trigger, that `QShortcutMap` retries with consumed modifiers
stripped, and the `Key_Backtab` versus `Shift+Tab` spelling.
The matching trim for "fix: stop treating long-time Mudlet users as
brand new players" (#9695) already landed separately as #9707, so it is
not repeated here.
No demo video: a comment-only change is not observable on screen.
**Test case:** `ctest` in the build directory - 79/80, with
`TelnetBenchmark` timing out only under parallel load (31s standalone
against a 60s limit) on a path this PR does not touch.
`TriggerSameLineMatchTest`, `UnitDeferredDeleteTest`,
`ProfileSwitchShortcutTest` and `ExperiencedPlayerGateTest` all pass.
Assisted-by: Claude:claude-opus-5
2026-08-06 17:43:35 +02:00
|
|
|
// killTrigger() skips an item that is only waiting to be freed; enableTrigger()
|
|
|
|
|
// has to as well, or it resurrects the corpse.
|
fix: a trigger that re-creates itself freezes Mudlet (#9697)
#### Brief overview of PR changes/additions
- A trigger whose script creates another trigger matching the same line
kept extending the list `TriggerUnit::processDataStream()` walks, so the
line never finished: 100% CPU and RSS climbing 1.7 GB to 5.9 GB in 44
seconds, from one ordinary line of game text. Same-line matching for
triggers created mid-pass now has a budget (100 per line); when it runs
out the offending trigger is named in an error and what the loop created
during that line is stopped - temporary ones removed, permanent ones
switched off for the session only, so nothing is saved to the profile.
- `enableTrigger()` could resurrect a killed or expired temporary
trigger during the window before its deferred delete runs, so a one-shot
fired twice and a `killTrigger()`ed trigger fired 49 more times. It now
skips anything queued for cleanup, which is what makes the guarantee
`TTrigger::match()` states actually true.
- The behaviour restored by #9458 ("fix: triggers created by other
triggers react to the current line again") is kept: triggers created
while a line is being processed still match that line, chained creation
included. 10 new tests, and the 6 that pin that behaviour still pass.
#### Motivation for adding to Mudlet
Release blocker for 5.0 - the freeze is reachable from ordinary server
text with the standard "one-shot trigger that re-arms itself" idiom, and
4.22.0 was not affected.
#### Other info (issues closed, discussion etc)
5.0 QA findings C11 (hang) and C12. C11 was introduced by eb2627383
(#9458), which deliberately restored pre-#9267 same-line semantics
without bounding them; #9368's depth guard cannot see it, because
nothing recurses. The budget is deliberately its own constant rather
than the `feedTriggers()` recursion depth: the two measure different
resources, and sharing one made a pass entered deep in nested
`feedTriggers()` abort before running anything.
**Test case:** run `function arm() tempRegexTrigger("^HP: 100/100$",
[[arm()]], 1) end arm()` then `feedTriggers("HP: 100/100\n")` - on
development Mudlet freezes for good; here it reports the trigger and
carries on.
Assisted-by: Claude:claude-opus-5
2026-08-06 12:18:33 +02:00
|
|
|
void test_triggerEnableByNameCannotReviveKilled()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("resurrect_trigger"), QString(), -1);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pTrigger = unit->getTrigger(id);
|
|
|
|
|
QVERIFY(pTrigger);
|
|
|
|
|
const QString name = QString::number(id);
|
|
|
|
|
QVERIFY(pTrigger->isActive());
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTrigger(name), "the temporary trigger should be killable by name");
|
|
|
|
|
QVERIFY2(!pTrigger->isActive(), "killTrigger() must deactivate as well as queue the delete");
|
|
|
|
|
QVERIFY2(unit->mCleanupSet.contains(pTrigger), "the killed trigger should be waiting to be freed");
|
|
|
|
|
|
|
|
|
|
QVERIFY2(!unit->enableTrigger(name), "enableTrigger() must not report success for a trigger that is only waiting to be freed");
|
|
|
|
|
QVERIFY2(!pTrigger->isActive(), "a killed trigger must stay dead until it is freed");
|
|
|
|
|
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTrigger(id), "the killed trigger should still have been freed");
|
|
|
|
|
}
|
|
|
|
|
|
infrastructure: trim the comments left behind by two merged QA fixes (#9708)
#### Brief overview of PR changes/additions
- Comment-only. `git diff origin/development...HEAD` changes no
statement, expression or declaration - every added and removed line is a
comment. 238 comment lines become 98.
- Applies the house standard to the comments added by "fix: a trigger
that re-creates itself freezes Mudlet" (#9697) and "Fix user key
bindings on Ctrl+1 to Ctrl+9 and Ctrl+Tab" (#9703): no historical
passages, and the rest cut to what a reader cannot derive from the code.
- Corrects four claims that were wrong, two of them inherited from those
PRs: a fires-per-line measurement taken with a smaller budget than the
one that shipped, an over-general note on `shortcutInstalledFor()`, a
`KeyUnit::disableKey()` note that had the mechanism backwards, and a
test comment crediting the `isEmpty()` guard for a result it does not
produce.
#### Motivation for adding to Mudlet
Both PRs merged while their comment-reduction pass was still in flight,
so the trim never landed with them.
#### Other info (issues closed, discussion etc)
The gotchas worth keeping survive in shorter form: why the same-line
creation budget is counted per pass rather than sharing the
`feedTriggers()` depth counter, why permanent triggers get
`deactivate()` and not `setIsActive(false)`, why `mCleanupSet` rather
than the deactivation is what stops `enableTrigger()` resurrecting a
spent trigger, that `QShortcutMap` retries with consumed modifiers
stripped, and the `Key_Backtab` versus `Shift+Tab` spelling.
The matching trim for "fix: stop treating long-time Mudlet users as
brand new players" (#9695) already landed separately as #9707, so it is
not repeated here.
No demo video: a comment-only change is not observable on screen.
**Test case:** `ctest` in the build directory - 79/80, with
`TelnetBenchmark` timing out only under parallel load (31s standalone
against a 60s limit) on a path this PR does not touch.
`TriggerSameLineMatchTest`, `UnitDeferredDeleteTest`,
`ProfileSwitchShortcutTest` and `ExperiencedPlayerGateTest` all pass.
Assisted-by: Claude:claude-opus-5
2026-08-06 17:43:35 +02:00
|
|
|
// As a user meets it: a one-shot has spent its fire and a script later on the
|
|
|
|
|
// same line enables it by name.
|
fix: a trigger that re-creates itself freezes Mudlet (#9697)
#### Brief overview of PR changes/additions
- A trigger whose script creates another trigger matching the same line
kept extending the list `TriggerUnit::processDataStream()` walks, so the
line never finished: 100% CPU and RSS climbing 1.7 GB to 5.9 GB in 44
seconds, from one ordinary line of game text. Same-line matching for
triggers created mid-pass now has a budget (100 per line); when it runs
out the offending trigger is named in an error and what the loop created
during that line is stopped - temporary ones removed, permanent ones
switched off for the session only, so nothing is saved to the profile.
- `enableTrigger()` could resurrect a killed or expired temporary
trigger during the window before its deferred delete runs, so a one-shot
fired twice and a `killTrigger()`ed trigger fired 49 more times. It now
skips anything queued for cleanup, which is what makes the guarantee
`TTrigger::match()` states actually true.
- The behaviour restored by #9458 ("fix: triggers created by other
triggers react to the current line again") is kept: triggers created
while a line is being processed still match that line, chained creation
included. 10 new tests, and the 6 that pin that behaviour still pass.
#### Motivation for adding to Mudlet
Release blocker for 5.0 - the freeze is reachable from ordinary server
text with the standard "one-shot trigger that re-arms itself" idiom, and
4.22.0 was not affected.
#### Other info (issues closed, discussion etc)
5.0 QA findings C11 (hang) and C12. C11 was introduced by eb2627383
(#9458), which deliberately restored pre-#9267 same-line semantics
without bounding them; #9368's depth guard cannot see it, because
nothing recurses. The budget is deliberately its own constant rather
than the `feedTriggers()` recursion depth: the two measure different
resources, and sharing one made a pass entered deep in nested
`feedTriggers()` abort before running anything.
**Test case:** run `function arm() tempRegexTrigger("^HP: 100/100$",
[[arm()]], 1) end arm()` then `feedTriggers("HP: 100/100\n")` - on
development Mudlet freezes for good; here it reports the trigger and
carries on.
Assisted-by: Claude:claude-opus-5
2026-08-06 12:18:33 +02:00
|
|
|
void test_triggerEnableByNameCannotReviveExpiredOneShot()
|
|
|
|
|
{
|
|
|
|
|
mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("oneShotFires = 0\n"
|
|
|
|
|
"reviverRan = false\n"
|
|
|
|
|
"tempComplexRegexTrigger('watchOnce', '^ONESHOT$', [[oneShotFires = oneShotFires + 1]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)\n"
|
|
|
|
|
"tempRegexTrigger('^ONESHOT$', [[\n"
|
|
|
|
|
" if not reviverRan then\n"
|
|
|
|
|
" reviverRan = true\n"
|
|
|
|
|
" enableTrigger('watchOnce')\n"
|
|
|
|
|
" feedTriggers('ONESHOT\\n')\n"
|
|
|
|
|
" end\n"
|
|
|
|
|
"]], 1)\n"
|
|
|
|
|
"feedTriggers('ONESHOT\\n')\n"));
|
|
|
|
|
|
|
|
|
|
QCOMPARE(mpHost->getTriggerUnit()->processingDepth(), 0);
|
|
|
|
|
QVERIFY2(readGlobalBool(qsl("reviverRan")), "the script that calls enableTrigger() has to have run for this to test anything");
|
|
|
|
|
QCOMPARE(readGlobalInt(qsl("oneShotFires")), 1);
|
|
|
|
|
}
|
|
|
|
|
|
infrastructure: trim the comments left behind by two merged QA fixes (#9708)
#### Brief overview of PR changes/additions
- Comment-only. `git diff origin/development...HEAD` changes no
statement, expression or declaration - every added and removed line is a
comment. 238 comment lines become 98.
- Applies the house standard to the comments added by "fix: a trigger
that re-creates itself freezes Mudlet" (#9697) and "Fix user key
bindings on Ctrl+1 to Ctrl+9 and Ctrl+Tab" (#9703): no historical
passages, and the rest cut to what a reader cannot derive from the code.
- Corrects four claims that were wrong, two of them inherited from those
PRs: a fires-per-line measurement taken with a smaller budget than the
one that shipped, an over-general note on `shortcutInstalledFor()`, a
`KeyUnit::disableKey()` note that had the mechanism backwards, and a
test comment crediting the `isEmpty()` guard for a result it does not
produce.
#### Motivation for adding to Mudlet
Both PRs merged while their comment-reduction pass was still in flight,
so the trim never landed with them.
#### Other info (issues closed, discussion etc)
The gotchas worth keeping survive in shorter form: why the same-line
creation budget is counted per pass rather than sharing the
`feedTriggers()` depth counter, why permanent triggers get
`deactivate()` and not `setIsActive(false)`, why `mCleanupSet` rather
than the deactivation is what stops `enableTrigger()` resurrecting a
spent trigger, that `QShortcutMap` retries with consumed modifiers
stripped, and the `Key_Backtab` versus `Shift+Tab` spelling.
The matching trim for "fix: stop treating long-time Mudlet users as
brand new players" (#9695) already landed separately as #9707, so it is
not repeated here.
No demo video: a comment-only change is not observable on screen.
**Test case:** `ctest` in the build directory - 79/80, with
`TelnetBenchmark` timing out only under parallel load (31s standalone
against a 60s limit) on a path this PR does not touch.
`TriggerSameLineMatchTest`, `UnitDeferredDeleteTest`,
`ProfileSwitchShortcutTest` and `ExperiencedPlayerGateTest` all pass.
Assisted-by: Claude:claude-opus-5
2026-08-06 17:43:35 +02:00
|
|
|
// uninstall() at a non-zero processing depth leaves its package's triggers in
|
|
|
|
|
// uninstallList rather than mCleanupSet, still in the lookup table. Populated
|
|
|
|
|
// directly: reaching that state from Lua needs a package-owned temporary item,
|
|
|
|
|
// which no current import path produces.
|
fix: a trigger that re-creates itself freezes Mudlet (#9697)
#### Brief overview of PR changes/additions
- A trigger whose script creates another trigger matching the same line
kept extending the list `TriggerUnit::processDataStream()` walks, so the
line never finished: 100% CPU and RSS climbing 1.7 GB to 5.9 GB in 44
seconds, from one ordinary line of game text. Same-line matching for
triggers created mid-pass now has a budget (100 per line); when it runs
out the offending trigger is named in an error and what the loop created
during that line is stopped - temporary ones removed, permanent ones
switched off for the session only, so nothing is saved to the profile.
- `enableTrigger()` could resurrect a killed or expired temporary
trigger during the window before its deferred delete runs, so a one-shot
fired twice and a `killTrigger()`ed trigger fired 49 more times. It now
skips anything queued for cleanup, which is what makes the guarantee
`TTrigger::match()` states actually true.
- The behaviour restored by #9458 ("fix: triggers created by other
triggers react to the current line again") is kept: triggers created
while a line is being processed still match that line, chained creation
included. 10 new tests, and the 6 that pin that behaviour still pass.
#### Motivation for adding to Mudlet
Release blocker for 5.0 - the freeze is reachable from ordinary server
text with the standard "one-shot trigger that re-arms itself" idiom, and
4.22.0 was not affected.
#### Other info (issues closed, discussion etc)
5.0 QA findings C11 (hang) and C12. C11 was introduced by eb2627383
(#9458), which deliberately restored pre-#9267 same-line semantics
without bounding them; #9368's depth guard cannot see it, because
nothing recurses. The budget is deliberately its own constant rather
than the `feedTriggers()` recursion depth: the two measure different
resources, and sharing one made a pass entered deep in nested
`feedTriggers()` abort before running anything.
**Test case:** run `function arm() tempRegexTrigger("^HP: 100/100$",
[[arm()]], 1) end arm()` then `feedTriggers("HP: 100/100\n")` - on
development Mudlet freezes for good; here it reports the trigger and
carries on.
Assisted-by: Claude:claude-opus-5
2026-08-06 12:18:33 +02:00
|
|
|
void test_triggerEnableByNameCannotReviveAnUninstalledTrigger()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("uninstall_revive_trigger"), QString(), -1);
|
|
|
|
|
QVERIFY(id > 0);
|
|
|
|
|
auto* pTrigger = unit->getTrigger(id);
|
|
|
|
|
QVERIFY(pTrigger);
|
|
|
|
|
const QString name = QString::number(id);
|
|
|
|
|
|
|
|
|
|
pTrigger->setIsActive(false);
|
|
|
|
|
unit->uninstallList.append(pTrigger);
|
|
|
|
|
QVERIFY2(!unit->mCleanupSet.contains(pTrigger), "uninstall() keeps the two deferred-delete containers disjoint");
|
|
|
|
|
|
|
|
|
|
QVERIFY2(!unit->enableTrigger(name), "enableTrigger() must not report success for a trigger an uninstall is waiting to free");
|
|
|
|
|
QVERIFY2(!pTrigger->isActive(), "a trigger whose package has been uninstalled must stay inactive");
|
|
|
|
|
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY2(!unit->getTrigger(id), "the uninstalled trigger should still have been freed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The skip must not stop the walk: a corpse and a live trigger can share a
|
infrastructure: trim the comments left behind by two merged QA fixes (#9708)
#### Brief overview of PR changes/additions
- Comment-only. `git diff origin/development...HEAD` changes no
statement, expression or declaration - every added and removed line is a
comment. 238 comment lines become 98.
- Applies the house standard to the comments added by "fix: a trigger
that re-creates itself freezes Mudlet" (#9697) and "Fix user key
bindings on Ctrl+1 to Ctrl+9 and Ctrl+Tab" (#9703): no historical
passages, and the rest cut to what a reader cannot derive from the code.
- Corrects four claims that were wrong, two of them inherited from those
PRs: a fires-per-line measurement taken with a smaller budget than the
one that shipped, an over-general note on `shortcutInstalledFor()`, a
`KeyUnit::disableKey()` note that had the mechanism backwards, and a
test comment crediting the `isEmpty()` guard for a result it does not
produce.
#### Motivation for adding to Mudlet
Both PRs merged while their comment-reduction pass was still in flight,
so the trim never landed with them.
#### Other info (issues closed, discussion etc)
The gotchas worth keeping survive in shorter form: why the same-line
creation budget is counted per pass rather than sharing the
`feedTriggers()` depth counter, why permanent triggers get
`deactivate()` and not `setIsActive(false)`, why `mCleanupSet` rather
than the deactivation is what stops `enableTrigger()` resurrecting a
spent trigger, that `QShortcutMap` retries with consumed modifiers
stripped, and the `Key_Backtab` versus `Shift+Tab` spelling.
The matching trim for "fix: stop treating long-time Mudlet users as
brand new players" (#9695) already landed separately as #9707, so it is
not repeated here.
No demo video: a comment-only change is not observable on screen.
**Test case:** `ctest` in the build directory - 79/80, with
`TelnetBenchmark` timing out only under parallel load (31s standalone
against a 60s limit) on a path this PR does not touch.
`TriggerSameLineMatchTest`, `UnitDeferredDeleteTest`,
`ProfileSwitchShortcutTest` and `ExperiencedPlayerGateTest` all pass.
Assisted-by: Claude:claude-opus-5
2026-08-06 17:43:35 +02:00
|
|
|
// name, and enable-by-name still has to reach every live one.
|
fix: a trigger that re-creates itself freezes Mudlet (#9697)
#### Brief overview of PR changes/additions
- A trigger whose script creates another trigger matching the same line
kept extending the list `TriggerUnit::processDataStream()` walks, so the
line never finished: 100% CPU and RSS climbing 1.7 GB to 5.9 GB in 44
seconds, from one ordinary line of game text. Same-line matching for
triggers created mid-pass now has a budget (100 per line); when it runs
out the offending trigger is named in an error and what the loop created
during that line is stopped - temporary ones removed, permanent ones
switched off for the session only, so nothing is saved to the profile.
- `enableTrigger()` could resurrect a killed or expired temporary
trigger during the window before its deferred delete runs, so a one-shot
fired twice and a `killTrigger()`ed trigger fired 49 more times. It now
skips anything queued for cleanup, which is what makes the guarantee
`TTrigger::match()` states actually true.
- The behaviour restored by #9458 ("fix: triggers created by other
triggers react to the current line again") is kept: triggers created
while a line is being processed still match that line, chained creation
included. 10 new tests, and the 6 that pin that behaviour still pass.
#### Motivation for adding to Mudlet
Release blocker for 5.0 - the freeze is reachable from ordinary server
text with the standard "one-shot trigger that re-arms itself" idiom, and
4.22.0 was not affected.
#### Other info (issues closed, discussion etc)
5.0 QA findings C11 (hang) and C12. C11 was introduced by eb2627383
(#9458), which deliberately restored pre-#9267 same-line semantics
without bounding them; #9368's depth guard cannot see it, because
nothing recurses. The budget is deliberately its own constant rather
than the `feedTriggers()` recursion depth: the two measure different
resources, and sharing one made a pass entered deep in nested
`feedTriggers()` abort before running anything.
**Test case:** run `function arm() tempRegexTrigger("^HP: 100/100$",
[[arm()]], 1) end arm()` then `feedTriggers("HP: 100/100\n")` - on
development Mudlet freezes for good; here it reports the trigger and
carries on.
Assisted-by: Claude:claude-opus-5
2026-08-06 12:18:33 +02:00
|
|
|
void test_triggerEnableByNameStillReachesALiveSameNamedTrigger()
|
|
|
|
|
{
|
|
|
|
|
auto* unit = mpHost->getTriggerUnit();
|
|
|
|
|
const QStringList permPatterns{qsl("mixed_corpse_perm")};
|
|
|
|
|
auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("mixed corpse placeholder"), QString(), permPatterns, QString());
|
|
|
|
|
QVERIFY2(permId > 0, qPrintable(message));
|
|
|
|
|
const QString sharedName = QString::number(permId + 1);
|
|
|
|
|
unit->getTrigger(permId)->setName(sharedName);
|
|
|
|
|
|
|
|
|
|
const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("mixed_corpse_temp"), QString());
|
|
|
|
|
QCOMPARE(tempId, permId + 1);
|
|
|
|
|
QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->killTrigger(sharedName), "the temporary trigger should be the one killed");
|
|
|
|
|
unit->getTrigger(permId)->setIsActive(false);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(unit->enableTrigger(sharedName), "enableTrigger must walk past the corpse to the live trigger filed under the same name");
|
|
|
|
|
QVERIFY2(unit->getTrigger(permId)->isActive(), "the live same-named trigger should have been enabled");
|
|
|
|
|
QVERIFY2(!unit->getTrigger(tempId)->isActive(), "the killed trigger must stay dead");
|
|
|
|
|
|
|
|
|
|
unit->doCleanup();
|
|
|
|
|
QVERIFY(!unit->getTrigger(tempId));
|
|
|
|
|
}
|
|
|
|
|
|
Fix temporary trigger/alias/key/timer cleanup evicting same-named items (#9682)
#### Brief overview of PR changes/additions
- An expired trigger is deactivated before it is queued for deletion, so
a nested `feedTriggers()` pass cannot fire it again while the deferred
delete is still pending.
- Deleting a temporary trigger, alias, key or timer unlinks only that
item from the by-name lookup table instead of every item filed under the
same name, and `killAlias()`/`killKey()`/`killTimer()` scan past a
same-named item they cannot kill rather than report failure over it.
- `AliasUnit` and `KeyUnit` gain the double-free guards `TriggerUnit`
and `TimerUnit` already had; `stopAllNamedTriggers()` and
`IDMgr:emergencyStop()` now stop named regex triggers too.
#### Motivation for adding to Mudlet
The four lookup tables are `QMultiMap`s, so names are not unique, but
the temporary-item branch used the single-argument `remove(key)` and
evicted live same-named items with it: a permanent trigger could stay
alive yet become invisible to `enableTrigger()`, `killTrigger()` and
`exists()` for the rest of the session. The kill-by-name asymmetry is
the same defect one level up - a permanent item restored from the
profile precedes this session's temporaries in the root node list, so it
stranded the temporary behind it.
#### Other info (issues closed, discussion etc)
Closes #9646, closes #9648, closes #9649, closes #9650
Test case: `permRegexTrigger("Health", "", {"^permanent$"},
[[echo("permanent fired\n")]])`, then `tempComplexRegexTrigger("Health",
"^temp$", [[]], 0,0,0,0,0,0,0,0,0,0)`, `killTrigger("Health")` and
`feedTriggers("permanent\n")` - `exists("Health", "trigger")` still
finds the permanent trigger.
New coverage: `test/functional_tests/UnitDeferredDeleteTest.cpp` (17
cases across all four units) plus additions to `Trigger_spec.lua`,
`Alias_spec.lua`, `KeyBinds_spec.lua` and `IDManager_spec.lua`, three of
which were `pending()` markers for these bugs.
Review turned up an adjacent defect deliberately **not** fixed here:
expiry is accounted for after `execute()` runs, so a trigger whose *own*
script re-feeds the matching line overshoots its `expireAfter`. Fixing
that means moving the expiry accounting ahead of `execute()` while
keeping the "return true to extend" contract, so it is left for a
follow-up and recorded as a `pending()` spec in `Trigger_spec.lua`.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:28 +02:00
|
|
|
// Helpers (reused from the ResetProfileTest pattern)
|
|
|
|
|
|
fix: a trigger that re-creates itself freezes Mudlet (#9697)
#### Brief overview of PR changes/additions
- A trigger whose script creates another trigger matching the same line
kept extending the list `TriggerUnit::processDataStream()` walks, so the
line never finished: 100% CPU and RSS climbing 1.7 GB to 5.9 GB in 44
seconds, from one ordinary line of game text. Same-line matching for
triggers created mid-pass now has a budget (100 per line); when it runs
out the offending trigger is named in an error and what the loop created
during that line is stopped - temporary ones removed, permanent ones
switched off for the session only, so nothing is saved to the profile.
- `enableTrigger()` could resurrect a killed or expired temporary
trigger during the window before its deferred delete runs, so a one-shot
fired twice and a `killTrigger()`ed trigger fired 49 more times. It now
skips anything queued for cleanup, which is what makes the guarantee
`TTrigger::match()` states actually true.
- The behaviour restored by #9458 ("fix: triggers created by other
triggers react to the current line again") is kept: triggers created
while a line is being processed still match that line, chained creation
included. 10 new tests, and the 6 that pin that behaviour still pass.
#### Motivation for adding to Mudlet
Release blocker for 5.0 - the freeze is reachable from ordinary server
text with the standard "one-shot trigger that re-arms itself" idiom, and
4.22.0 was not affected.
#### Other info (issues closed, discussion etc)
5.0 QA findings C11 (hang) and C12. C11 was introduced by eb2627383
(#9458), which deliberately restored pre-#9267 same-line semantics
without bounding them; #9368's depth guard cannot see it, because
nothing recurses. The budget is deliberately its own constant rather
than the `feedTriggers()` recursion depth: the two measure different
resources, and sharing one made a pass entered deep in nested
`feedTriggers()` abort before running anything.
**Test case:** run `function arm() tempRegexTrigger("^HP: 100/100$",
[[arm()]], 1) end arm()` then `feedTriggers("HP: 100/100\n")` - on
development Mudlet freezes for good; here it reports the trigger and
carries on.
Assisted-by: Claude:claude-opus-5
2026-08-06 12:18:33 +02:00
|
|
|
int readGlobalInt(const QString& name)
|
|
|
|
|
{
|
|
|
|
|
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
|
|
|
|
|
lua_getglobal(L, name.toUtf8().constData());
|
|
|
|
|
const int value = static_cast<int>(lua_tointeger(L, -1));
|
|
|
|
|
lua_pop(L, 1);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool readGlobalBool(const QString& name)
|
|
|
|
|
{
|
|
|
|
|
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
|
|
|
|
|
lua_getglobal(L, name.toUtf8().constData());
|
|
|
|
|
const bool value = lua_toboolean(L, -1);
|
|
|
|
|
lua_pop(L, 1);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
Fix temporary trigger/alias/key/timer cleanup evicting same-named items (#9682)
#### Brief overview of PR changes/additions
- An expired trigger is deactivated before it is queued for deletion, so
a nested `feedTriggers()` pass cannot fire it again while the deferred
delete is still pending.
- Deleting a temporary trigger, alias, key or timer unlinks only that
item from the by-name lookup table instead of every item filed under the
same name, and `killAlias()`/`killKey()`/`killTimer()` scan past a
same-named item they cannot kill rather than report failure over it.
- `AliasUnit` and `KeyUnit` gain the double-free guards `TriggerUnit`
and `TimerUnit` already had; `stopAllNamedTriggers()` and
`IDMgr:emergencyStop()` now stop named regex triggers too.
#### Motivation for adding to Mudlet
The four lookup tables are `QMultiMap`s, so names are not unique, but
the temporary-item branch used the single-argument `remove(key)` and
evicted live same-named items with it: a permanent trigger could stay
alive yet become invisible to `enableTrigger()`, `killTrigger()` and
`exists()` for the rest of the session. The kill-by-name asymmetry is
the same defect one level up - a permanent item restored from the
profile precedes this session's temporaries in the root node list, so it
stranded the temporary behind it.
#### Other info (issues closed, discussion etc)
Closes #9646, closes #9648, closes #9649, closes #9650
Test case: `permRegexTrigger("Health", "", {"^permanent$"},
[[echo("permanent fired\n")]])`, then `tempComplexRegexTrigger("Health",
"^temp$", [[]], 0,0,0,0,0,0,0,0,0,0)`, `killTrigger("Health")` and
`feedTriggers("permanent\n")` - `exists("Health", "trigger")` still
finds the permanent trigger.
New coverage: `test/functional_tests/UnitDeferredDeleteTest.cpp` (17
cases across all four units) plus additions to `Trigger_spec.lua`,
`Alias_spec.lua`, `KeyBinds_spec.lua` and `IDManager_spec.lua`, three of
which were `pending()` markers for these bugs.
Review turned up an adjacent defect deliberately **not** fixed here:
expiry is accounted for after `execute()` runs, so a trigger whose *own*
script re-feeds the matching line overshoots its `expireAfter`. Fixing
that means moving the expiry accounting ahead of `execute()` while
keeping the "return true to extend" contract, so it is left for a
follow-up and recorded as a `pending()` spec in `Trigger_spec.lua`.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:28 +02:00
|
|
|
void startProfile(const QString& hostname, const QString& address, const QString& port)
|
|
|
|
|
{
|
|
|
|
|
QTimer::singleShot(0ms, qApp, [hostname, address, port]() {
|
|
|
|
|
mudlet::self()->startAutoLogin({});
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), hostname);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), address);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), port);
|
|
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
if (!host) {
|
|
|
|
|
QFAIL("No active host available for the test.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
|
|
|
|
|
if (!spy2.wait(500)) {
|
|
|
|
|
QFAIL("Could not connect with the host.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
|
|
|
{
|
|
|
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
|
|
|
QDir dir(path);
|
|
|
|
|
|
|
|
|
|
if (!dir.exists()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
dir.removeRecursively();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void initializeQRCResourcesForUnitDeferredDeleteTest()
|
|
|
|
|
{
|
|
|
|
|
#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 "UnitDeferredDeleteTest.moc"
|
|
|
|
|
QTEST_MAIN(UnitDeferredDeleteTest)
|