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

Песочница для плагинов: изоляция и обработка крашей сторонних плагинов

2 просмотров

Введение

Плагин, написанный сторонним разработчиком и загруженный в адресное пространство основного процесса через QPluginLoader, технически имеет полный доступ ко всей памяти процесса — баг или преднамеренно вредоносный код в плагине может уронить всё приложение целиком, включая несохранённые данные пользователя в других, не связанных с этим плагином частях программы. В статье разберём подходы к изоляции плагинов разной степени строгости — от элементарной защиты от крашей до полноценного запуска плагинов в отдельном процессе.

Концепция

Полная изоляция в рамках единого процесса невозможна на уровне ОС — если плагин обращается к невалидной памяти, сигнал segmentation fault затрагивает весь процесс. Реалистичные уровни защиты: (1) перехват сигналов краша (SIGSEGV и аналоги) для аварийного, но контролируемого завершения с сохранением данных пользователя перед закрытием — не предотвращает краш, но снижает его последствия; (2) выполнение операций плагина в отдельном потоке с таймаутом, чтобы зависший (но не крашнувшийся) плагин не блокировал основной UI бесконечно; (3) по-настоящему надёжная изоляция — запуск кода плагина в отдельном процессе, общающемся с основным приложением через IPC (QLocalSocket, рассмотренный в основном цикле статей про сеть), что устраняет риск краша основного процесса полностью, но требует значительно более сложной архитектуры взаимодействия.

Пример кода

// Уровень 1: выполнение вызова плагина с таймаутом в отдельном потоке
#include <QThread>
#include <QFuture>
#include <QtConcurrent/QtConcurrent>
#include <QFutureWatcher>

class SafePluginInvoker : public QObject
{
    Q_OBJECT
public:
    void invokeWithTimeout(IExportPlugin *plugin, const QString &filePath,
                          const QByteArray &data, int timeoutMs = 5000)
    {
        auto future = QtConcurrent::run([plugin, filePath, data]() {
            return plugin->exportToFile(filePath, data);
        });

        auto *watcher = new QFutureWatcher<bool>(this);
        connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher]() {
            qDebug() << "Плагин завершил работу, результат:" << watcher->result();
            watcher->deleteLater();
        });
        watcher->setFuture(future);

        // Если плагин зависает дольше timeoutMs, сигнализируем проблему,
        // не дожидаясь его (хотя сам поток пула продолжит выполнение зависшего кода)
        QTimer::singleShot(timeoutMs, this, [watcher]() {
            if (!watcher->isFinished()) {
                qWarning() << "Плагин не завершился за отведённое время — возможно зависание";
                emit /* сигнал о проблемном плагине */;
            }
        });
    }

signals:
    void pluginTimedOut();
};
// Уровень 3 (концептуально): запуск плагина в отдельном процессе через IPC
// pluginhostprocess.cpp — отдельный исполняемый файл-хост, загружающий плагин
#include <QCoreApplication>
#include <QLocalSocket>
#include <QPluginLoader>

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    const QString pluginPath = app.arguments().value(1);
    const QString socketName = app.arguments().value(2);

    QPluginLoader loader(pluginPath);
    QObject *pluginInstance = loader.instance();
    auto *plugin = qobject_cast<IExportPlugin *>(pluginInstance);
    if (!plugin) return 1;

    QLocalSocket socket;
    socket.connectToServer(socketName);
    if (!socket.waitForConnected(3000)) return 1;

    // Получение команды от основного процесса и выполнение через плагин,
    // с результатом, отправленным обратно по тому же сокету.
    // Если plugin->exportToFile() крашит процесс — крашится ТОЛЬКО
    // этот отдельный хост-процесс, а не основное приложение.
    QObject::connect(&socket, &QLocalSocket::readyRead, [&]() {
        const QByteArray request = socket.readAll();
        const bool result = plugin->exportToFile("output.tmp", request);
        socket.write(result ? "OK" : "FAIL");
    });

    return app.exec();
}

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

SafePluginInvoker выполняет вызов метода плагина в фоновом потоке через QtConcurrent::run и параллельно запускает таймер тайм-аута — если вызов не завершился за отведённое время, приложение получает сигнал о потенциальном зависании плагина и может, например, предупредить пользователя или, в крайнем случае, инициировать перезапуск приложения, не дожидаясь зависшего вызова бесконечно. Важная оговорка: сам зависший поток продолжает существовать и потреблять ресурсы (поток нельзя безопасно «убить» извне в Qt/C++), это лишь способ не дать UI зависнуть synchronously вместе с плагином.

Концептуальный пример с отдельным процессом-хостом (pluginhostprocess) показывает архитектуру по-настоящему надёжной изоляции: каждый плагин (или группа плагинов) запускается в собственном процессе, общающемся с основным приложением через QLocalSocket — если код плагина внутри этого хост-процесса крашится, операционная система просто завершает только этот отдельный, изолированный процесс, никак не затрагивая память и состояние основного приложения.

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

  • Перехват сигналов краша (SIGSEGV и аналогичные) технически возможен через signal()/sigaction() на Linux/macOS, но крайне ограничен в том, что безопасно можно сделать внутри обработчика сигнала — после реального segmentation fault состояние памяти процесса уже непредсказуемо, и попытка выполнить сложные операции (например, полноценное сохранение файла через стандартные функции работы с файлами, не являющиеся async-signal-safe) внутри такого обработчика сама может крашнуться или повредить данные — практическая польза от такого подхода ограничена.
  • Накладные расходы IPC-архитектуры с отдельным процессом на каждый плагин существенно выше, чем прямой вызов функции в том же адресном пространстве — сериализация данных через сокет, задержки межпроцессного взаимодействия делают этот подход неподходящим для плагинов, вызываемых очень часто с малым объёмом данных за вызов (например, фильтр, применяемый к каждому кадру видео в реальном времени).
  • Не каждый тип плагина технически может быть изолирован в отдельный процесс без серьёзного架構урного рефакторинга. Плагины, требующие прямого доступа к общим структурам данных приложения по указателю (а не через сериализуемые данные) или к GUI-объектам, разделяемым с основным процессом, плохо подходят для модели «отдельный процесс» без существенного изменения самого интерфейса плагина в сторону передачи данных по значению, а не по ссылке/указателю.
  • Иллюзия полной безопасности при использовании только тайм-аутов без реальной изоляции процесса. Тайм-аут (уровень 1 из примера) защищает от зависания UI, но не от краша всего процесса при реальном обращении плагина к невалидной памяти — для приложений, которые действительно должны быть устойчивы к багам в непроверенных сторонних плагинах, только архитектура с отдельным процессом (уровень 3) даёт реальную гарантию, а не просто снижает вероятность видимого пользователем зависания.