fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +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 . *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
# include <QtTest/QtTest>
# include "Host.h"
# include "MudletInstanceCoordinator.h"
# include "TLuaInterpreter.h"
# include "TMainConsole.h"
# include "TriggerUnit.h"
# include "TelnetServerStub.h"
# include "ctelnet.h"
# include "dlgConnectionProfiles.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 initializeQRCResources ( ) ;
// A trigger created from another trigger's script (tempTrigger() & Co.) must
// still get to match the line being processed. That has been observable
// behaviour for as long as trigger processing iterated the live root-node
// std::list - a push_back lands in front of end(), so the new trigger was
// reached within the same pass - and room-capture scripts ("start capture on
// the room title line, grab it and the following lines") depend on it.
// Iterating a snapshot (introduced by #9267 to fix a use-after-free) silently
// deferred such triggers to the next line and broke those scripts.
class TriggerSameLineMatchTest : public QObject
{
Q_OBJECT
private :
TelnetServerStub * mpServer = nullptr ;
const QString mpHostname = " Test-TriggerSameLineMatch " ;
const QString mpPort = " 4000 " ;
const QString mpLocalhost = " localhost " ;
private slots :
void initTestCase ( ) { initializeQRCResources ( ) ; }
void init ( )
{
mpServer = new TelnetServerStub ( qApp ) ;
mpServer - > start ( mpLocalhost , mpPort . toUShort ( ) ) ;
mudlet : : start ( ) ;
mudlet : : self ( ) - > setupConfig ( ) ;
mudlet : : self ( ) - > takeOwnershipOfInstanceCoordinator ( std : : make_unique < MudletInstanceCoordinator > ( " MudletInstanceCoordinator " ) ) ;
mudlet : : self ( ) - > init ( ) ;
mudlet : : self ( ) - > setStorePasswordsSecurely ( false ) ;
deleteProfileDirectory ( mpHostname ) ;
}
// The classic room-capture pattern: a trigger on the room title line
// creates a catch-all temp trigger, which must capture the title line
// itself, not start one line late.
void test_tempTriggerCreatedInTriggerMatchesCurrentLine ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " captured = {} \n "
" tempRegexTrigger('^Room 74042', [=[ \n "
" tempRegexTrigger('^(.*)$', [[table.insert(captured, matches[2])]], 200) \n "
" ]=]) \n "
" feedTriggers('Room 74042: The Bitter Almond Grove \\ n') \n "
" feedTriggers('Exits: North South West \\ n') \n "
" echo('CAPTURED=' .. table.concat(captured, '|') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " CAPTURED=Room 74042: The Bitter Almond Grove|Exits: North South West# " ) ) ,
" Expected the temp trigger created on the room title line to capture that same line first, then the next line " ) ;
}
// The new trigger matches the current line after every pre-existing
// trigger, mirroring where the live-list iteration used to reach it (the
// end of the list), not right after its creator.
void test_newTriggerMatchesAfterExistingTriggers ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " order = {} \n "
" tempRegexTrigger('^o$', [=[ \n "
" table.insert(order, 'first') \n "
" tempRegexTrigger('^o$', [[table.insert(order, 'created')]]) \n "
" ]=]) \n "
" tempRegexTrigger('^o$', [[table.insert(order, 'second')]]) \n "
" feedTriggers('o \\ n') \n "
" echo('ORDER=' .. table.concat(order, ',') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " ORDER=first,second,created# " ) ) , " Expected the mid-pass trigger to fire on the current line after all pre-existing triggers " ) ;
}
// A trigger created by a trigger that was itself created this pass must
// also match the current line - creation can chain within one line.
void test_chainedCreationAllMatchCurrentLine ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " chain = {} \n "
" tempRegexTrigger('^c$', [==[ \n "
" table.insert(chain, 'creator') \n "
" tempRegexTrigger('^c$', [=[ \n "
" table.insert(chain, 'A') \n "
" tempRegexTrigger('^c$', [[table.insert(chain, 'B')]]) \n "
" ]=]) \n "
" ]==]) \n "
" feedTriggers('c \\ n') \n "
" echo('CHAIN=' .. table.concat(chain, ',') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " CHAIN=creator,A,B# " ) ) , " Expected each generation of mid-pass triggers to still match the current line " ) ;
}
// A single-shot (expireAfter=1) temp trigger created mid-pass spends its
// one shot on the creating line and must not linger to the next one.
void test_singleShotFiresOnCreatingLine ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " expiryLine = '' \n "
" tempRegexTrigger('^e', [=[ \n "
" tempRegexTrigger('^(.*)$', [[expiryLine = expiryLine .. matches[2] .. ';']], 1) \n "
" ]=], 1) \n "
" feedTriggers('e one \\ n') \n "
" feedTriggers('e two \\ n') \n "
" echo('EXPIRYLINE=' .. expiryLine .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " EXPIRYLINE=e one;# " ) ) , " Expected the single-shot temp trigger to fire once, on the line that created it " ) ;
}
// tempLineTrigger(0, n, ...) created mid-pass starts counting from the
// current line, so its first capture is the creating line itself.
void test_lineTriggerStartsOnCurrentLine ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " lineGrabs = {} \n "
" tempRegexTrigger('^lstart$', [=[ \n "
" tempLineTrigger(0, 2, [[table.insert(lineGrabs, getCurrentLine())]]) \n "
" ]=], 1) \n "
" feedTriggers('lstart \\ n') \n "
" feedTriggers('second \\ n') \n "
" feedTriggers('third \\ n') \n "
" echo('LINEGRABS=' .. table.concat(lineGrabs, ',') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " LINEGRABS=lstart,second# " ) ) , " Expected the mid-pass line trigger to grab the creating line and the one after it " ) ;
}
// With nested processing (the creator also calls feedTriggers()), the new
// trigger matches the nested line while it is being processed and still
// matches the outer line afterwards - in that order.
void test_nestedFeedTriggersMatchesBothLines ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
infrastructure: guard functional-test lookups with QVERIFY (#9524)
#### Brief overview of PR changes/additions
The 12 functional-test sites that dereferenced the result of
`getActiveHost()`, `getArea()`, `getRoom()` or `getHost()` without first
checking it for null now guard the pointer with `QVERIFY(ptr)` before
use. The other lookup sites this PR touched already had a `QVERIFY` and
are left as they were.
#### Motivation for adding to Mudlet
CodeQL's `cpp/inconsistent-null-check` rule flagged these lookups
because some call sites checked the returned pointer for null while
others in the same files dereferenced it directly. Adding `QVERIFY(ptr)`
at the previously-unchecked sites makes the handling consistent: every
lookup result is verified before it is dereferenced, so a null result
fails the test loudly instead of crashing.
`QVERIFY` is the project's accepted idiom for guarding a pointer in a
test, so it is used here rather than an `if (!ptr) { QFAIL(...); }`
block. CodeQL does not recognise `QVERIFY(ptr)` as a null check (the
branch it generates tests `qVerify()`'s return value, not the pointer
itself), so it will keep reporting `cpp/inconsistent-null-check` on
these sites and on the pre-existing `QVERIFY` sites. Those alerts will
be dismissed as false positives rather than changing the test style to
satisfy the checker.
#### Other info (issues closed, discussion etc)
Test files only - no production code is touched. The 12 newly-guarded
sites are:
- `MapRoundTripTest.cpp` - 2 (`pAreaA`, `pAreaB`)
- `TriggerSameLineMatchTest.cpp` - 6 (`host`)
- `TFeedTriggersRecursionTest.cpp` - 4 (`host`)
**Test case:**
Build and run the five affected binaries under the functional-test flock
- all pass:
- `TFeedTriggersRecursionTest` - passed
- `TriggerSameLineMatchTest` - passed
- `MapRoundTripTest` - 6 passed, 0 failed
- `UndoServerWrapTest` - passed
- `UndoServerWrapReplay` - 2 passed, 1 skipped (manual replay tool;
skips without `REPLAY_CAPTURE`/`REPLAY_OUT`)
2026-07-29 13:42:04 +02:00
QVERIFY ( host ) ;
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " nested = {} \n "
" tempRegexTrigger('^outer$', [=[ \n "
" tempRegexTrigger('^(.*)$', [[table.insert(nested, matches[2])]], 10) \n "
" feedTriggers('inner \\ n') \n "
" ]=], 1) \n "
" feedTriggers('outer \\ n') \n "
" echo('NESTED=' .. table.concat(nested, ',') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " NESTED=inner,outer# " ) ) , " Expected the mid-pass trigger to match the nested line first, then the outer line it was created on " ) ;
}
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
// The naive "one-shot that re-arms itself at the end of its own handler" is
// the shape users write. Without the budget this does not fail, it hangs.
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_selfRecreatingTriggerIsStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " loopFires = 0 \n "
" function arm() \n "
" tempRegexTrigger('^hploop$', [[loopFires = loopFires + 1; arm()]], 1) \n "
" end \n "
" arm() \n "
" feedTriggers('hploop \\ n') \n "
" echo('LOOPFIRES=' .. loopFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected the same-line re-creation abort error in the console buffer " ) ;
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
// one fire from the trigger already there, then one per budgeted creation;
// the trailing # keeps the check from also passing on ten times the number
Fix triggers being deleted when a script creates a lot of them at once (#9724)
#### Brief overview of PR changes/additions
- One budget of 100 covered every root trigger created while a line was
processed, and tripping it deactivated all of them, so a script arming
101 unrelated triggers lost all 101.
- Triggers created mid-line now carry the creation lineage they belong
to and how many generations deep they sit in it. A batch is one
generation however big it is; only a trigger that re-creates itself
keeps adding generations, so that is the only shape the budget counts.
The limit is 1000 generations, and only the runaway lineage is stopped
and named.
- Generations do not bound a lineage that widens as it deepens, so past
20000 creations on one line new triggers stop being offered that line.
Nothing is disowned there - they are all still armed for the lines that
follow.
#### Motivation for adding to Mudlet
#9697 fixed a real freeze, but its counter had no lineage, so it
destroyed legitimate triggers along with the runaway. Any routine arming
more than 100 triggers from a trigger loses them, permanent ones
included, which is a regression against 4.22.0.
#### Other info (issues closed, discussion etc)
Fixes a regression introduced by merged #9697; release-blocking for 5.0.
Test case: `lua fired=0; tempTrigger("GATE", function() for i=1,200 do
tempTrigger("PAY", function() fired=fired+1 end) end end);
feedTriggers("\nGATE\n"); feedTriggers("\nPAY\n"); print(fired)` -
prints 0 before, 200 after.
A/B against a shipped 4.22.0 binary (4.22.0 / 5.0 RC / this PR), counted
on the line after the one that armed them: 1001 unrelated temp triggers
1001 / 0 / 1001; two scripts of 600 each 600+600 / 0+0 / 600+600; 1001
permanent triggers 1001 / 0 / 1001. #9697's freeze is still stopped and
bounded (601 runaway lines in 8 s, RSS flat at ~820 MB) and its own
tests still pass. Eleven new tests parameterise the creation count, and
cover nested passes and folder/filter-chain children, which none of
#9697's did - that is why this shipped.
Assisted-by: Claude:claude-opus-5
2026-08-08 08:26:28 +02:00
const int expectedFires = 1 + TriggerUnit : : scmMaxSameLineGenerations ;
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
QVERIFY2 ( bufferContains ( qsl ( " LOOPFIRES=%1# " ) . arg ( expectedFires ) ) , qPrintable ( qsl ( " Expected the re-arming trigger to fire exactly %1 times " ) . arg ( expectedFires ) ) ) ;
}
void test_selfRecreatingTriggerAbortNamesTheTrigger ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " function armNamed() \n "
" tempComplexRegexTrigger('hpWatcher', '^hpnamed$', [[armNamed()]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1) \n "
" end \n "
" armNamed() \n "
" feedTriggers('hpnamed \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " trigger 'hpWatcher' " ) ) , " Expected the abort message to name the trigger that keeps re-creating itself " ) ;
}
void test_finiteCreationChainIsUnaffected ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " chainFires = 0 \n "
" function chainStep() \n "
" chainFires = chainFires + 1 \n "
" if chainFires < 10 then \n "
" tempRegexTrigger('^chain$', [[chainStep()]], 1) \n "
" end \n "
" end \n "
" tempRegexTrigger('^chain$', [[chainStep()]], 1) \n "
" feedTriggers('chain \\ n') \n "
" echo('CHAINFIRES=' .. chainFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " CHAINFIRES=10# " ) ) , " Expected all ten generations of the finite chain to match the current line " ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A chain that ends on its own must not trip the same-line generation budget " ) ;
}
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
// Without disowning what the loop created, each line costs a multiple of the
// one before it, so the freeze is postponed rather than prevented.
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_selfRecreatingTriggerDoesNotAccumulateAcrossLines ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " keptFires = 0 \n "
" function armKept() \n "
" tempRegexTrigger('^kept$', [[keptFires = keptFires + 1; armKept()]]) \n "
" end \n "
" armKept() \n "
" feedTriggers('kept \\ n') \n "
" feedTriggers('kept \\ n') \n "
" echo('KEPTFIRES=' .. keptFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
Fix triggers being deleted when a script creates a lot of them at once (#9724)
#### Brief overview of PR changes/additions
- One budget of 100 covered every root trigger created while a line was
processed, and tripping it deactivated all of them, so a script arming
101 unrelated triggers lost all 101.
- Triggers created mid-line now carry the creation lineage they belong
to and how many generations deep they sit in it. A batch is one
generation however big it is; only a trigger that re-creates itself
keeps adding generations, so that is the only shape the budget counts.
The limit is 1000 generations, and only the runaway lineage is stopped
and named.
- Generations do not bound a lineage that widens as it deepens, so past
20000 creations on one line new triggers stop being offered that line.
Nothing is disowned there - they are all still armed for the lines that
follow.
#### Motivation for adding to Mudlet
#9697 fixed a real freeze, but its counter had no lineage, so it
destroyed legitimate triggers along with the runaway. Any routine arming
more than 100 triggers from a trigger loses them, permanent ones
included, which is a regression against 4.22.0.
#### Other info (issues closed, discussion etc)
Fixes a regression introduced by merged #9697; release-blocking for 5.0.
Test case: `lua fired=0; tempTrigger("GATE", function() for i=1,200 do
tempTrigger("PAY", function() fired=fired+1 end) end end);
feedTriggers("\nGATE\n"); feedTriggers("\nPAY\n"); print(fired)` -
prints 0 before, 200 after.
A/B against a shipped 4.22.0 binary (4.22.0 / 5.0 RC / this PR), counted
on the line after the one that armed them: 1001 unrelated temp triggers
1001 / 0 / 1001; two scripts of 600 each 600+600 / 0+0 / 600+600; 1001
permanent triggers 1001 / 0 / 1001. #9697's freeze is still stopped and
bounded (601 runaway lines in 8 s, RSS flat at ~820 MB) and its own
tests still pass. Eleven new tests parameterise the creation count, and
cover nested passes and folder/filter-chain children, which none of
#9697's did - that is why this shipped.
Assisted-by: Claude:claude-opus-5
2026-08-08 08:26:28 +02:00
const int firesPerLine = 1 + TriggerUnit : : scmMaxSameLineGenerations ;
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
QVERIFY2 ( bufferContains ( qsl ( " KEPTFIRES=%1# " ) . arg ( 2 * firesPerLine ) ) ,
qPrintable ( qsl ( " Expected the second line to cost the same %1 fires as the first, not a multiple of them " ) . arg ( firesPerLine ) ) ) ;
}
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
// Permanent triggers are saved with the profile, so they are stopped without
// being deleted and with deactivate(), which leaves the user-active state
// XMLexport writes alone.
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_selfRecreatingPermanentTriggerIsStoppedButNotDeleted ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " permFires = 0 \n "
" function armPerm() \n "
" permRegexTrigger('Perm Loop', '', {'^permloop$'}, [[permFires = permFires + 1; armPerm()]]) \n "
" end \n "
" armPerm() \n "
" feedTriggers('permloop \\ n') \n "
" echo('PERMFIRES=' .. permFires .. '# \\ n') \n "
" echo('PERMACTIVE=' .. isActive('Perm Loop', 'trigger') .. '# \\ n') \n "
" echo('PERMEXISTS=' .. exists('Perm Loop', 'trigger') .. '# \\ n') \n " ) ) ;
Fix triggers being deleted when a script creates a lot of them at once (#9724)
#### Brief overview of PR changes/additions
- One budget of 100 covered every root trigger created while a line was
processed, and tripping it deactivated all of them, so a script arming
101 unrelated triggers lost all 101.
- Triggers created mid-line now carry the creation lineage they belong
to and how many generations deep they sit in it. A batch is one
generation however big it is; only a trigger that re-creates itself
keeps adding generations, so that is the only shape the budget counts.
The limit is 1000 generations, and only the runaway lineage is stopped
and named.
- Generations do not bound a lineage that widens as it deepens, so past
20000 creations on one line new triggers stop being offered that line.
Nothing is disowned there - they are all still armed for the lines that
follow.
#### Motivation for adding to Mudlet
#9697 fixed a real freeze, but its counter had no lineage, so it
destroyed legitimate triggers along with the runaway. Any routine arming
more than 100 triggers from a trigger loses them, permanent ones
included, which is a regression against 4.22.0.
#### Other info (issues closed, discussion etc)
Fixes a regression introduced by merged #9697; release-blocking for 5.0.
Test case: `lua fired=0; tempTrigger("GATE", function() for i=1,200 do
tempTrigger("PAY", function() fired=fired+1 end) end end);
feedTriggers("\nGATE\n"); feedTriggers("\nPAY\n"); print(fired)` -
prints 0 before, 200 after.
A/B against a shipped 4.22.0 binary (4.22.0 / 5.0 RC / this PR), counted
on the line after the one that armed them: 1001 unrelated temp triggers
1001 / 0 / 1001; two scripts of 600 each 600+600 / 0+0 / 600+600; 1001
permanent triggers 1001 / 0 / 1001. #9697's freeze is still stopped and
bounded (601 runaway lines in 8 s, RSS flat at ~820 MB) and its own
tests still pass. Eleven new tests parameterise the creation count, and
cover nested passes and folder/filter-chain children, which none of
#9697's did - that is why this shipped.
Assisted-by: Claude:claude-opus-5
2026-08-08 08:26:28 +02:00
const int expectedFires = 1 + TriggerUnit : : scmMaxSameLineGenerations ;
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
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected a permanent trigger re-creating itself to be stopped too " ) ;
QVERIFY2 ( bufferContains ( qsl ( " PERMFIRES=%1# " ) . arg ( expectedFires ) ) , qPrintable ( qsl ( " Expected the re-arming permanent trigger to fire exactly %1 times " ) . arg ( expectedFires ) ) ) ;
QVERIFY2 ( bufferContains ( qsl ( " PERMACTIVE=1# " ) ) , " Expected only the trigger that predates the line to still be active " ) ;
QVERIFY2 ( bufferContains ( qsl ( " PERMEXISTS=%1# " ) . arg ( expectedFires + 1 ) ) , " Expected the stopped permanent triggers to still exist - stopping them is not deleting them " ) ;
}
Fix triggers being deleted when a script creates a lot of them at once (#9724)
#### Brief overview of PR changes/additions
- One budget of 100 covered every root trigger created while a line was
processed, and tripping it deactivated all of them, so a script arming
101 unrelated triggers lost all 101.
- Triggers created mid-line now carry the creation lineage they belong
to and how many generations deep they sit in it. A batch is one
generation however big it is; only a trigger that re-creates itself
keeps adding generations, so that is the only shape the budget counts.
The limit is 1000 generations, and only the runaway lineage is stopped
and named.
- Generations do not bound a lineage that widens as it deepens, so past
20000 creations on one line new triggers stop being offered that line.
Nothing is disowned there - they are all still armed for the lines that
follow.
#### Motivation for adding to Mudlet
#9697 fixed a real freeze, but its counter had no lineage, so it
destroyed legitimate triggers along with the runaway. Any routine arming
more than 100 triggers from a trigger loses them, permanent ones
included, which is a regression against 4.22.0.
#### Other info (issues closed, discussion etc)
Fixes a regression introduced by merged #9697; release-blocking for 5.0.
Test case: `lua fired=0; tempTrigger("GATE", function() for i=1,200 do
tempTrigger("PAY", function() fired=fired+1 end) end end);
feedTriggers("\nGATE\n"); feedTriggers("\nPAY\n"); print(fired)` -
prints 0 before, 200 after.
A/B against a shipped 4.22.0 binary (4.22.0 / 5.0 RC / this PR), counted
on the line after the one that armed them: 1001 unrelated temp triggers
1001 / 0 / 1001; two scripts of 600 each 600+600 / 0+0 / 600+600; 1001
permanent triggers 1001 / 0 / 1001. #9697's freeze is still stopped and
bounded (601 runaway lines in 8 s, RSS flat at ~820 MB) and its own
tests still pass. Eleven new tests parameterise the creation count, and
cover nested passes and folder/filter-chain children, which none of
#9697's did - that is why this shipped.
Assisted-by: Claude:claude-opus-5
2026-08-08 08:26:28 +02:00
// A script arming a batch of unrelated triggers is not a runaway, however
// big the batch: each of them starts a creation chain of its own, and none
// of those chains ever gets a second link.
void test_bulkUnrelatedCreationsAreNotStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int bulkCount = TriggerUnit : : scmMaxSameLineGenerations + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " bulkFires = 0 \n "
" tempRegexTrigger('^bulkgate$', [=[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^bulkpay$', [[bulkFires = bulkFires + 1]]) \n "
" end \n "
" ]=], 1) \n "
" feedTriggers('bulkgate \\ n') \n "
" feedTriggers('bulkpay \\ n') \n "
" echo('BULKFIRES=' .. bulkFires .. '# \\ n') \n " )
. arg ( bulkCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A batch of unrelated triggers must not be mistaken for a trigger re-creating itself " ) ;
QVERIFY2 ( bufferContains ( qsl ( " BULKFIRES=%1# " ) . arg ( bulkCount ) ) , qPrintable ( qsl ( " Expected all %1 triggers armed on the previous line to survive and fire " ) . arg ( bulkCount ) ) ) ;
}
// Two scripts arming triggers on one line get a budget each, so neither can
// exhaust the other's - together they come to more than one budget's worth.
void test_twoScriptsArmingOnOneLineKeepBothSetsOfTriggers ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int eachCount = ( TriggerUnit : : scmMaxSameLineGenerations / 2 ) + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " firesA, firesB = 0, 0 \n "
" tempRegexTrigger('^sharedgate$', [=[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^payA$', [[firesA = firesA + 1]]) \n "
" end \n "
" ]=], 1) \n "
" tempRegexTrigger('^sharedgate$', [=[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^payB$', [[firesB = firesB + 1]]) \n "
" end \n "
" ]=], 1) \n "
" feedTriggers('sharedgate \\ n') \n "
" feedTriggers('payA \\ n') \n "
" feedTriggers('payB \\ n') \n "
" echo('SHARED=' .. firesA .. ',' .. firesB .. '# \\ n') \n " )
. arg ( eachCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " SHARED=%1,%1# " ) . arg ( eachCount ) ) , qPrintable ( qsl ( " Expected both scripts to keep all %1 of the triggers they armed " ) . arg ( eachCount ) ) ) ;
}
// The batch is armed by a trigger that was itself created on this line, so
// creator and batch share a lineage. Counting a lineage's members rather than
// its generations condemns the whole batch here, which is the room-capture
// shape: the room-title trigger creates the capture trigger, and the capture
// trigger is what arms the batch.
void test_bulkCreationsFromAMidLineTriggerAreNotStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int bulkCount = TriggerUnit : : scmMaxSameLineGenerations + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " deepFires = 0 \n "
" tempRegexTrigger('^deepgate$', [===[ \n "
" tempRegexTrigger('^deepgate$', [==[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^deeppay$', [[deepFires = deepFires + 1]]) \n "
" end \n "
" ]==], 1) \n "
" ]===], 1) \n "
" feedTriggers('deepgate \\ n') \n "
" feedTriggers('deeppay \\ n') \n "
" echo('DEEPFIRES=' .. deepFires .. '# \\ n') \n " )
. arg ( bulkCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A batch is one generation wherever it is armed from, and must not be mistaken for a runaway " ) ;
QVERIFY2 ( bufferContains ( qsl ( " DEEPFIRES=%1# " ) . arg ( bulkCount ) ) , qPrintable ( qsl ( " Expected all %1 triggers armed by a trigger created on the same line to survive and fire " ) . arg ( bulkCount ) ) ) ;
}
// Permanent triggers take the same path, and are the more painful loss - a
// "rebuild my triggers when the game says X" routine arms them in bulk.
void test_bulkPermanentCreationsAreNotStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int bulkCount = TriggerUnit : : scmMaxSameLineGenerations + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " permBulkFires = 0 \n "
" function permBulkStep() permBulkFires = permBulkFires + 1 end \n "
" tempRegexTrigger('^permgate$', [=[ \n "
" for i = 1, %1 do \n "
" permRegexTrigger('PermBulk' .. i, '', {'^permpay$'}, [[permBulkStep()]]) \n "
" end \n "
" ]=], 1) \n "
" feedTriggers('permgate \\ n') \n "
" feedTriggers('permpay \\ n') \n "
" echo('PERMBULK=' .. permBulkFires .. '# \\ n') \n "
" echo('PERMBULKACTIVE=' .. isActive('PermBulk%1', 'trigger') .. '# \\ n') \n " )
. arg ( bulkCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " PERMBULK=%1# " ) . arg ( bulkCount ) ) , qPrintable ( qsl ( " Expected all %1 permanent triggers armed on the previous line to survive and fire " ) . arg ( bulkCount ) ) ) ;
QVERIFY2 ( bufferContains ( qsl ( " PERMBULKACTIVE=1# " ) ) , " Expected the permanent triggers to be left switched on " ) ;
}
// The whole point of the budget being per chain: the runaway loses its
// triggers, the script that happened to arm a trigger on the same line does not.
void test_runawayChainSparesTriggersFromOtherScripts ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " innocentFires = 0 \n "
" function armRunaway() \n "
" tempRegexTrigger('^runline$', [[armRunaway()]], 1) \n "
" end \n "
" armRunaway() \n "
" tempRegexTrigger('^runline$', [=[ \n "
" tempRegexTrigger('^innocent$', [[innocentFires = innocentFires + 1]]) \n "
" ]=], 1) \n "
" feedTriggers('runline \\ n') \n "
" feedTriggers('innocent \\ n') \n "
" echo('INNOCENT=' .. innocentFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected the self-recreating chain to still be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " INNOCENT=1# " ) ) , " Expected the trigger armed by an unrelated script on the same line to survive the runaway's abort and fire " ) ;
}
// A lineage of exactly the budget's depth ends on its own; the trip is on the
// generation after it, which test_selfRecreatingTriggerIsStopped() pins from
// the other side. Both land on the same fire count, so the presence or
// absence of the abort message is what tells the two apart.
void test_chainExactlyAtTheLimitIsNotStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int limit = TriggerUnit : : scmMaxSameLineGenerations ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " boundFires = 0 \n "
" function boundStep() \n "
" boundFires = boundFires + 1 \n "
" if boundFires <= %1 then \n "
" tempRegexTrigger('^boundline$', [[boundStep()]], 1) \n "
" end \n "
" end \n "
" tempRegexTrigger('^boundline$', [[boundStep()]], 1) \n "
" feedTriggers('boundline \\ n') \n "
" echo('BOUNDFIRES=' .. boundFires .. '# \\ n') \n " )
. arg ( limit ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A chain of exactly the budget's length ends on its own and must not be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " BOUNDFIRES=%1# " ) . arg ( limit + 1 ) ) , qPrintable ( qsl ( " Expected the chain to run to its own end, %1 fires " ) . arg ( limit + 1 ) ) ) ;
}
// Once the line that created a trigger is done with, that trigger is as
// ordinary as any other and what it creates starts fresh chains - otherwise
// it would carry its creator's chain around for the rest of the session.
void test_aTriggerOutlivingItsLineStartsFreshChains ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int bulkCount = TriggerUnit : : scmMaxSameLineGenerations + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " laterFires = 0 \n "
" tempRegexTrigger('^egate$', [==[ \n "
" tempRegexTrigger('^esecond$', [=[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^epay$', [[laterFires = laterFires + 1]]) \n "
" end \n "
" ]=], 1) \n "
" ]==], 1) \n "
" feedTriggers('egate \\ n') \n "
" feedTriggers('esecond \\ n') \n "
" feedTriggers('epay \\ n') \n "
" echo('LATERFIRES=' .. laterFires .. '# \\ n') \n " )
. arg ( bulkCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A trigger created on an earlier line is not part of a chain any more and must arm freely " ) ;
QVERIFY2 ( bufferContains ( qsl ( " LATERFIRES=%1# " ) . arg ( bulkCount ) ) , qPrintable ( qsl ( " Expected all %1 triggers armed on the later line to survive and fire " ) . arg ( bulkCount ) ) ) ;
}
// A lineage that starts in the outer pass and runs away inside a nested
// feedTriggers() has members either side of the nested pass's first-node
// index, which is why stopping one scans the whole list rather than the
// tail of the tripping pass. Scanning only the tail leaves the first link
// alive, and the outer pass then has to trip on the same lineage all over
// again - the fire count is what shows that, at twice this number.
void test_runawayCrossingIntoANestedPassIsStoppedWhole ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " nestFires, nestSafeFires = 0, 0 \n "
" function armNested() \n "
" tempRegexTrigger('^nestin$', [[nestFires = nestFires + 1; armNested()]]) \n "
" end \n "
" tempRegexTrigger('^nestout$', [=[ \n "
" armNested() \n "
" tempRegexTrigger('^nestsafe$', [[nestSafeFires = nestSafeFires + 1]]) \n "
" feedTriggers('nestin \\ n') \n "
" ]=], 1) \n "
" feedTriggers('nestout \\ n') \n "
" echo('NESTFIRES=' .. nestFires .. '# \\ n') \n "
" nestFires = 0 \n "
" feedTriggers('nestin \\ n') \n "
" feedTriggers('nestsafe \\ n') \n "
" echo('NESTAFTER=' .. nestFires .. '# \\ n') \n "
" echo('NESTSAFE=' .. nestSafeFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected a runaway that crosses into a nested pass to be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " NESTFIRES=%1# " ) . arg ( TriggerUnit : : scmMaxSameLineGenerations ) ) , " Expected the runaway to cost one budget, not one per pass the lineage is spread across " ) ;
QVERIFY2 ( bufferContains ( qsl ( " NESTAFTER=0# " ) ) , " Expected no member of the stopped lineage to be left armed, wherever in the list it sat " ) ;
QVERIFY2 ( bufferContains ( qsl ( " NESTSAFE=1# " ) ) , " Expected a trigger armed by an unrelated script on the outer line to survive the nested pass's abort " ) ;
}
// Creations made inside a nested pass are appended to the same list the outer
// pass is walking, so a batch armed there has to be read as one generation
// just the same.
void test_bulkCreationsInsideANestedPassAreNotStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
const int bulkCount = TriggerUnit : : scmMaxSameLineGenerations + 1 ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " crossFires = 0 \n "
" tempRegexTrigger('^crossout$', [==[ \n "
" tempRegexTrigger('^crossin$', [=[ \n "
" for i = 1, %1 do \n "
" tempRegexTrigger('^crosspay$', [[crossFires = crossFires + 1]]) \n "
" end \n "
" ]=], 1) \n "
" feedTriggers('crossin \\ n') \n "
" ]==], 1) \n "
" feedTriggers('crossout \\ n') \n "
" feedTriggers('crosspay \\ n') \n "
" echo('CROSSFIRES=' .. crossFires .. '# \\ n') \n " )
. arg ( bulkCount ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( ! bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " A batch armed inside a nested pass is still one generation and must not be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " CROSSFIRES=%1# " ) . arg ( bulkCount ) ) , qPrintable ( qsl ( " Expected all %1 triggers armed inside the nested pass to survive and fire " ) . arg ( bulkCount ) ) ) ;
}
// Only root triggers carry a lineage, so a trigger sitting in a folder creates
// on the folder's behalf. Read the child's own (always empty) lineage instead
// and every round would start a fresh one, which never deepens and so never
// trips - the run would only end at the per-line creation ceiling.
void test_folderChildCreatesOnItsRootsBehalf ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " folderFires, folderCount = 0, 0 \n "
" function makeFolderGen() \n "
" folderCount = folderCount + 1 \n "
" local name = 'FGen' .. folderCount \n "
" permGroup(name, 'trigger') \n "
" permRegexTrigger('FChild' .. folderCount, name, {'^folderloop$'}, [[folderFires = folderFires + 1; makeFolderGen()]]) \n "
" end \n "
" makeFolderGen() \n "
" feedTriggers('folderloop \\ n') \n "
" echo('FOLDERFIRES=' .. folderFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected a runaway driven from inside a folder to be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " FOLDERFIRES=%1# " ) . arg ( 1 + TriggerUnit : : scmMaxSameLineGenerations ) ) ,
" Expected the folder's lineage to deepen by one per round, so the generation budget is what ends it " ) ;
}
// The same for a filter chain, where the child is reached through the parent's
// capture rather than by the root list passing data down.
void test_filterChainChildCreatesOnItsRootsBehalf ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " filterFires, filterCount = 0, 0 \n "
" function makeFilterGen() \n "
" filterCount = filterCount + 1 \n "
" local name = 'FiltP' .. filterCount \n "
" tempComplexRegexTrigger(name, '^(filterloop)$', '', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0) \n "
" permRegexTrigger('FiltC' .. filterCount, name, {'filterloop'}, [[filterFires = filterFires + 1; makeFilterGen()]]) \n "
" end \n "
" makeFilterGen() \n "
" feedTriggers('filterloop \\ n') \n "
" echo('FILTERFIRES=' .. filterFires .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected a runaway driven from inside a filter chain to be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " FILTERFIRES=%1# " ) . arg ( 1 + TriggerUnit : : scmMaxSameLineGenerations ) ) ,
" Expected the filter parent's lineage to deepen by one per round, so the generation budget is what ends it " ) ;
}
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
// The outer line's own mid-pass triggers were registered before the nested
// pass began, so its abort must not take them.
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_nestedPassAbortLeavesTheOuterLineAlone ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " seen = {} \n "
" function armInner() \n "
" tempRegexTrigger('^inner$', [[armInner()]], 1) \n "
" end \n "
" armInner() \n "
" tempRegexTrigger('^outer$', [=[ \n "
" tempRegexTrigger('^(.*)$', [[table.insert(seen, matches[2])]], 10) \n "
" feedTriggers('inner \\ n') \n "
" ]=], 1) \n "
" feedTriggers('outer \\ n') \n "
" echo('SEEN=' .. table.concat(seen, ',') .. '# \\ n') \n " ) ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
QVERIFY2 ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected the runaway in the nested pass to be stopped " ) ;
QVERIFY2 ( bufferContains ( qsl ( " SEEN=inner,outer# " ) ) , " Expected the capture trigger created by the outer line to survive the nested pass's abort and still match the outer line " ) ;
}
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
// Not a feedTriggers() curiosity - real socket text takes the same path - and
// driving it from the socket also proves the abort leaves the event loop running.
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_selfRecreatingTriggerFromServerTextIsStopped ( )
{
startProfile ( mpHostname , mpLocalhost , mpPort ) ;
auto * host = mudlet : : self ( ) - > getActiveHost ( ) ;
QVERIFY ( host ) ;
host - > mEchoLuaErrors = true ;
host - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " function armFromServer() \n "
" tempRegexTrigger('^HP: 100/100$', [[armFromServer()]], 1) \n "
" end \n "
" armFromServer() \n " ) ) ;
mpServer - > sendRaw ( QByteArray ( " HP: 100/100 \r \n " ) ) ;
QTRY_VERIFY2_WITH_TIMEOUT ( bufferContains ( qsl ( " Trigger processing stopped to prevent a freeze " ) ) , " Expected server text to reach the same-line generation budget and be stopped " , 10000 ) ;
QCOMPARE ( host - > getTriggerUnit ( ) - > processingDepth ( ) , 0 ) ;
}
fix: triggers created by other triggers react to the current line again (#9458)
#### Brief overview of PR changes/additions
- A `tempTrigger()`/`tempRegexTrigger()`/`tempLineTrigger()` created
from another trigger's script matches the line being processed again,
instead of only starting on the next line
- Keeps #9267's snapshot iteration (its use-after-free fix stays
intact); root triggers registered during a pass are matched against the
current line afterwards
- New `TriggerSameLineMatchTest` with 6 cases - all fail without the fix
#### Motivation for adding to Mudlet
Long-standing behaviour that room-capture scripts rely on ("start
capture on the room title line, grab it and the following lines")
silently regressed in 4.21.0; reported on Discord with ~6 years of
scripts affected.
#### Other info (issues closed, discussion etc)
Regression from #9267, shipped in 4.21.0/4.21.1/4.22.0. Nested
`feedTriggers()` passes, chained trigger creation, and single-shot
(`expireAfter=1`) semantics all restored to match the old live-list
iteration.
**Test case:** Make a trigger matching some line that runs
`tempRegexTrigger("^(.*)$", [[echo("got: " .. matches[2] .. "\n")]],
2)`, then feed that line: the temp trigger must capture the creating
line itself, not start one line late. Or run `TriggerSameLineMatchTest`.
**Demo** (before: capture starts a line late; after: it includes the
creating line):
https://github.com/user-attachments/assets/be8d95aa-0691-4a9b-954e-e85f39e18a3d
---------
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-27 20:19:14 +02:00
void cleanup ( )
{
delete mpServer ;
mpServer = nullptr ;
deleteProfileDirectory ( mpHostname ) ;
delete mudlet : : self ( ) ;
}
// Starts a profile the way a user would via the GUI (mirrors the helper in
// TelnetTextDisplayedTest).
void startProfile ( const QString & hostname , const QString & address , const QString & port )
{
QTimer : : singleShot ( 0 , qApp , [ hostname , address , port ] ( ) {
mudlet : : self ( ) - > startAutoLogin ( { } ) ;
QTest : : qWait ( 100 ) ;
QTest : : mouseClick ( mudlet : : self ( ) - > mpConnectionDialog - > new_profile_button , Qt : : LeftButton ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , hostname ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , address ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , port ) ;
QTest : : qWait ( 100 ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Return ) ;
} ) ;
QSignalSpy spy ( mudlet : : self ( ) , & mudlet : : signal_profileLoaded ) ;
if ( ! spy . wait ( 5000 ) ) {
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 ( 2000 ) ) {
QFAIL ( " Could not connect with the host. " ) ;
}
}
// Joins every physical buffer line and normalises whitespace before
// matching, so a needle the console word-wraps across lines is still found.
QString joinedBuffer ( )
{
auto console = mudlet : : self ( ) - > getActiveHost ( ) - > mpConsole ;
QString allText ;
for ( int i = 0 ; i < = console - > buffer . getLastLineNumber ( ) ; + + i ) {
allText . append ( console - > buffer . line ( i ) ) . append ( QChar : : Space ) ;
}
return allText . simplified ( ) ;
}
bool bufferContains ( const QString & needle ) { return joinedBuffer ( ) . contains ( needle ) ; }
void deleteProfileDirectory ( const QString & profileName )
{
const QString path = mudlet : : getMudletPath ( enums : : profileHomePath , profileName ) ;
QDir dir ( path ) ;
if ( ! dir . exists ( ) ) {
return ;
}
dir . removeRecursively ( ) ;
}
} ;
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 ( ) ;
}
# include "TriggerSameLineMatchTest.moc"
QTEST_MAIN ( TriggerSameLineMatchTest )