infrastructure: move module documents into their write jobs instead of copying (#9699)

#### Brief overview of PR changes/additions

- `Host::saveProfile()` cloned each module's XML document into its
background write job while the original sat unread in `writers` as a
save-in-progress token, doubling peak memory per module during saves.
The document is now moved into the job instead (pugixml move, available
since 1.9).
- The async profile save in `runAsyncSave()` had the identical doubling
and gets the same move.

#### Motivation for adding to Mudlet

Follow-up to #9690 "fix: module save reaching into the profile after it
is destroyed" - saves now hold one copy of each document, not two.

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

Test case: `ModuleSaveTeardownTest`, `PackageUninstallSaveTeardownTest`
and `ProfileRoundTripTest` pass; a 241KB document round-trips
byte-identical through the move under ASan/UBSan.

Assisted-by: Claude:claude-fable-5
This commit is contained in:
Vadim Peretokin 2026-08-07 06:13:15 +02:00 committed by GitHub
parent c594d83c25
commit fd822ebb83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 15 additions and 27 deletions

View file

@ -701,10 +701,9 @@ QList<Host::ModuleWriteJob> Host::prepareModuleSaves(bool backup)
writer->writeModuleXML(moduleName);
// The writer stays in `writers` purely as the save-in-progress token that
// xmlSaved() retires on the main thread, so the XMLexport - a QObject with
// main-thread affinity - is only ever destroyed there. What gets written out
// is the job's own copy of the document, which outlives both of them.
// main-thread affinity - is only ever destroyed there.
writers.insert(xmlFilename, writer);
jobs.append({writer->cloneExportDocument(), moduleName, filename, xmlFilename, backup ? backupPath + moduleName : QString()});
jobs.append({writer->takeExportDocument(), moduleName, filename, xmlFilename, backup ? backupPath + moduleName : QString()});
if (entry.at(1).toInt()) {
mModulesToSync << moduleName;

View file

@ -47,6 +47,7 @@
#include <QMetaEnum>
#include <sstream>
#include <utility>
XMLexport::XMLexport(Host* pH)
: mpHost(pH)
@ -85,8 +86,7 @@ XMLexport::XMLexport(TKey* pT)
// Builds the module's XML document into mExportDoc. This reads the live
// trigger/timer/alias/action/script/key lists, so it must run on the main thread;
// serializing a copy of it (cloneExportDocument()) to disk can then happen on a
// background thread.
// serializing it to disk can then happen on a background thread.
void XMLexport::writeModuleXML(const QString& moduleName)
{
auto pHost = mpHost;
@ -161,17 +161,12 @@ void XMLexport::writeModuleXML(const QString& moduleName)
}
}
// Deep copy of the document built so far, so the write can outlive both this XMLexport
// and the Host it belongs to. The copy is made while the main thread is already
// quiescent, and each document owns its own tree, so the clone can be serialized off
// the main thread without touching anything shared.
std::shared_ptr<pugi::xml_document> XMLexport::cloneExportDocument() const
// Hands the document over so the write can outlive both this XMLexport and its Host
// without a second copy of the tree. mExportDoc is left valid but empty, and any
// xml_node handle taken from it beforehand must not be used afterwards.
std::shared_ptr<pugi::xml_document> XMLexport::takeExportDocument()
{
auto clone = std::make_shared<pugi::xml_document>();
for (pugi::xml_node child = mExportDoc.first_child(); child; child = child.next_sibling()) {
clone->append_copy(child);
}
return clone;
return std::make_shared<pugi::xml_document>(std::move(mExportDoc));
}
bool XMLexport::exportHost(const QString& filename_pugi_xml)
@ -188,15 +183,11 @@ bool XMLexport::exportHost(const QString& filename_pugi_xml)
return true;
}
// Helper to encapsulate async save pattern: clone document, save in background thread,
// notify host when complete
void XMLexport::runAsyncSave(const QString& fileName, const QString& xmlSavedKey)
{
// Clone the XML document on the main thread, then serialize and save it on a
// background thread that owns the clone outright.
QPointer<Host> host = mpHost;
auto future = QtConcurrent::run([fileName, docClone = cloneExportDocument()]() {
return XMLexport::saveXmlDocToFile(fileName, *docClone);
auto future = QtConcurrent::run([fileName, doc = takeExportDocument()]() {
return XMLexport::saveXmlDocToFile(fileName, *doc);
});
// Parented to the profile for the same reason the module save's watcher is: the
// deleteLater() below needs an event loop that is still running to be delivered,
@ -325,11 +316,9 @@ bool XMLexport::saveXml(const QString& fileName)
return success;
}
// Save an XML document to a file. This is thread-safe and can be called from a background thread
// as long as the document is not being modified concurrently (which we ensure by passing a clone).
// Static method so it can be called without keeping XMLexport alive.
// Note: This is a static member method that doesn't access any instance state,
// making it safe to call from background threads.
// Callable from a background thread as long as nothing modifies the document
// concurrently, which handing it over with takeExportDocument() ensures. Static so it
// neither keeps the XMLexport alive nor touches any instance state.
bool XMLexport::saveXmlDocToFile(const QString& fileName, const pugi::xml_document& doc)
{
QSaveFile file(fileName);

View file

@ -67,7 +67,7 @@ public:
void writeKey(TKey*, pugi::xml_node xmlParent);
void writeVariable(TVar*, LuaInterface*, VarUnit*, pugi::xml_node xmlParent, bool insideSavedTable = false);
void writeModuleXML(const QString& moduleName);
std::shared_ptr<pugi::xml_document> cloneExportDocument() const;
std::shared_ptr<pugi::xml_document> takeExportDocument();
static bool saveXmlDocToFile(const QString& fileName, const pugi::xml_document& doc);
bool exportHost(const QString& filename_pugi_xml);