Система плагинов

Hot-reload плагинов во время работы приложения

1 просмотров

Введение

Во время разработки плагина перезапуск всего основного приложения после каждого изменения кода плагина замедляет цикл разработки. Hot-reload — возможность выгрузить старую версию плагина и загрузить новую, пересобранную версию без перезапуска основного приложения — заметно ускоряет итерацию, особенно для приложений с долгим временем старта или сложным состоянием, которое неудобно восстанавливать при каждом перезапуске. В статье разберём, как реализовать безопасный hot-reload для Qt-плагинов и какие у него фундаментальные ограничения.

Концепция

Hot-reload строится на повторном вызове unload() у существующего QPluginLoader для уже загруженного плагина, после чего создаётся новый QPluginLoader для того же (пересобранного) файла, и происходит повторная загрузка через load()/instance(). Критичное условие безопасности — на момент unload() не должно существовать ни одного активного указателя на объекты, предоставленные этим плагином, и ни одной активной операции, выполняемой кодом плагина — иначе выгрузка библиотеки из памяти процесса, в то время как код этой библиотеки ещё выполняется или на него ссылаются живые указатели, приведёт к обращению по освобождённому адресу.

Пример кода

#pragma once
#include <QPluginLoader>
#include <QFileSystemWatcher>
#include <QObject>
#include "iexportplugin.h"

class HotReloadablePlugin : public QObject
{
    Q_OBJECT
public:
    explicit HotReloadablePlugin(const QString &pluginPath, QObject *parent = nullptr)
        : QObject(parent), m_pluginPath(pluginPath)
    {
        loadPlugin();

        // Отслеживаем изменение файла плагина на диске (после пересборки)
        m_watcher.addPath(QFileInfo(pluginPath).absolutePath());
        connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
                this, &HotReloadablePlugin::onDirectoryChanged);
    }

    IExportPlugin *current() const { return m_currentPlugin; }

signals:
    void pluginReloaded();

private slots:
    void onDirectoryChanged()
    {
        const QDateTime newModified = QFileInfo(m_pluginPath).lastModified();
        if (newModified <= m_lastLoadedModified) {
            return; // файл не изменился по сравнению с уже загруженной версией
        }

        // КРИТИЧНО: убеждаемся, что нет активных операций перед выгрузкой
        if (m_operationInProgress) {
            qWarning() << "Отложенная перезагрузка: плагин занят, повторим позже";
            QTimer::singleShot(500, this, &HotReloadablePlugin::onDirectoryChanged);
            return;
        }

        reloadPlugin();
    }

private:
    void loadPlugin()
    {
        m_loader = std::make_unique<QPluginLoader>(m_pluginPath);
        QObject *instance = m_loader->instance();
        m_currentPlugin = qobject_cast<IExportPlugin *>(instance);
        m_lastLoadedModified = QFileInfo(m_pluginPath).lastModified();

        if (!m_currentPlugin) {
            qWarning() << "Ошибка загрузки плагина:" << m_loader->errorString();
        }
    }

    void reloadPlugin()
    {
        qDebug() << "Обнаружено изменение плагина, выполняем hot-reload...";

        m_currentPlugin = nullptr; // обязательно ДО unload(), чтобы никто не использовал старый указатель
        m_loader->unload();
        m_loader.reset();

        loadPlugin();
        emit pluginReloaded();
    }

    QString m_pluginPath;
    std::unique_ptr<QPluginLoader> m_loader;
    IExportPlugin *m_currentPlugin = nullptr;
    QFileSystemWatcher m_watcher;
    QDateTime m_lastLoadedModified;
    bool m_operationInProgress = false;
};

Пояснения к коду

QFileSystemWatcher отслеживает директорию с файлом плагина (отслеживание именно директории, а не самого файла, надёжнее в большинстве случаев, поскольку процесс пересборки часто заменяет файл целиком, что для некоторых ОС выглядит как удаление старого и создание нового inode, а не модификация существующего файла — отслеживание отдельного файла может «потерять» такое событие). При обнаружении изменения времени модификации файла относительно уже загруженной версии запускается reloadPlugin().

Критичный момент в reloadPlugin() — присваивание m_currentPlugin = nullptr до вызова m_loader->unload(): это гарантирует, что любой код, проверяющий актуальность указателя через current(), увидит nullptr и не попытается использовать уже выгружаемый объект, минимизируя окно гонки между моментом начала выгрузки и моментом, когда библиотека действительно покидает память процесса. Проверка m_operationInProgress (флаг, который в реальном коде должен устанавливаться вокруг каждого вызова метода плагина) откладывает перезагрузку, если плагин в данный момент занят выполнением операции.

Подводные камни

  • Hot-reload принципиально несовместим с любым состоянием, которое плагин хранит между вызовами и которое не сериализуется/восстанавливается явно. Если плагин накопил какое-то внутреннее состояние (кеш, открытые ресурсы), при выгрузке это состояние теряется безвозвратно — новый экземпляр плагина после перезагрузки начинает «с нуля», и приложение должно быть готово к этому (например, переинициализировать плагин нужными данными сразу после pluginReloaded).
  • Гонка между обнаружением изменения файла и завершением процесса записи новой версии библиотеки на диск. Системы сборки иногда записывают файл библиотеки не атомарно (постепенно дописывая байты), и QFileSystemWatcher может сработать на промежуточном, ещё не полностью записанном состоянии файла — попытка загрузить такой неполный файл через QPluginLoader завершится ошибкой загрузки; более надёжные системы сборки записывают во временный файл и атомарно переименовывают его в целевое имя после завершения записи, что не создаёт такого окна гонки.
  • Утечка ресурсов ОС, удерживаемых старой версией динамической библиотеки, если unload() не до конца освобождает все её ресурсы из-за каких-либо внешних ссылок, оставшихся живыми (например, статический объект внутри самой библиотеки плагина, на который что-то снаружи продолжает ссылаться) — на некоторых платформах unload() может вернуть false, и библиотека технически останется в памяти процесса, даже если код приложения считает её выгруженной.
  • Hot-reload в продакшен-окружении конечного пользователя — практика, оправданная почти исключительно для самого процесса разработки. Включение этого механизма в финальной поставке приложения (а не только в режиме разработки/отладки) добавляет постоянные накладные расходы на отслеживание файловой системы и риск нештатного поведения, не оправданные реальной потребностью конечного пользователя в замене плагина «на лету» без перезапуска приложения.