mudlet/test/functional_tests/PackageSelfRemovalTest.cpp

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

675 lines
32 KiB
C++
Raw Permalink Normal View History

fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
/***************************************************************************
* Copyright (C) 2026 by Mudlet Developers *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
/*
* Regression test for use-after-free when a package uninstalls itself from its
* own timer script or event-handler script (#9337 class, the remaining timer
* and script cases).
*
* Package auto-updaters commonly call uninstallPackage()+installPackage() on
* their own package from one of the package's own items. Since the
* *Unit::uninstall() methods started deleting items immediately (instead of
* just unregistering them), doing that from a timer deleted the very TTimer
* whose execute() was still on the call stack; doing it from an event handler
* deleted TScript objects that Host::raiseEvent() was still iterating over; and
* doing it from a script's top-level body deleted the very TScript that
* compileScript() was in the middle of compiling - heap corruption every way.
* TriggerUnit/AliasUnit/KeyUnit gained a processing-depth deferral in #9383;
* this covers TimerUnit and ScriptUnit (both the event-dispatch and the
* compile-time body paths).
*
* Under AddressSanitizer the pre-fix code aborts with heap-use-after-free
* inside TTimer::execute() / Host::raiseEvent() / TScript::compileScript(); with
* the deferral in place all scenarios complete cleanly.
*
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
* The second half covers the other side of that deferral: an item whose delete
* is outstanding is still registered, and must not be written back into the
* profile by a save taken before the unit goes idle.
*
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
* Run with: ctest -R PackageSelfRemovalTest -V
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
*/
#include <QtTest/QtTest>
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include <QScopeGuard>
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include <QTemporaryDir>
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include "ActionUnit.h"
#include "AliasUnit.h"
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include "Host.h"
#include "HostManager.h"
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include "LuaInterface.h"
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include "MudletInstanceCoordinator.h"
#include "ScriptUnit.h"
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include "TAction.h"
#include "TAlias.h"
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include "TEvent.h"
#include "TScript.h"
#include "TTimer.h"
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include "TTrigger.h"
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include "TimerUnit.h"
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
#include "TriggerUnit.h"
#include "VarUnit.h"
#include "XMLexport.h"
#include "XMLimport.h"
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
#include "mudlet.h"
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
extern "C" {
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
#include <lua5.1/lauxlib.h>
#include <lua5.1/lua.h>
#else
#include <lauxlib.h>
#include <lua.h>
#endif
}
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
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();
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
void initializeQRCResourcesForPackageSelfRemovalTest();
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
// TriggerUnit only holds its depth inside processDataStream(), so a save at
// depth has to come from a trigger's own script. Stands in for the Lua
// saveProfile() one would call - a bare test Host has no console for that.
static Host* gpMidPassExportHost = nullptr;
static QString gMidPassExportPath;
static QString gMidPassExportedXml;
static int exportProfileMidPass(lua_State* L)
{
Q_UNUSED(L)
gMidPassExportedXml.clear();
if (!gpMidPassExportHost) {
return 0;
}
auto writer = std::make_shared<XMLexport>(gpMidPassExportHost);
// variables included: the only export here that builds the variable tree,
// and it does so with a Lua call frame live
if (!writer->exportPackage(gMidPassExportPath, true, false)) {
qWarning() << "exportProfileMidPass() - the export itself failed";
return 0;
}
QFile file(gMidPassExportPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning() << "exportProfileMidPass() - could not read back" << gMidPassExportPath;
return 0;
}
gMidPassExportedXml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(gMidPassExportPath);
return 0;
}
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
class PackageSelfRemovalTest : public QObject
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
{
Q_OBJECT
private:
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
const QString mProfileName = qsl("PackageSelfRemoval-Test");
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
Host* mpHost = nullptr;
private slots:
void initTestCase()
{
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
initializeQRCResourcesForPackageSelfRemovalTest();
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
// Keep the test hermetic: point the config dir resolution at a
// temporary directory instead of the user's real profiles.
QVERIFY(mConfigDir.isValid());
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host");
mpHost = mudlet::self()->getHostManager().getHost(mProfileName);
QVERIFY(mpHost);
// A bare Host blocks script compilation until the full profile boot
// would normally clear this; the test's scripts need to compile:
mpHost->mBlockScriptCompile = false;
// NB: mLoadedOk is left false on purpose - the deferred saveProfile()
// that uninstallPackage() schedules then declines to run, which this
// console-less test Host could not service anyway.
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
createKeeperItems();
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// A package's event-handler script uninstalls its own package while
// Host::raiseEvent() is mid-dispatch. The second handler in the same
// package is what the pre-fix code would have called through a freed
// TScript pointer.
void test_scriptEventHandlerSelfUninstall()
{
const QString packageName = qsl("selfuninstall-script");
mpHost->mInstalledPackages << packageName;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallHandler"));
QVERIFY2(pUninstaller->setScript(qsl("function selfUninstallHandler(event)\n uninstallPackage(\"%1\")\nend\n").arg(packageName)), "uninstaller handler script failed to compile");
pUninstaller->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")});
pUninstaller->setIsActive(true);
auto pBystander = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pBystander);
pBystander->mPackageName = packageName;
pBystander->setName(qsl("selfUninstallBystander"));
QVERIFY2(pBystander->setScript(qsl("function selfUninstallBystander(event)\nend\n")), "bystander handler script failed to compile");
pBystander->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")});
pBystander->setIsActive(true);
TEvent event{};
event.mArgumentList.append(qsl("testSelfUninstallEvent"));
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
// Pre-fix this dispatch freed both TScripts under raiseEvent()'s feet:
mpHost->raiseEvent(event);
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// The deferred deletes must have been flushed once the dispatch ended:
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallHandler")).empty(), "uninstalled script is still registered");
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallBystander")).empty(), "uninstalled script is still registered");
}
// A package script's TOP-LEVEL body (not an event handler) uninstalls its own
// package while it is being compiled - the path profile boot and reset take
// when they run "all the Lua code outside of functions" via ScriptUnit::compileAll().
// Pre-fix, ScriptUnit::uninstall() deleted the script immediately: compileScript()
// then wrote to the freed TScript (heap-use-after-free WRITE at TScript::compileScript),
// and compileAll()'s range-for walked onto the freed std::list node it had unlinked.
void test_scriptBodySelfUninstallOnCompile()
{
const QString packageName = qsl("selfuninstall-script-compile");
mpHost->mInstalledPackages << packageName;
// Defer compilation so the bodies run through compileAll() below rather than
// from setScript() here, mirroring how a freshly loaded package's scripts are
// compiled at profile boot (mudlet::loadProfile) and reset (Host::resetProfile_phase2):
mpHost->mBlockScriptCompile = true;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallOnCompile"));
// A bare uninstallPackage() at the top level runs the moment the script is compiled:
pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName));
pUninstaller->setIsActive(true);
// A second script in the same package, registered AFTER the uninstaller, is
// the node the pre-fix immediate delete unlinks from under compileAll()'s loop:
auto pBystander = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pBystander);
pBystander->mPackageName = packageName;
pBystander->setName(qsl("selfUninstallCompileBystander"));
pBystander->setScript(qsl("local noop = true\n"));
pBystander->setIsActive(true);
mpHost->mBlockScriptCompile = false;
// Pre-fix this trips heap-use-after-free; post-fix the delete is deferred until
// after the loop and then flushed by compileAll():
mpHost->getScriptUnit()->compileAll();
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// The deferred deletes must have been flushed at the end of compileAll():
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallOnCompile")).empty(), "uninstalled script is still registered");
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallCompileBystander")).empty(), "uninstalled bystander script is still registered");
}
// A package script's top-level body uninstalls its own package from a plain
// setScript() compile - the path permScript()/setScript() take when invoked from
// an alias, key or the command line, none of which sit inside compileAll(), the
// editor's saveScript(), or raiseEvent(). Pre-fix this hit the same
// heap-use-after-free in compileScript() as the compileAll() case. The compile
// guard defers the delete here too; because this entry point has no synchronous
// flush of its own, the deferred script lingers (deactivated) until a catch-all
// flush - ScriptUnit::doCleanup() via Host::incomingStreamProcessor()/
// slot_purgeTemps(), or the doCleanup() Host::uninstallPackage()'s queued save runs
// - collects it, which must happen without leaking or double-freeing.
void test_scriptBodySelfUninstallFromSetScript()
{
const QString packageName = qsl("selfuninstall-script-setscript");
mpHost->mInstalledPackages << packageName;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallFromSetScript"));
// mBlockScriptCompile is false, so setScript() compiles and runs the top-level
// body immediately - uninstallPackage() fires from inside compileScript():
pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName));
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// No synchronous flush point covers this entry, so the script is still
// registered (deferred, deactivated) right after setScript() returns:
QVERIFY2(!mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "self-uninstalling script should still be deferred, not yet deleted");
// The catch-all flush (as wired into incomingStreamProcessor()/slot_purgeTemps())
// must then collect it cleanly - no leak, no double-free:
mpHost->getScriptUnit()->doCleanup();
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "deferred uninstalled script was not flushed");
}
// A package's timer script uninstalls its own package while
// TTimer::execute() is still on the call stack for that timer. Pre-fix,
// execute() would resume on a freed `this`.
void test_timerScriptSelfUninstall()
{
const QString packageName = qsl("selfuninstall-timer");
mpHost->mInstalledPackages << packageName;
auto pTimer = new TTimer(qsl("selfUninstallTimer"), QTime(0, 0, 0, 250), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = packageName;
// The trailing error() is what makes this a genuine regression test: after
// uninstallPackage() has (pre-fix) freed this timer, the error aborts the
// Lua call so it returns false, and TTimer::execute() then resumes past the
// call and reads the freed `this` at `mpQTimer->stop()` - the heap-use-after-free
// the deferral prevents. Without the error the call returns cleanly and
// execute() never touches `this` again, so the bug would go undetected.
QVERIFY2(pTimer->setScript(qsl("uninstallPackage(\"%1\")\nerror(\"boom\")").arg(packageName)), "timer script failed to compile");
pTimer->setIsActive(true);
pTimer->enableTimer();
// Let the timer fire and take its package (and itself) down:
QTRY_VERIFY_WITH_TIMEOUT(!mpHost->mInstalledPackages.contains(packageName), 5000);
// Allow any queued activity (the declined deferred save, further timer
// ticks) to surface problems:
QTest::qWait(500);
// mudlet::slot_timerFires() flushes the deferred delete as soon as the
// uninstalling timer's execute() has finished, so by now the timer must
// be properly gone - not lingering deactivated where the next profile
// save would serialize it back in:
QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("selfUninstallTimer")), "uninstalled package timer is still registered");
}
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
// The trigger route, driven the whole way: the package's own trigger fires,
// uninstalls its package and saves, all inside the pass. Its export is the
// only one here that includes the variables, so it doubles as the check that
// the variable tree can be built from inside a live Lua call frame.
void test_saveFromTriggerScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-trigger");
mpHost->mInstalledPackages << packageName;
gpMidPassExportHost = mpHost;
gMidPassExportPath = qsl("%1/mid-pass-export.xml").arg(mConfigDir.path());
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_register(L, "qaExportProfileMidPass", exportProfileMidPass);
QCOMPARE(luaL_dostring(L, "midPassSavedVar = 'saved from inside the pass'"), 0);
mpHost->getLuaInterface()->getVarUnit()->savedVars.insert(qsl("midPassSavedVar"));
auto pGroup = new TTrigger(nullptr, mpHost);
pGroup->setIsFolder(true);
pGroup->registerTrigger();
pGroup->setName(qsl("resurrectTriggerGroup"));
pGroup->mPackageName = packageName;
pGroup->setIsActive(true);
auto pKicker = new TTrigger(pGroup, mpHost);
pKicker->setRegexCodeList({qsl("^resurrect me$")}, {REGEX_PERL});
pKicker->registerTrigger();
QVERIFY2(pKicker->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "trigger script failed to compile");
pKicker->setName(qsl("resurrectTriggerKicker"));
pKicker->setIsActive(true);
// a sibling that never runs: the whole group must go, not just the one
// that fired
auto pBystander = new TTrigger(pGroup, mpHost);
pBystander->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL});
pBystander->registerTrigger();
pBystander->setName(qsl("resurrectTriggerBystander"));
pBystander->setIsActive(true);
QVERIFY2(exportedProfileXml().contains(qsl("resurrectTriggerGroup")), "the trigger group should be in the profile before its package is uninstalled");
mpHost->getTriggerUnit()->processDataStream(qsl("resurrect me"), -1);
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerGroup")), "a save taken mid-pass wrote the uninstalled package's trigger group back into the profile");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerKicker")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerBystander")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile");
const QString keeperError = keepersMissingFrom(gMidPassExportedXml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(gMidPassExportedXml.contains(qsl("saved from inside the pass")), "the variables were not read out of Lua by a save taken from inside a script");
QVERIFY2(mpHost->getTriggerUnit()->findItems(qsl("resurrectTriggerKicker")).empty(), "uninstalled trigger is still registered");
mpHost->getLuaInterface()->getVarUnit()->savedVars.remove(qsl("midPassSavedVar"));
gpMidPassExportHost = nullptr;
}
// The alias route: a package shipping its own "uninstall" alias.
void test_saveFromAliasScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-alias");
mpHost->mInstalledPackages << packageName;
gpMidPassExportHost = mpHost;
gMidPassExportPath = qsl("%1/mid-pass-alias-export.xml").arg(mConfigDir.path());
lua_register(mpHost->mLuaInterpreter.getLuaGlobalState(), "qaExportProfileMidPass", exportProfileMidPass);
auto pAlias = new TAlias(qsl("resurrectAlias"), mpHost);
pAlias->setRegexCode(qsl("^resurrect me$"));
mpHost->getAliasUnit()->registerAlias(pAlias);
pAlias->mPackageName = packageName;
QVERIFY2(pAlias->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "alias script failed to compile");
pAlias->setIsActive(true);
QVERIFY2(exportedProfileXml().contains(qsl("resurrectAlias")), "the alias should be in the profile before its package is uninstalled");
mpHost->getAliasUnit()->processDataStream(qsl("resurrect me"));
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectAlias")), "a save taken mid-pass wrote the uninstalled package's alias back into the profile");
const QString keeperError = keepersMissingFrom(gMidPassExportedXml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getAliasUnit()->findFirstAlias(qsl("resurrectAlias")), "uninstalled alias is still registered");
gpMidPassExportHost = nullptr;
}
// The timer route. beginProcessing()/endProcessing() below are the calls
// TTimer::execute() wraps its whole callback in.
void test_saveDuringTimerCallbackDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-timer");
mpHost->mInstalledPackages << packageName;
auto pTimer = new TTimer(qsl("resurrectTimer"), QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = packageName;
QVERIFY2(pTimer->setScript(qsl("local noop = true\n")), "timer script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectTimer")), "the timer should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getTimerUnit()->beginProcessing();
// a failed QVERIFY returns from the slot; a level left on would
// wedge every later test's doCleanup()
const auto depthGuard = qScopeGuard([this]() {
mpHost->getTimerUnit()->endProcessing();
mpHost->getTimerUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectTimer")), "a save taken during a timer callback wrote the uninstalled package's timer back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("resurrectTimer")), "uninstalled timer is still registered");
}
// The button route: TAction::execute() holds ActionUnit's depth the same way.
void test_saveDuringButtonScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-action");
mpHost->mInstalledPackages << packageName;
auto pAction = new TAction(qsl("resurrectAction"), mpHost);
mpHost->getActionUnit()->registerAction(pAction);
pAction->mPackageName = packageName;
QVERIFY2(pAction->setScript(qsl("local noop = true\n")), "button script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectAction")), "the button should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getActionUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getActionUnit()->endProcessing();
mpHost->getActionUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectAction")), "a save taken during a button script wrote the uninstalled package's button back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getActionUnit()->findAction(qsl("resurrectAction")), "uninstalled button is still registered");
}
// The event-handler route: Host::raiseEvent() holds ScriptUnit's depth.
void test_saveDuringEventDispatchDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-script");
mpHost->mInstalledPackages << packageName;
auto pScript = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->mPackageName = packageName;
pScript->setName(qsl("resurrectScript"));
QVERIFY2(pScript->setScript(qsl("local noop = true\n")), "script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectScript")), "the script should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getScriptUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getScriptUnit()->endProcessing();
mpHost->getScriptUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectScript")), "a save taken during an event dispatch wrote the uninstalled package's script back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("resurrectScript")).empty(), "uninstalled script is still registered");
}
// Host::reloadModule() - reachable from Lua - uninstalls and reinstalls a
// module back to back, so from a script the old items are still registered
// when the new ones arrive.
void test_moduleSaveDuringReloadDoesNotDuplicateItsItems()
{
const QString moduleName = qsl("resurrect-module");
registerModuleAs(moduleName);
QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerBeforeReload")), "could not import the module's timer");
QVERIFY2(exportedModuleXml(moduleName).contains(qsl("moduleTimerBeforeReload")), "the module's timer should be in its file before the reload");
QString xml;
{
mpHost->getTimerUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getTimerUnit()->endProcessing();
mpHost->getTimerUnit()->doCleanup();
});
// the uninstall half: at depth the old timer only gets deactivated
QVERIFY(mpHost->uninstallPackage(moduleName, enums::PackageModuleType::ModuleSync));
// ... and the reinstall half brings the module back with fresh items
registerModuleAs(moduleName);
QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerAfterReload")), "could not re-import the module's timer");
xml = exportedModuleXml(moduleName);
}
QVERIFY2(!xml.isEmpty(), "the module export produced nothing to check");
QVERIFY2(xml.contains(qsl("moduleTimerAfterReload")), "the reloaded module's timer must be written to its file");
QVERIFY2(!xml.contains(qsl("moduleTimerBeforeReload")), "a module save taken mid-reload wrote the pre-reload copy of the timer back into the module file");
}
private:
// Items of a package that is never uninstalled. Every other assertion here
// is an absence, so without these an over-broad filter passes the whole file
// while emptying the user's profile.
void createKeeperItems()
{
const QString keeperPackage = qsl("keeper-package");
mpHost->mInstalledPackages << keeperPackage;
auto pTrigger = new TTrigger(nullptr, mpHost);
pTrigger->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL});
pTrigger->registerTrigger();
pTrigger->setName(qsl("keeperTrigger"));
pTrigger->mPackageName = keeperPackage;
auto pAlias = new TAlias(qsl("keeperAlias"), mpHost);
pAlias->setRegexCode(qsl("^never matched$"));
mpHost->getAliasUnit()->registerAlias(pAlias);
pAlias->mPackageName = keeperPackage;
auto pTimer = new TTimer(qsl("keeperTimer"), QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = keeperPackage;
auto pAction = new TAction(qsl("keeperAction"), mpHost);
mpHost->getActionUnit()->registerAction(pAction);
pAction->mPackageName = keeperPackage;
auto pScript = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->setName(qsl("keeperScript"));
pScript->mPackageName = keeperPackage;
}
QString keepersMissingFrom(const QString& xml) const
{
for (const auto& name : {qsl("keeperTrigger"), qsl("keeperAlias"), qsl("keeperTimer"), qsl("keeperAction"), qsl("keeperScript")}) {
if (!xml.contains(name)) {
return qsl("a save taken while a delete was outstanding dropped \"%1\", which belongs to a package that is still installed").arg(name);
}
}
return {};
}
void registerModuleAs(const QString& moduleName)
{
mpHost->mInstalledModules[moduleName] = QStringList{qsl("%1/%2.xml").arg(mConfigDir.path(), moduleName), qsl("0")};
mpHost->mModulesLoadedOk << moduleName;
}
// The module-member flag is private to XMLimport, so a genuine module item
// can only be made by importing one: that creates the module's master folder
// per unit, and renaming the timer's tells the two copies apart.
bool importModuleTimerNamed(const QString& moduleName, const QString& itemName)
{
const QString path = qsl("%1/%2-import.xml").arg(mConfigDir.path(), itemName);
auto* pSeed = new TTimer(itemName, QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pSeed);
pSeed->setScript(qsl("local noop = true\n"));
const bool exported = XMLexport(pSeed).exportTimer(path);
mpHost->getTimerUnit()->unregisterTimer(pSeed);
delete pSeed;
if (!exported) {
return false;
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return false;
}
XMLimport importer(mpHost);
const bool imported = importer.importPackage(&file, moduleName, 1).first;
file.close();
QFile::remove(path);
if (!imported) {
return false;
}
TTimer* pTimer = mpHost->getTimerUnit()->findFirstTimer(moduleName);
if (!pTimer) {
return false;
}
pTimer->setName(itemName);
return true;
}
// Builds the document writeModuleXML() produces for a save and reads it back.
QString exportedModuleXml(const QString& moduleName)
{
const QString path = qsl("%1/module-export.xml").arg(mConfigDir.path());
XMLexport writer(mpHost);
writer.writeModuleXML(moduleName);
if (!XMLexport::saveXmlDocToFile(path, *writer.takeExportDocument())) {
fix: two ways a profile save could lose or resurrect your data (#9704) #### Brief overview of PR changes/additions - Variables: the export skipped its refresh whenever the editor's Variables view was on screen, so anything a script wrote into a saved variable while it sat open was dropped from the save - including the session's last save, which is taken with whatever view the editor was left on. The variables are now read into a throwaway tree, which also stops a save stranding the editor's variable search results. - Packages: a save taken while a unit was still executing an item of a package that had just been uninstalled wrote that package's items back into the profile, where they returned as orphans the Package Manager could not remove. The XML writers now skip what the units have queued for a deferred delete, the module writer included - reloading a module from a script used to write both the pre- and post-reload copies of its items into the module file. - `LuaInterface::getVars()` is now `setjmp`-guarded like every other Lua-touching method there, so a panic cannot jump past the export's scope with its variable tree and registry references still held. #### Motivation for adding to Mudlet Both are silent data loss in everyday use: quitting with the editor on the Variables tab, and the `mpkg`/auto-updater shape of uninstalling a package from a script. #### Other info (issues closed, discussion etc) From the 5.0 QA sweep, findings C13 and C14. The variables half re-opens the loss that `20009c5ec` "fix: variables added while playing are no longer lost when saving (#9492)" fixed, via the guard it added; the packages half is the missing counterpart to the self-uninstall deferral in `276e8bbfd` (#9383) and its follow-ups. #9492's own cases still pass unchanged. **Test case:** create a table from the command line, tick it to be saved in the editor's Variables view, leave the editor there, run `lua myTable.later = "x"`, quit and reopen - `later` is still there. `ctest -R 'XMLexportVariablesTest|PackageSelfUninstallTest'` covers both halves; all 12 new cases were verified to fail against the unfixed source. Assisted-by: Claude:claude-opus-5
2026-08-07 10:14:45 +02:00
return {};
}
return readBack(path);
}
// The writers a profile save uses, without the console Host::saveProfile()
// would need.
QString exportedProfileXml()
{
const QString path = qsl("%1/profile-export.xml").arg(mConfigDir.path());
auto writer = std::make_shared<XMLexport>(mpHost);
if (!writer->exportPackage(path, true, true)) {
return {};
}
return readBack(path);
}
static QString readBack(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return {};
}
const QString xml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(path);
return xml;
}
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
};
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
void initializeQRCResourcesForPackageSelfRemovalTest()
fix: stop crashes while saving and profiles losing their triggers (#9557) #### Brief overview of PR changes/additions - Profile loading now only considers real `*.xml` saves: an empty QSaveFile temporary left behind by a crash during a save can no longer be loaded as "the profile", which made a profile open with its connection settings intact but every trigger/script seemingly gone. Affected profiles heal themselves on next load by falling back to the newest real save. - Packages that uninstall themselves from their own timer script or event-handler script (a common auto-updater pattern) no longer free the very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on the call stack, completing the #9337/#9383 fix that already covered triggers/aliases/keys. Deferred timer deletes are flushed before the queued post-uninstall save runs, so removed items cannot be serialized back into the profile. - `Host::saveProfile()`'s background module task no longer reads `writers`/`saveFutures` concurrently with the main thread (data race in the profile save path). #### Motivation for adding to Mudlet Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 / MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall activity around saves) and the profile data loss it caused. #### Other info (issues closed, discussion etc) Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed the `*Unit::uninstall()` methods from unregister-only to immediate `delete`. A package script calling `uninstallPackage()` on its own package then freed objects still on the call stack - use-after-free that poisons the heap, typically detected slightly later during the background save serialization (hence the pugixml/lua frames, aborts mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in `current/`). #9383 fixed the trigger/alias/key cases; this completes timers and scripts, which reproduce under ASan on current development (heap-use-after-free in `Tree<TScript>::isActive()` / `TTimer::execute()`). Data-loss mechanism (generic): a crash mid-save leaves a 0-byte QSaveFile temporary as the newest file in `current/`; `mudlet::loadProfile()` picked the newest file of any name, tried to load the empty temp, and the profile opened "gutted" (connection details live in separate files and survived). Verified end-to-end with affected profile data and covered by a synthetic regression test. Both new functional tests fail on pre-fix code (`PackageSelfUninstallTest` trips ASan heap-use-after-free; `ProfileLoadTempFileTest` reproduces the data loss) and pass with the fix; full functional suite green (24/24). Known remaining (pre-existing) issue documented in-code at `Host::pendingXmlSaveFutures()`: module writing still touches `writers` from the background task for profiles that use modules; fixing that properly means moving module serialization back to the main thread and deserves its own PR. **Test case:** 1. Create a package containing a timer or event-handler script that calls `uninstallPackage()` on its own package, and let it fire - no crash, package cleanly removed, next save does not resurrect it. 2. Simulate an interrupted save: place an empty file named like `2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with the newest timestamp - the profile still loads the newest real save with all triggers intact, and the temporary no longer appears in Connect → Options → Profile history. 3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an ASan (default Debug) build. Assisted-by: Claude:claude-fable-5 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
{
#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();
}
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
#include "PackageSelfRemovalTest.moc"
QTEST_MAIN(PackageSelfRemovalTest)