Окончание неожиданно распухшего туториала, который начался здесь и имел неосторожность продолжиться тут.

Не трогай, это на новый год

Прежде чем переходить к ограничению потребляемой памяти, сначала разберёмся, как, собственно, в Lua реализована работа с ней.

Каждый раз, когда Lua требуется выделить, перераспределить или освободить память, происходит вызов универсального аллокатора.

-- Создаём новую таблицу?
supply = {'Sugar', 'Water'} -- Lua запрашивает новую память через аллокатор

-- Добавляем элемент в уже существующую?
table.insert(supply, 'Yeast') -- Lua через тот же аллокатор изменяет размер

-- Загружаем новые функции?
dofile("booze_routine.lua") -- Снова дёргаеся аллокатор - выделяет под них память.

local shot = getBooze(supply)

shot.coolingTo(6)

-- Удаляем объект?
drink(shot) -- Тоже через аллокатор, но отложенно, а не в момент удаления.
            -- Сборщик мусора вызовет его когда посчитает нужным.
            -- Сам же объект в Lua считается удалённым сразу.
-- booze_routine.lua
function getBooze(rawMaterials)

    local barrel = getBarrel();

    for i = #rawMaterials,1,-1 do
        local item = table.remove(rawMaterials)
        barrel.put(item)
    end

    barrel.warmUpTo(23)

    while not barrel.isReady() do
        barrel.proceed()
    end
    return distilate(barrel)
end

function drink(what)
    what = nil
end

Если прям совсем упростить, то весь требуемый функционал — выделение, удаление и изменение размера уже выделенного блока — реализуется на коленке через realloc и free:

void *luaAlloc(void *ptr, size_t newSize)
{
    if (newSize == 0) {
        free(ptr);
        return NULL;
    }
    return realloc(ptr, newSize);
}

Собственно, стандартный именно так и выглядит. Ну, за исключением пары нюансов, о которых чуть позже.

А теперь самое интересное: Lua позволяет подменить свой стандартный аллокатор пользовательским и гарантирует, что абсолютно все запросы памяти будут идти именно через него. То есть механизм у нас есть: перехватываем контроль за выделением памяти, ведём её учёт и, в случае превышения лимитов, просто не даём лишнего.

Более того, Lua нам существенно упрощает жизнь в плане реализации учёта, так как на самом деле в аллокатор он передаёт ещё два очень полезных для нас параметра:

void *luaAlloc(void *ud, void *ptr, size_t currSize, size_t newSize);

ud — указатель на блок пользовательских данных и currSize — текущий размер блока памяти, на который указывает *ptr.

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

Дело в том, что он, как бы это сказать... он — не всегда размер. Если ptr ссылается на уже выделенную память, то это размер. А вот если Lua запрашивает выделение нового блока памяти (ptr == NULL), то он может принимать другие значения: например, код типа объекта, который Lua сейчас пытается создать, ну так, чисто в качестве подсказки. Имейте в виду — это поведение завезли только для Lua 5.2+. Можно смело пользоваться, если пишете навороченный аллокатор, оптимизирующий выделение памяти.

Всё, что нам остаётся — обеспечить такое поведение нашего аллокатора, которое удовлетворяет ожиданиям Lua:

*ptr

currSize

newSize

ожидаемое поведение

NULL

0 либо код типа

> 0

Выделяем новый блок запрошенного размера. Возвращаем указатель на него, либо NULL если выделить не удалось.

!NULL

> 0

0

Освобождаем память, на которую ссылается ptr. Возвращаем NULL.

!NULL

> 0

> 0

Прикидываемся realloc'ом: изменяем размер блока, на который указывает *ptr, возвращаем указатель в случае успеха или NULL, если увеличить не удалось.

Сама же подмена осуществляется в момент создания Lua-стейта:

void *limitedAlloc(void *ud, void *ptr, size_t currSize, size_t newSize);

constexpr size_t cLualMemoryLimit = 1 * 1024 * 1024; // 1 Mb

struct LuaAllocatorState
{
    size_t used {};
    size_t limit {cLualMemoryLimit};
} allocState;

sol::state lua(sol::default_at_panic, limitedAlloc, &allocState);

Насчёт первого аргумента — sol::default_at_panic — пока не заморачиваемся, это дефолтный sol'овский обработчик ошибок типа "всё пропало". Просто в конструкторе он идёт первым, и мы вынуждены его указать явно (можно также своим подменить, но сейчас не актуально), чтобы была возможность задать последние два аргумента.

Ну и нужно следить за тем, чтобы жизненный цикл allocState был больше, чем у самого lua::state, т.к. при удалении последнего Lua активно использует наш аллокатор для того, чтобы прибить всё, что успел навыделять для себя.

Да, немаловажный момент: обращаю внимание на то, что аллокатор именно на Lua-стейт ставится. То есть он у нас будет общий на все песочницы, которые крутятся на этом стейте. Следовательно, и лимит на выделяемый объём памяти будет общий для всех них — это нужно учитывать при его определении. Если же прям жёсткого контроля захочется, то тогда придётся работать по схеме один рантайм — одна песочница.

На этом теории, пожалуй, хватит.

Поехали:

namespace lua::memory
{
    constexpr size_t c1MB = 1L * 1024 * 1024;
    constexpr size_t cDefaultMemLimit = c1MB;

    struct LimitedAllocatorState
    {
        size_t used {};
        size_t limit {cDefaultMemLimit};

        // Аварийные флаги
        bool limitReached {false};
        bool overflow {false};

        [[nodiscard]]
        bool isLimitEnabled() const { return limit > 0; }
        void disableLimit() { limit = 0; }
        void resetErrorFlags() noexcept { limitReached = overflow = false; }        
    };

    void *limitedAlloc(void *ud, void *ptr, size_t currSize, size_t newSize) noexcept
    {
        auto *allocState = static_cast<LimitedAllocatorState*>(ud);

        // Отказываемся работать без указателя на состояние
        if (allocState == nullptr) {
            assert(allocState != nullptr
                   && "Pointer to the allocator state must be provided.");
            return nullptr;
        }
        if (ptr == nullptr) {
            // Здесь обработка подсказок насчёт типа создаваемого объекта.
            // ...должна быть. Но в нашем случае не актуально — опускаем.
            // И обнуляем currSize для того,
            // чтобы дальше арифметика коректно работала.
            currSize = 0;
        }
        if (newSize == 0) {
            if (ptr != nullptr) {
                // Просто на всякий случай, чтобы не улететь ниже 0
                allocState->used -= (allocState->used >= currSize)
                                    ? currSize
                                    : allocState->used;
            }
            std::free(ptr);
            return nullptr;
        }
        // Опять же, чтобы не свалиться ниже 0 при дальнейших расчётах
        const size_t usedBase = (allocState->used >= currSize)
                                ? allocState->used - currSize
                                : 0;
        // Защита от переполнения
        if (newSize > (std::numeric_limits<size_t>::max() - usedBase)) {
            allocState->overflow = true;
            return nullptr;
        }
        
        const size_t newUsed = usedBase + newSize;

        // Проверяем, нет ли попытки откусить больше дозволенного
        if (allocState->isLimitEnabled() && newUsed > allocState->limit) {
            allocState->limitReached = true;
            return nullptr;
        }
        void *newPtr = std::realloc(ptr, newSize);
        if (newPtr != nullptr) {
            allocState->used = newUsed;
        }
        return newPtr;
    }
} // namespace lua::memory

Ну а подружить с LuaRuntime — это уже совсем тривиальная задача.

class LuaRuntime
{
private:
    lua::memory::LimitedAllocatorState allocatorState;
    lua_Alloc allocatorFn{nullptr};

public:
    sol::state state; // state у нас уже был объявлен, но здесь я его намеренно
                      // указал ещё раз, чтобы подчеркнуть порядок объявления полей
                      // от которого зависит их время жизни:
                      // allocatorState должен быть объявлен раньше чем state,
                      // чтобы гарантировать работу аллокатора,
                      // на этапе удаления state.
    ...
public:
    // Добавляем ещё один конструктор в LuaRuntime,
    // и теперь можем создавать рантаймы с поддержкой лимитов на память
    LuaRuntime(size_t memoryLimit, lua_Alloc fn = lua::memory::limitedAlloc)
        : allocatorState({.limit = memoryLimit}),
          allocatorFn(fn),
          state(sol::default_at_panic, fn, &allocatorState)
    {}

    // Чтобы не возвращаться к этому вопросу позже —
    // сразу реализуем возможность сброса lua-стейта
    void reset()
    {
        if (allocatorState.isActivated()) {
            // Т.к. и для старого и для нового sol::state у нас один и тот же
            // allocatorState, причём сначала создастся новый и только потом будет
            // удалён старый, то в теории возможен выход за пределы установленного
            // лимита. Поэтому просто отключим лимит на время этих телодвижений.

            // Сохраняем для нового стейта
            const auto currentLimit = allocatorState.limit;
            allocatorState.disableLimit();

            state = sol::state(sol::default_at_panic, allocatorFn, &allocatorState);
            // К этому моменту allocatorState.used у нас сначала увеличился на
            // размер выделенной памяти для нового lua-стейта, а затем уменьшился
            // на весь объём памяти предыдущего.
            // Т.е. сейчас он у нас полностью соответствует новому.
            // Его вместе с размером лимита переносим в новый allocatorState,
            // а все остальные поля сбрасываем до дефолтных значений.
            allocatorState = {.used = allocatorState.used, .limit = currentLimit};
        } else {
            state = sol::state();
        }
    }

    [[nodiscard]]
    bool hasAllocError() const noexcept
    {
        return allocatorState.limitReached || allocatorState.overflow;
    }
    void resetAllocErrors() noexcept { allocatorState.resetErrorFlags(); }

    [[nodiscard]]
    auto getAllocatorState() const
        -> const lua::memory::LimitedAllocatorState &
    { 
        return allocatorState;
    }

    [[nodiscard]]
    bool usesLimitedAllocator() { return allocatorFn != nullptr; }
 
    // Ну и механизм изменения лимита на лету — потом пригодится.
    bool LuaRuntime::setMemoryLimit(size_t limit)
    {
        if (allocatorState.isActivated()) {
            allocatorState.limit = limit;
        }
        return allocatorState.isActivated();
    }
    ...
};

Теперь Lua, получив вместо запрошенного куска памяти неожиданный nullptr, сгенерирует соответствующую ошибку. А стандартный способ их перехвата — это проверка результата выполнения скрипта:

LuaRuntime lua(16'384); // 16 Kb
LuaSandbox sandbox(lua, LuaSandbox::Presets::Minimal);

// Здесь с тем же успехом мог быть вызов через sandbox.runFile()
auto result = sandbox.run(R"(
    chalkboard = {}
    while true do
        table.insert(chalkboard, "I will not waste chalk" .. ", ")
    end
)");

if (!result.valid()) {
    if (lua.hasAllocError()) {
        ...
        // Если нужно, можем прям детально до конкретного варианта ошибки докопаться:
        // auto allocatorState = lua.getAllocatorState();
        // if (allocatorState.limitReached) {...};
        // if (allocatorState.overflow) {...};
        lua.resetAllocErrors(); // И можно дальше работать
    } else { 
        // остальные виды рантайм ошибок
        sol::error error = result;
        std::cout << std::format("Script execution resulted in the following error: \"{}\"\n",
                                 error.what());
    }
}

Точнее — один из стандартных способов, но наиболее подходящий для нас, т.к. альтернатива — это исключения, которыми код sol2 обмазан более чем достаточно, и в нашем случае их хотелось бы избежать (ну геймдев же, ну) Ⓒ. К счастью, sol2 позволяет их запрещать явно.

Помните я в начале говорил о том, что для подключения Lua к нашему C++ проекту достаточно одного заголовочного файла? Я немного упростил. На самом деле нам придётся ещё задать несколько опций для конфигурации sol2, но на том этапе это была избыточная информация. Теперь можно )

#define SOL_ALL_SAFETIES_ON 1   // Включает все доступные в sol2 механизмы безопасности,
                                // в т.ч. частично заменяет выброс исключений в случае 
                                // возникновения ошибок Lua на возврат самих ошибок
                                // в виде sol::protected_function_result.

#define SOL_NO_EXCEPTIONS 1     // Отключаем вообще все исключения внутри sol2
#define SOL_LUA_VERSION 501     // Явно указываем используемую версию Lua

#include <sol/sol.hpp> // И только потом подключаем сам заголовочный файл

Предлагаю на тему аллокаторов на этом закруглиться, а то мы так сейчас и до перехода на mimalloc/jemalloc/tcmalloc договоримся... Что, кстати, далеко не лишено смысла.

"Я буду готова через 5 минут" (с)

Здесь, на самом деле, не так много вариантов. Наибанальнейшая мысль признаюсь, весь цикл задумывался только для того, чтобы было куда вставить этот оборот, которая первой приходит в голову — нам всего-то нужно периодически ос��анавливать выполнение Lua-скрипта для проверки прошедшего времени. Если лимит не исчерпан — возвращаем управление в Lua до следующего прерывания, если же отпущенное время истекло — генерируем ошибку и в скрипт уже не возвращаемся. К сожалению, механизм, который позволяет всё это провернуть в Lua уже есть, и нам не придётся его рожать самостоятельно.

Хуки

Lua позволяет указать ей произвольную C-функцию, которая будет вызываться при наступлении определённых событий.

int lua_sethook(lua_State *L,
                lua_Hook func,  // Произвольная функция — обработчик прерывания
                int mask,       // Тип прерывания
                int count);     // и периодичность его срабатывания

Причём в нашем распоряжении на выбор аж четыре типа прерываний. Допустимые значения mask:

LUA_MASKCALL  //  Срабатывает при каждом вызове функции (любой) интерпретатором Lua
LUA_MASKRET   //  При возврате из функций -- т.е. перед каждым return
LUA_MASKLINE  //  При переходе интерпретатора к выполнению новой строки кода
              //  и, барабанная дробь....
LUA_MASKCOUNT //  Каждый раз после выполнения определённого количества инструкций

Вот последний — как раз то, что доктор прописал. Так как, что бы мы там себе в скриптах не нашкодили — в том числе бесконечный цикл — после того как интерпретатор проглотит заданное количество инструкций (определяемое через count), он гарантированно передаст управление нашему обработчику, который не менее гарантированно все эти безобразия беспощадно прекратит. Насчёт "беспощадно" — запомним пока, чуть позже к этому моменту придётся вернуться.

Здесь, правда, есть один нюанс — учитываются именно Lua-инструкции выполненные интерпретатором. А вот выполнение С/С++ функций, вызванных из Lua не будет увеличивать счётчик инструкций т.к. интерпретатор Lua в этот момент будет находиться в состоянии ожидания их завершения. Эммм... два нюанса. Второй — если мы уже из обработчика вызовем какой-нибудь Lua-код, то его выполнение тоже не окажет влияния на счётчик инструкций. Правда этот момент нас не сильно колышет до тех пор, пока, не посетит гениальная идея сделать обработчик настолько гибким, что его поведение можно было бы настраивать скриптами.

Сам хук, как и в случае с кастомным аллокатором — всё та же простая C-функция.

typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar);

Соответственно и ограничения те же, что и с аллокатором — состояние нам тоже придётся хранить во внешней структуре. Но это мы только что проходили, так что здесь

проблем быть не должно.

И ставится он тоже в одном экземпляре на Lua-стейт. Поэтому постановку и снятие хука, вкупе с управлением его состоянием логично будет тоже поселить на уровне LuaRuntime, а не живущих на нём LuaSandbox'ах.

Ну а с идиоматическими способами

// генерации ошибок
luaL_error(L, "Timeout guard: Script timed out.");

// и их отлова
auto result = sandbox.runFile(scriptFile);

if (!result.valid()) {
    auto err = sol::error{result};
    std::cerr << err.what();
}

мы уже более чем знакомы ещё по предыдущей части статьи.

Помните, я про "беспощадность" писал? Так вот, она кроется именно здесь — ненавязчиво напоминаю о том, что при вызове luaL_error:

Происходит перепрыгивание через C++ кадры стека, и деструкторы C++ объектов на этом пути не вызываются.

Это не проблема, просто учитываем данный нюанс при реализации хука и не суём в него ничего, что потребовало бы динамического выделения памяти. Хотя там логика примитивная — тупо проверяем, не закончилось ли время выполнения, и если да, то генерируем ошибку:

namespace lua::timeoutGuard
{
    void defaultHook(lua_State *L, lua_Debug* /*ar*/) // второй аргумент не используется
    {
        auto *ctx = static_cast<HookContext*>(ud);
        if (ctx->isTimedOut()) {
            luaL_error(L, "Timeout guard: Script timed out.");
        }
    }
    ...

Если где и можно было вляпаться в динамическое выделение памяти, так это только при генерации текста ошибки. Только не спрашивайте зачем.

Весь же механизм его постановки и снятия умещается всего в нескольких строках:

    ...
    using InstructionsCount = int; // Просто для улучшения читабельности

    // Постановка
    void setHook(sol::state_view lua,
                 InstructionsCount checkPeriod,
                 lua_Hook func /* = lua::timeoutGuard::defaultHook */)
    {
        assert(checkPeriod > 0 && "Check period must be a positive integer.");
        lua_sethook(lua.lua_state(), func, LUA_MASKCOUNT, checkPeriod);
    }

    // И снятие
    void removeHook(sol::state_view lua)
    {
        lua_sethook(lua.lua_state(), nullptr, 0, 0);
    }
    ...

Ну и контекст, в котором хранится состояние, не менее минималистичен:

    ...
    namespace time = std::chrono;

    struct HookContext
    {
        using clock = time::steady_clock;

        clock::time_point deadline{}; // Собственно точка на временном континууме,
                                      // за которую нам нельзя вывалиться
        bool enabled{false};          // Признак того, что таймер запущен

        // Включаем таймер перед запуском подозрительного скрипта...
        void start(time::milliseconds limit)
        {
            enabled = true;
            deadline = clock::now() + limit;
        }

        // ... периодически проверяем не слишком ли много внимания тот к себе хочет.
        bool isTimedOut() { return enabled && clock::now() > deadline; }

        // И сбрасываем таймер после завершения выполнения скрипта,
        // причём не важно, с каким результатом — удачно или с ошибкой.
        // Иначе, если не выгрузим хук, то он всё время будет генерировать ошибку.
        void reset() { *this = HookContext{}; }
    };
} //namespace lua::timeoutGuard

Всё.

Или нет? Ошибку заметили?

В отличие от аллокатора, Lua при вызове хука не предоставляет ему указатель на пользовательские данные, и тип указателя нам явно на это намякивает:

typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar);
void defaultHook(lua_State *L, lua_Debug* /*ar*/)
{
    // auto *ctx = static_cast<HookContext*>(ud); < Это не сработает, у нас нет `ud`
    if (ctx->isTimedOut()) {
        luaL_error(L, "Timeout guard: Script timed out.");
    }
}

Так что примерно здесь халява с готовыми решениями закончилась, пошли искать варианты на передачу HookContext в сам обработчик прерывания.

  • В принципе, у нас есть Lua-стейт — можно прямо в него, как Lua-объект. Вот только доступ к нему будет и у скриптов. И даже если сделать его поля read-only через sol::readonly или sol::properety, то мы никак не сможем запретить перезаписать весь объект, плюс попытка записи в read-only поля генерирует ошибку, а это — дополнительный канал для утечки стабильности.

  • Лямбду с захватом в качестве хука подсунуть не получится — она просто не преобразуется к указателю на функцию.

  • Через глобальную переменную? У нас может быть несколько рантаймов и каждому свой независимый HookContext нужен. Помещать в неё контекст текущего рантайма перед активацией защиты по таймауту? А если в соседнем потоке другой рантайм в этот момент крутится? То есть это уже либо делать её thread_local и следить за тем, чтобы каждый Lua-стейт дёргался только из одного потока, либо делать глобальный контейнер с контекстами, из которого каждый хук каким-то образом будет выбирать именно свой.

  • Ну, либо почитать в конце концов документацию и найти там специально заточенный под это механизм — реестр (Lua registry).

Lua registry — это специальная таблица внутри Lua-стейта, предназначенная для хранения данных на стороне C/C++ кода, и она недоступна обычным Lua-скриптам.

Плюс ко всему, sol2 нам ещё и до нельзя упрощает доступ к её элементам:

sol::state lua;
// sol::state_view lua(L) // Либо так, если мы, как в случае с хуком, не владеем 
                          // стейтом, но у нас есть аргумент с lua_State *L

lua.registry()[key] = value;        // Запись в реестр
auto value = lua.registry()[key];   // Получение значения из него

Ну согласитесь, элегантно же — на голову выше варианта с передачей контекста через глобальные переменные.

Осталась всего одна маленькая, чисто символическая проблемка... передать ключ в функцию... Нда, сильно продвинулись )

Ладно, шучу, на самом деле действительно продвинулись — у нас теперь есть способ передачи контекста актуального для конкретного Lua-стейта. С ключом проще — он может быть одинаковым вообще для всех рантаймов и потоков. Его единственная задача - быть уникальным снежинком. Поэтому да, просто глобальная константа. Ну почти.

Начнём с типа. В качестве гарантированно уникального ключа RTFM нам настойчиво рекомендует использовать так называемый light userdata с адресом C/C++ объекта внутри — в sol2 он реализован под именем sol::lightuserdata. А кто мы такие, чтобы перечить мануалу?

Поэтому его мы использовать не будем (ну он реально тяжеловат для ключа), но возьмём упрощённую версию, которая является просто обёрткой над void * указателем:

struct sol::lightuserdata_value // Да и называется почти так же ))
{
    void* value;
    lightuserdata_value(void* data) : value(data) {}
    operator void*() const { return value; }
};
namespace lua::registry
{
    using Key = sol::lightuserdata_value;
    ...

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

    ...
    template <typename Tag>
    struct KeyTag
    {
        inline static char kTag{}; // В чём смысл бытия? В самом существовании.
                                   // Ну и уникальный адрес в качестве сайд-эффекта
        static auto key() noexcept -> Key { return Key{&kTag}; }
    };
} // namespace lua::registry

Таким образом мы для произвольного типа получаем уникальный ключ. И доступ к нему у нас есть из любого места программы, в том числе из нашего хука-обработчика, ради которого это всё и затевалось. Через такую вот аваду кедавру.

auto registryKey = lua::registry::KeyTag<lua::timeoutGuard::HookContext>::key();

Ну ладно-ладно, сейчас сократим.

А чтобы два раза не вставать — действительно же потом для похожих задач может понадобиться — добавим один уровень абстракции: lua::registry::TypeTaggedSlot — гарантированно уникальная запись в реестре, хранящая указатель на произвольную структуру данных, доступ к которой осуществляется через указание типа этой структуры. То есть мы вообще уходим от ручного использования ключей, пряча их под капотом, и заменяем их типом — вот что система шаблонов животворящая делает. Сейчас на код посмотрим — надеюсь, станет понятней.

И наш многострадальный хук, наконец-то, сможет приобрести законченный и, главное, уже работоспособный вид:

namespace lua::timeoutGuard
{
    void defaultHook(lua_State *L, lua_Debug* /*ar*/)
    {
        using Registry = registry::TypeTaggedSlot<HookContext>;

        auto *ctx = Registry::get(L); // Согласитесь — так гораздо лаконичней

        if (ctx == nullptr) {
            luaL_error(L, "Timeout guard: Unable to get hook context.");
        }
        if (ctx->isTimedOut()) {
            luaL_error(L, "Timeout guard: Script timed out.");
        }
    }
} // namespace lua::timeoutGuard

Ну а всю работу со слотами реестра упаковываем в TypeTaggedSlot. Ещё раз заостряю внимание — внутри храним не сами структуры, а только указатели на них.

namespace lua::registry
{
    template <typename Tag, typename DataT = Tag>
    struct TypeTaggedSlot
    {
        using SlotKey = KeyTag<Tag>; // Уникальный для данного типа ключ

        using Stored = sol::lightuserdata_value; // Всё та же обёртка над void *.
        // В данном случае он нам подходит и как тип для value — так как хранить
        // мы будем указатель на структуру данных

        // Создание записи в реестре и помещение туда указателя
        static void set(sol::state_view lua, DataT *data)
        {
            lua.registry()[SlotKey::key()] = Stored{static_cast<void *>(data)};
        }

        // Получение хранимого указателя
        static DataT *get(sol::state_view lua)
        {
            auto data = sol::object{lua.registry()[SlotKey::key()]};

            if (!data.valid() || !data.is<Stored>()) { // проверяем, есть ли вообще
                return nullptr;                        // в реестре такая запись?
            }
            return static_cast<DataT*>(data.as<Stored>().value); // void* -> DataT*
        }

        // И удаление записи из реестра
        static void remove(sol::state_view lua)
        {
            lua.registry()[SlotKey::key()] = sol::nil;
        }

        // Ну и проверка на наличие записи как таковой
        static bool empty(sol::state_view lua) { return get(lua) == nullptr; }
    };
} // namespace lua::registry

Всё, вот теперь у нас есть весь инструментарий.

Посмотрим, на него в действии — попробуем запустить скрипт с защитой по таймауту.

// Создаём рантайм с песочницей
LuaRuntime lua;
LuaSandbox sandbox(lua, LuaSandbox::Presets::Minimal);

using guardCtxRegistry = lua::registry::TypeTaggedSlot<HookContext>;

// Помещаем контекст в реестр
auto guard = HookContext{};
guardCtxRegistry::set(lua.state, &guard);

// И ставим хук
lua_sethook(lua.state.lua_state(), lua::timeoutGuard, LUA_MASKCOUNT, 10'000);

guard.start(5ms); // Активируем защиту

// Запускаем наш зело подозрительный скрипт
auto result = sandbox.runFile("exceedingly_suspected.lua"); 

guard.reset(); // Деактивируем защиту

if (!result.valid()) {
    auto err = sol::error{result};
    if (contains(err.what(), "Script timed out")) {
        // Обрабатываем эту ошибку
    }
}

...

// Наигрались? Удаляем хук
lua_sethook(lua.state.lua_state(), nullptr, 0, 0);

// И контекст
guardCtxRegistry::remove(lua.state);

Нда. Не многовато телодвижений? На мой взгляд — перебор.

Сильно сократить количество действий, конечно, не получится, но автоматизировать можно.

We need to go deeper (с) Ди Каприо

Во-первых, помещение контекста в реестр и регистрация хука — это те операции, которые прямо просятся сделать их всего один раз, при создании рантайма. Ну ладно два — их выгрузка из Lua-стейта тоже считается. И в дальнейшем просто оборачивать активацией и сбросом таймера каждый вызов Lua-кода, который мы хотим обезопасить. Но если копнуть чуть глубже, то у нас всё это время будут активными сразу две бомбы два глобальных состояния — сам хук, и запись в реестре. Что если кто-то захочет для какого-то отдельного куска Lua-кода использовать свою пару контекст/обработчик? А ведь хуки можно и для других целей использовать, где гарантия, что кто-нибудь не захочет параллельно воспользоваться этим механизмом? А тут уже просто постановкой и регистрацией не отделаешься — здравствуйте проверки установленного/сохранение/восстановление после использования.

А, ведь можно ещё глубже зарыться — вложенные вызовы: например, на стороне Lua вызываем безобидную функцию:

auto guard = HookContext{};

...

guard.start(5ms); // Активация защиты

auto result = sandbox.run(R"(
    return acceptTheFiefOfArrakis() -- Казалось бы, что может пойти не так?
)");

guard.reset();
...

Которая, фактически, является C-функцией, зарегистрированной в рантайме:

...
sandbox["acceptTheFiefOfArrakis"] = [&]() -> sol::protectef_function_result {

    guard.start(5ms); // А что у нас здесь с нашей защитой происходит?
                      // Явно не то поведение, которое уровнем выше ожидается.

    auto result = sandbox.runFile("PlansWithinPlansWithinPlans.lua");

    guard.reset();

    if (!result.valid()) {
        ...
    }
    return result;
}
...

То есть ещё и, как минимум, на контроль повторной активации таймера налипаем.

И это мы ещё даже внутрь .lua-файла не заглянули.

И вот, в свете того, что нам сейчас придётся как-то разруливать потенциальные проблемы с глобальными состояниями, которые могут быть изменены где и кем только не, идея с загрузкой контекста и хука перед каждым использованием уже перестаёт восприниматься как нечто иррациональное. И становится даже более чем рациональным после того, как посчитать реальные затраты на загрузку/выгрузку и понять, что они просто ничтожно малы по сравнению с затратами на исполнение самого защищаемого Lua-кода. Ну и добьём тем, что контроль времени выполнения требуется очень далеко не для всего Lua-кода, и даже более — нам никто не мешает реализоваться такую опцию, как "только первый запуск с контролем", после чего скрипт/функция переходит в разряд доверенных.

Поэтому, чтобы сейчас не переусложнять объяснение, есть предложение на данном этапе остановиться на решении в лоб, а тонкую оптимизацию оставить на потом (если, конечно, профайлинг(!) покажет (покажет!), что многократные постановки и снятия хука с его контекстом, действительно, дают просадку производительности). Кто сказал "технический долг"?

Итак, решение "в лоб":
Каждый запуск Lua-кода с защитой по таймауту предваряется регистрацией контекста и хука, которые выгружаются при завершении. Если хук или контекст уже были установлены — защита не активируется. Но в пределах такого блока оставляем возможность для реактивации таймера — продления его времени действия, чтобы можно было последовательно несколько кусков Lua-кода выполнить.

Напомню, это всё было "во-первых".

Лучше день потерять, потом за пять минут долететь!

А во-вторых, обещанная автоматизация потребует определённой подготовки.

Для начала обмажемся ещё одним слоем абстракции: Watchdog, на плечи которой ляжет рутина по регистрации контекста в реестре, постановка хука и активация таймера, включая проверки естественно. Ну и обратные действия конечно. Кроме того, Watchdog у нас будет привязываться к конкретному Lua-стейту.

namespace lua::timeoutGuard
{
    constexpr auto kDefaultCheckPeriod {10'000}; // Дефолтный период вызова хука
	constexpr auto kDefaultLimit {5ms};          // Дефолтный таймаут

    class Watchdog
    {
    private:
        lua_State *lua{nullptr};          // Привязка к конкретному Lua-стейту
        InstructionsCount checkPeriod{0}; // Период вызова хука, в кол-ве инструкций
        lua_Hook hook{nullptr};           // Указатель на сам хук
        HookContext context{};            // Ну и его контекст

        bool running{false};              // Признак того, что защита активирована

    public:
        // Алиас регистрового слота для контекста, просто сократим для читабельности
        using CtxRegistry = registry::TypeTaggedSlot<HookContext>;

        // Конструктор, с обязательной привязкой к Lua-стейту
        Watchdog(sol::state_view lua,
                 InstructionsCount checkPeriod = kDefaultCheckPeriod,
                 lua_Hook hookFn = defaultHook)
            : lua(lua),
              checkPeriod(checkPeriod > 0 ? checkPeriod : kDefaultCheckPeriod),
              hook(hookFn)
        {}

        // Отключаем возможность копирования и перемещения во избежание эксцессов
        Watchdog(const Watchdog &) = delete;
        Watchdog &operator=(const Watchdog &) = delete;
        Watchdog(Watchdog &&) = delete;
        Watchdog &operator=(Watchdog &&) = delete;

        // При уничтожении объекта обязательно нужно подчистить за собой Lua-стейт:
        // снять хук, если остался и выгрузить контекс из реестра
        ~Watchdog() { detach(); }

        // Привязка к Lua-стейту
        bool attach(sol::state_view newLua, bool force = false);

        // И отсоединение от него
        void detach();

        // Изменение параметров хука — можно задать период вызова и саму функцию,
        // которую будем вызывать 
        bool configureHook(InstructionsCount newCheckPeriod, lua_Hook newHook);

        bool armed() { return running; } // Праверка активности зашиты
        bool timedOut() { return context.isTimedOut(); } // Проверка сработки

        bool arm(time::milliseconds limit);   // Активация защиты
        bool rearm(time::milliseconds limit); // Используется для продления времени
                                              // действия уже активированной защиты.
        void disarm();                        // Деактивация защиты

    private:
        // Проверка на предмет привязки к какому-нибудь Lua-стейту
        bool attached() { return lua != nullptr; } 
    };
    // Привязка к Lua-стейту
    bool Watchdog::attach(sol::state_view newLua, bool force /* = false */)
    {
        // Осуществляем привязку только в том случае, если в данный момент защита
        // не активирована. Мы же условились, что активирована она может быть только
        // непосредственно перед выполнением защищаемого Lua-кода, следовательно
        // переключение Lua-стейта сейчас — явно нечто странное.
        if (force) {
            // Но допускаем принудительную перепривязку через соотвествующую опцию
            detach();
        } else if (armed()) {
            std::cerr << "Cannot attach timeout watchdog to a new Lua state while it's armed\n";
            return false;
        }
        lua = newLua;
        return true;
    }

    // Отвязка от текущего Lua-стейта
    void Watchdog::detach()
    {
        disarm();
        lua = nullptr;
    }

    // Задаём параметры хука — период вызова и саму функцию — обработчик
    // Здесь только задаём значения, в Lua-стейт не ставится!
    bool Watchdog::configureHook(InstructionsCount newCheckPeriod, lua_Hook newHook)
    {
        if (armed()) { // Запрет на изменения если защита взведена
            std::cerr << "Cannot change timeout watchdog hook settings while it's armed\n";
            return false;
        }
        if (newCheckPeriod <= 0) { // Защита от установки ошибочных значений
            std::cerr << "Unable to change timeout watchdog hook settings: "
                         "Check period has to be a positive integer\n";
            return false;
        }
        if (newHook == nullptr) { // Защита от установки ошибочных значений
            std::cerr << "Unable to change timeout watchdog hook settings: "
                         "Hook function pointer cannot be null\n";
            return false;
        }
        checkPeriod = newCheckPeriod;
        hook = newHook;
        return true;
    }

    // Взведение защиты в активное состояние
    bool Watchdog::arm(time::milliseconds limit)
    {
        if (armed()) { // Опять же, защита от повторного взведения
            std::cerr << "Unable to arm timeout watchdog: already armed\n";
            return false;
        }
        if (!attached()) { // Не даём взвестись, если не привязаны к Lua-стейту
            std::cerr << "Unable to arm timeout watchdog: "
                         "Lua state is not properly initialized\n";
            return false;
        }
        // Проверка на незанятость ячейки реестра под контекст
        if (!CtxRegistry::empty(lua)) {
            spdlog::error("Unable to arm timeout watchdog: "
                          "Lua state already has a hook context registered");
            return false;
        }
        // Проверка на то, что кто-то другой уже не повесил свой хук
        if (lua_gethook(lua) != nullptr) {
            std::cerr << "Unable to arm timeout watchdog: Lua state already has a hook set\n";
            return false;
        }
        running = true;
        CtxRegistry::set(lua, &context); // Регистрируем контекст
        setHook(lua, checkPeriod, hook); // Ставим хук
        context.start(limit);            // Запускаем таймер
        return true;
    }

    // Принудительное обнуление таймера, для продления его времени действия
    bool Watchdog::rearm(time::milliseconds limit)
    {
        // Работает только если защита была взведена ранее
        if (!armed()) {
            spdlog::error("Unable to rearm timeout watchdog: it is not currently armed");
            return false;
        }
        context.start(limit);
        return true;
    }

    // Отключение защиты
    void Watchdog::disarm()
    {
        context.reset(); // Обнуление контекста
        const bool wasArmed = running;
        running = false;

        if (!attached() || !wasArmed) { // В этом случае нам нечего дальше выгружать
            return;
        }
        removeHook(lua); // Выгружаем хук
        CtxRegistry::remove(lua); // и его контекст из реестра
    }
} // namespace lua::timeoutGuard

Смотрим, что в итоге получилось:

LuaRuntime lua;
LuaSandbox sandbox(lua, LuaSandbox::Presets::Minimal);

auto watchdog = timeout::Watchdog(lua);

watchdog.arm(5ms);

sandbox.runFile("exceedingly_suspected.lua")

if (watchdog.timedOut()) {
    ...
}

watchdog.disarm();

Ну что же, можно констатировать, что уже даже более-менее читабельно стало. Разве что ещё Watchdog внутрь LuaRuntime просится, чтобы вручную его не создавать.

Ну раз просится...

class LuaRuntime
{
    ...
public:
	sol::state state; // Опять же, оставил только для фиксации порядка объявления
    ...
private:
    // Дадим ему более конкретное наименование
	lua::timeoutGuard::Watchdog timeoutGuard;

public:
    // И доработаем конструкторы для принудительной инициализации.
	LuaRuntime()
		: state{},
		  timeoutGuard(state) // Гарантированно привязываем его к стейту
	{}

	LuaRuntime(size_t memoryLimit,
               lua::memory::Allocator fn = lua::memory::limitedAlloc)
		: allocatorState({.limit = memoryLimit}),
		  allocatorFn(fn),
		  state(sol::default_at_panic, fn, &allocatorState),
		  timeoutGuard(state) // И здесь
	{}
    ...
    // Остаётся только дополнить reset(), чтобы при создании нового Lua-стейта
    // Watchdog у нас автоматически привязывался к новой инкарнации
    void LuaRuntime::reset()
    {
        ...	
        // Принудительная перепривязка Lua-стейта
        timeoutGuard.attach(state, true); 
    }
};

Но и это ещё не всё. (Потерпите, чуть-чуть осталось)

Обратили внимание, что timeoutGuard — приватный, и отсутствие геттера делает невозможным его использование снаружи? Дело в то��, что и его применение можно автоматизировать.

Отпрыгнем немного в сторону:

В C++ имеется механизм конструкторов и деструкторов, которые автоматически вызываются в момент инициализации объектов и при завершении их времени жизни соответственно. Объекты с автоматическим временем хранения уничтожаются автоматически при выходе из блока, в котором они были объявлены.

Понимаете, к чему я клоню? Мы можем создать временный объект и вызовы arm/disarm поместить в его конструктор с деструктором, и обернуть это всё фигурными скобками поместить его в один блок с вызовом защищаемого Lua-кода:

LuaRuntime lua;
LuaSandbox sandbox(lua, LuaSandbox::Presets::Minimal);

// Здесь вызываем доверенный код
sandbox.runFile("trusted_code.lua");

{ // Блок подозрительного кода
    auto scopeGuard = sandbox.makeTimeoutGuardedScope(5ms); // arm() в конструкторе

    sandbox.runFile("exceedingly_suspected.lua")
    if (scopeGuard.timedOut()) {
        ...
    }
    
    // При необходимости, можно прямо здесь следующую порцию Lua-кода запустить
    scopeGuard.rearm(); 

    sandbox["someFunction"](); // Да, с отдельными функциями тоже работает
    if (scopeGuard.timedOut()) {
        ...
    }
} // disarm() в деструкторе scopeGuard

Ну согласитесь — из-за такого стоит ещё немного поднапрячься ;)

Ну там, правда, чуть-чуть осталось. Итак, GuardedScope — обёртка теперь уже над Watchdog:

namespace lua::timeoutGuard
{
	class GuardedScope
	{
	private:
		Watchdog *watchdog{nullptr}; // Указатель на оборачиваемый Watchdog

	public:
        // Watchdog передаём по ссылке, чтобы гарантировать, что объект существует
		GuardedScope(Watchdog &watchdog, time::milliseconds limit = kDefaultLimit)
			: watchdog(&watchdog)
		{
            // Собственно, то, ради чего всё и затевалось — arm в конструкторе
			if (!watchdog.arm(limit)) {
				disable();
			}
		}
        // Запрещаем копирование
		GuardedScope(const GuardedScope &) = delete;
		GuardedScope &operator=(const GuardedScope &) = delete;

        // А вот move-конструктор пригодится для для фабричных методов
		GuardedScope(GuardedScope &&other) 
            : watchdog(other.watchdog)
        { 
            other.disable(); // Деактивируем источник
        }
		GuardedScope &operator=(GuardedScope &&other) = delete; // тоже удаляем

		// Автоматическая деактивация Watchdog
        ~GuardedScope()
		{
			if (disabled()) {
				return;
			}
			watchdog->disarm();
		}

        // Возможность продления времени действия таймера для последующих
        // порций Lua-кода хапускаемых в этом же scope
		bool rearm(time::milliseconds limit = kDefaultLimit)
		{
			if (disabled()) {
				return false;
			}
			return watchdog->rearm(limit);
		}
        // Проверка на сработку
		bool timedOut() { return !disabled() && watchdog->timedOut(); }

	private:
		void disable() { watchdog = nullptr; } // Ну и деактивация 

		bool disabled() { return watchdog == nullptr; } // с соотвествующей проверкой
	};
} // namespace lua::timeoutGuard

И последний штрих: добавляем цепочку методов LuaSandbox -> LuaRuntime для получения экземпляра GuardedScope:

auto LuaRuntime::makeTimeoutGuardedScope(std::chrono::milliseconds limit)
    -> lua::timeoutGuard::GuardedScope
{
    return lua::timeoutGuard::GuardedScope{timeoutGuard, limit};
}

auto LuaSandbox::makeTimeoutGuardedScope(std::chrono::milliseconds limit)
    -> lua::timeoutGuard::GuardedScope
{
    return runtime->makeTimeoutGuardedScope(limit);
}

Вот теперь точно всё.

High Score

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

Хотелось бы, конечно, иметь возможность просто подключить его к своему движку, нажать кнопку "сделать зае и, выставив несколько отвечающих за изоляцию опций на этапе компиляции, получить готовое решение, полностью нас устраивающее. Да, конечно, есть альтернативные варианты разной степени готовности — например, Luau — решение от создателей Roblox, но это компромисс ценой частичной потери гибкости и совместимости. Что же касается ванильного Lua, то, к сожалению, пока имеем то, что имеем: приходится пилить изоляцию вручную — слой за слоем, закрывая потенциальные дыры. Этим циклом мы успели охватить основную часть проблем:

  1. Изоляция окружения через sol::environment и управляемый _G, чтобы скрипты не лезли в глобальный стейт напрямую.

  2. Явные правила загрузки, (в том числе частичной), библиотек в песочницу.

  3. Безопасные замены функций, запускающих модули и файлы со скриптами, с проверкой путей и запретом байткода.

  4. Лимиты памяти через кастомный аллокатор.

  5. Ограничение времени выполнения с RAII-обёрткой для минимизации ручной рутины.

Из того, что ещё нужно, но не было охвачено:

  • Механизм контроля целостности доверенных скриптов — например, скриптов, поставляемых нами самими. Со скриптами модов пользователь может делать что угодно, но для гарантированной работоспособности движка родные скрипты трогать не стоит.

  • Hotreload — по факту изменений. В общем, логика простая: регистрируем определённые скрипты как требующие горячей перезагрузки, периодически проверяем, не изменились ли эти файлы, и, если да, то перезапускаем их.

Но прежде чем излагать эти решения в виде статьи, деталям реализации ещё нужно выкристаллизоваться. Возможно, как-нибудь потом.

Засим разрешите откланяться. Буду рад, если пригодится.


Код в удобоваримой, пригодной к использованию форме можно посмотреть здесь.