Безопасность исключений и устойчивость инвариантов движка
Этот документ объясняет, как движок сохраняет согласованное состояние при исключениях. Главное требование: исключение посреди составного изменения состояния (создание, регистрация, уничтожение и инвалидирование сущности, межсущностные связи, персистентность) не должно оставлять работающий процесс в частично измененном состоянии, которое исправляется только перезапуском.
1. Нехватка памяти не является восстанавливаемой ошибкой
Пути выделения памяти движка завершают процесс при исчерпании памяти, а не бросают исключение:
safe_alloc::make_refcounted/MakeRaw/MakeUnique/MakeRawArr(Source/Essentials/MemorySystem.h) используютnothrow new, освобождают фиксированный резерв, повторяют попытку и вызываютReportAndExit. Так создаются все сущности (Item,Critter,Map,Location,CustomEntityи другие).SafeAllocator<T>обслуживает все контейнерные псевдонимы движка изSource/Essentials/Containers.h:vector,small_vector, хешированные и упорядоченные наборы и словари,list,deque,stringи потоки строк. Рост, вставка, резервирование и рехеширование не бросаютstd::bad_alloc: процесс детерминированно завершается в точке неудачного выделения.- Кодек анимации моделей устанавливает в
ModelAnimationData.cppадаптер Ozz на основеSafeAllocator<uint8_t>до создания объектов Ozz. Каждый статически связанный модуль устанавливает собственный адаптер, а vendored исходники Ozz остаются идентичны закрепленной upstream-версии. safe_alloc::malloc_raw/CallocRaw/ReallocRaw/FreeRawи выровненные варианты применяют ту же последовательность report, резерв, retry,ReportAndExitк C-совместимому выделению. Через них подключены SDL, Effekseer, spine-cpp, libpng и curl; низкоуровневые rpmalloc-примитивы остаются локальными дляMemorySystem.cpp.move_only_function/copyable_functionизSource/Essentials/FunctionObjects.hдержат небольшой nothrow-movable target inline, а крупный выделяют через nothrow path сReportFatalAndExitпри исчерпании. Модуль расположен доMemorySystemи не может использоватьSafeAlloc, но хранение callable движка всё равно завершает процесс, а не возвращаетstd::bad_alloc.ModelMeshBakerодин раз, до параллельных заданий, передает приватной meshoptimizer callbacks наSafeAllocator<uint8_t>. Эта зависимость используется только baker-ом и не входит в runtime-читатели моделей.
Следствие: не пишите откат ради возможного выделения памяти. Если
единственная причина отказа операции состоит в выделении через словарь
движка, операция либо завершается, либо процесс останавливается в точке
отказа. Не добавляйте scope_exit/scope_fail только из-за гипотетического
роста контейнера, строки или счетчика ссылок между двумя изменениями.
Глобальный бросающий operator new по-прежнему доступен для new T и
std::allocator. Код движка должен предпочитать SafeAlloc и контейнеры
движка, если ему нужен контракт terminate-on-OOM.
Этот контракт не делает любую операцию контейнера noexcept: конструктор
элемента, преобразование, сравнение, move и swap все еще могут бросать.
Особенно важно заново вывести гарантию при замене vector на small_vector,
поскольку inline-перемещение меняет адреса. Правила выбора и границы точных
типов принадлежат Essentials.md.
std::bad_alloc остается достижим за пределами словаря памяти движка: shared
state у std::future, std::promise и std::packaged_task, std::thread,
std::filesystem::path, файловые потоки и сторонние ABI со своими
контейнерами (nlohmann::json, LibreSSL, ogg/vorbis/theora). Единственный
сохранённый std::function — hook script provider в StackTrace.h, находящийся
выше callable module движка. BasicCore,
StackTrace и BaseLogging намеренно используют стандартные контейнеры выше
MemorySystem в порядке включения; OOM-репортер не должен зависеть от
сломавшегося allocator-а. Поэтому interop-граница может обоснованно ловить
std::bad_alloc. Проект-встраиватель может вести собственный полный аудит,
но его команды и allowlist не являются нормативным доказательством движка.
Гарантия немедленного завершения относится только к памяти, не к другим
ресурсам ОС. Создание потока может бросить std::system_error, открытие файла
или сокета тоже может отказать. Например, spawn_pool_worker не является
noexcept, а submit_impl откатывает только что поставленную задачу, если
создание потока не удалось. Убирать guard допустимо только когда единственная
причина исключения действительно состоит в выделении через SafeAlloc.
2. Что может быть брошено и где это перехватывается
В работающем сервере могут распространяться:
VerificationExceptionизFO_VERIFY_AND_THROW(...)и скриптовогоverify(...);- исключения движка:
EntitySyncException,DataBaseException,GenericException, исключения менеджеров и другие; - исключения native lifecycle-кода вокруг dispatch callback-ов, включая повторные проверки после событий;
ScriptExceptionизScriptHelpers::CallInitScript, когдаInitScriptсущности не разрешается в функцию нужной сигнатуры. Это отказ движка, а не исключение скрипта. СамScriptFunc::Callявляетсяnoexcept, сообщает об исключении скрипта черезReportExceptionAndContinueи возвращаетfalse.
Игровая работа сервера выполняется заданиями WorkerPool.
WorkerPool::WorkerEntry ловит std::exception, пишет отчет и продолжает,
освобождая SyncContext; неизвестное исключение завершает процесс. Поэтому
обычное исключение задания не перезапускает сервер, а уже выполненные побочные
эффекты остаются в мире. Правила ниже нужны именно для их согласованности.
Рассылка скриптовых событий через Fire(...) является noexcept: исключение
отдельного callback-а превращается в остановку цепочки. Побочные эффекты
обработчика, включая уничтожение и перемещение сущностей, сохраняются, поэтому
движок повторно проверяет состояние после события.
У клиента та же форма восстановления на уровне кадра, но есть дополнительное обязательство renderer-а. MainEntry ловит std::exception из ClientEngine::MainLoop, сообщает о нём и продолжает следующим кадром только после Application::EndFrame, который требует отсутствия привязанного render target. Поэтому draw-блок в ClientEngine::MainLoop защищён scope_fail, вызывающим SpriteManager::AbortScene: частичный draw отбрасывается, а stacks scissor и render target полностью снимаются. Любая новая frame-scoped привязка render target обязана обеспечить такую же очистку.
AbortScene является noexcept, поскольку выполняется во время unwind. Неотказное состояние manager сбрасывается напрямую, а освобождение backend идёт через safe_call, чтобы потерянный render context был зарегистрирован, но не заменил исходное исключение. Сам backend operation остаётся throwing на обычном пути. EndScene также остаётся обычным вызовом, а не переносится в scope_success: его проверки инвариантов должны бросать, пока scope_fail ещё активен, а не из неявно noexcept destructor-а.
Нельзя бросать значения вне std::exception
Любое исключение native-кода Engine или встраивающего проекта должно наследоваться от std::exception. Integer, bare struct или стороннее нестандартное исключение проходит мимо обычных границ отчётности и запрещено.
Поэтому общий catch (...) рядом с catch (const std::exception& ex) не является recoverable error path. Он означает нарушенный инвариант и должен иметь вид:
catch (...) {
FO_UNKNOWN_EXCEPTION();
}
Нельзя журналировать такую ошибку и продолжать, создавать обычный domain error "Unknown exception" или преобразовывать её в ожидаемый отказ. Узкие исключения из disposition существуют только для no-throw teardown/unwind boundaries и самой logging/reporting machinery, где ничего не должно выйти наружу или повторно войти в reporter. Они не разрешают исходному коду бросать значение, не наследующее std::exception.
3. Контракт жизненного цикла сущности (создание / уничтожение)
Жизненный цикл намеренно не является транзакцией с общим откатом. Контракт
закреплен тестами Source/Tests/Test_EntityLifecycle.cpp и
Source/Tests/Test_ServerMapOperations.cpp.
Создание (CritterManager::CreateCritterOnMap, ItemManager::CreateItem,
MapManager::CreateLocation/CreateMap, ServerEngine::CreateCritter и
LoadCritter):
- сущность сначала создается и регистрируется, затем размещается, после чего выполняются init-скрипт и входные события;
- событие может законно уничтожить или переместить новую сущность;
- функция создания бросает исключение как сигнал неноминального завершения,
но не откатывает результат событий. Уничтоженная сущность исчезает,
перемещенная остается в новом месте, а уничтоженная при загрузке сущность
остается уничтоженной. Это проверяют
ItemInitEventMayDestroyItem,CritterInitEventMayDestroyCritter,LocationInitEventMayDestroyLocation,MapAddCritterEventMayMoveCritterAwayThrows,MapAddCritterInitEventMayMoveCritterAwayThrowsиCritterLoadEventMayDestroyLoadedCritterThrows.
Общий create-time rollback неверен: в точке исключения нельзя отличить предусмотренное перемещение выжившей сущности от утечки.
Уничтожение (DestroyCritter, DestroyItem, DestroyLocation,
DestroyMap, DestroyCustomEntity):
- сначала фиксируется
MarkAsDestroying(), повторный вызов выходит раньше; IsDestroying()иIsDestroyed()являются acquire/release atomic-latch, а изменяемое содержимое по-прежнему защищает lock сущности;- после finish-события окружение отделяется в повторяемом teardown-цикле, который ловит и сообщает исключения, пока все зависимости не сняты или progress guard не обнаружит отсутствие сходимости;
- snapshot-наборы обходятся через
copy_hold_ref(...), а после каждого события retained reference снова проверяется черезIsDestroyed().
Обработчик finish-события не может отменить уничтожение. Это закреплено
ItemFinishEventCannotTakeOverItemDestruction и аналогичными тестами
криттера и локации.
3.1 Сходимость цикла уничтожения
Каждый DestroyX опустошает коллекции дочерних сущностей и связей в цикле с
prev_deps. Таких циклов восемь: ItemManager::DestroyItem,
CritterManager::DestroyCritter и DestroyInventory,
MapManager::DestroyMapContent, DestroyMapInternal и внутренний цикл
DestroyLocation, а также EntityManager::DestroyInnerEntities и внутренний
цикл DestroyCustomEntity.
Цикл может не сходиться по трем причинам:
- Re-entrant обработчик события снова добавляет дочернюю сущность к уже уничтожаемому владельцу.
- Шаг отделения бросает на каждой итерации, поэтому коллекция не уменьшается.
- Ошибка логики оставляет число зависимостей неизменным.
Механизм выхода
Основная защита: запрет повторного добавления при уничтожении, эшелонированный по уровням из раздела 5. Источник такого добавления обычно скриптовый обработчик, поэтому правило проверяется на двух глубинах:
- на вершине каждый
FO_SCRIPT_APIadd-метод (Server_Map_AddItem,Server_Map_AddCritter,Server_Critter_AddItem,Server_Critter_AttachToCritter,Server_Item_AddItem,Server_Location_AddMap) бросаетScriptException; - внутренние методы изменения (
Entity::AddInnerEntity,CritterManager::AddItemToCritter,Map::AddCritter,Map::SetItem,Item::SetItemToContainer,Critter::AttachToCritter,Location::AddMap) повторяют проверку черезFO_VERIFY_AND_THROW.
Запрещено только расширять уничтожаемого владельца. Изменять свойства уничтожаемой сущности разрешено: ее finish-обработчик законно очищает ее.
Чтение уничтожаемой карты разрешено под lock. _hexField уничтожается
только деструктором Map, а общий _staticMap->HexField живет дольше
экземпляра. Во время drain структура grid остается доступной, меняется лишь
содержимое. Конкурентность обеспечивает эксклюзивный lock изменения, а не
проверка IsDestroying.
Поэтому query-методы используют LOCKED, NOT_DESTROYED без
NOT_DESTROYING. Если потребитель не хочет работать с умирающей картой, он
сам проверяет IsDestroying или неудачу Sync::Lock. Единое правило:
расширение умирающей сущности запрещено; чтение под lock разрешено; полностью
мертвая сущность недоступна; drain является частью уничтожения.
Предусловия методов объявляет FO_VALIDATE_ENTITY(<flags>). Это временная
диагностическая инфраструктура lock-системы. Флаги: LOCKED требует покрытие
this sync-контекстом; NOT_DESTROYED делает
FO_STRONG_ASSERT(!IsDestroyed()); NOT_DESTROYING делает бросающий
FO_VERIFY_AND_THROW(!IsDestroying()); NONE не добавляет требований.
В noexcept-теле NOT_DESTROYING недопустим: inline throw вызывает C4297
на MSVC /W4. Нулевая терпимость к предупреждениям делает это дефектом даже
без /WX; review остается основной проверкой.
Политика строгости: mutation-метод по умолчанию получает максимальные
LOCKED, NOT_DESTROYED, NOT_DESTROYING, а read/query-метод получает
LOCKED, NOT_DESTROYED. Исключения определяются тестами поведения:
noexceptникогда не принимаетNOT_DESTROYING; при необходимости он используетFO_VERIFY_AND_RETURN_VALUE.- Accessor-ы, нужные drain-циклу, не принимают
NOT_DESTROYING: коллекции inventory, inner items/entities, visibility и maps живут до деструктора. - Методы destroy/transfer cascade должны принимать
IsDestroying, потому что transfer является частью уничтожения. - Post-event re-validation, которая сначала делает
if (IsDestroyed()) return, принимает толькоLOCKED; ранний выход и есть контракт для retained, но уже уничтоженного объекта.
Положительное основание для NOT_DESTROYING одно: операция расширения или
добавления к владельцу (AddItem, AddCritter, AddMap,
SetItemToContainer и аналоги). Read/query его не используют: grid и
коллекции живут весь drain, а конкурентный доступ регулируется lock.
Последний рубеж: завершение процесса при истинной несходимости. Каждый teardown-цикл хранит локальный счетчик и требует строгого уменьшения числа оставшихся зависимостей:
for (size_t prev_deps = std::numeric_limits<size_t>::max(); cr->HasItems() || cr->HasInnerEntities() || …;) {
try { /* tear off one layer */ } catch (const std::exception& ex) { ReportExceptionAndContinue(ex); }
const size_t remaining_deps = cr->GetInvItems().size() + cr->GetInnerEntitiesCount() + …;
FO_STRONG_ASSERT(remaining_deps < prev_deps, "Critter destruction made no progress", cr->GetId(), remaining_deps, prev_deps);
prev_deps = remaining_deps;
}
Нормальный drain выходит по условию до следующей проверки, поэтому медленное,
но прогрессирующее уничтожение не считается ошибкой. Непрогрессирующий проход
вызывает FO_STRONG_ASSERT: бросающее исключение оставило бы уже финализированную,
но живую сущность в registry. Итого уничтожение либо завершается, либо процесс
детерминированно останавливается на реальной ошибке.
4. Инварианты после изменения требуют FO_STRONG_ASSERT
Условие каждого FO_VERIFY_* и FO_STRONG_ASSERT — предикат, а не выполняемое действие. Запись, flush, чтение, вставку в контейнер, изменение файловой системы или вызов с выходным параметром выполняют сначала; результат сохраняют в именованной локальной переменной и проверяют её. Например: bool written = file.write(data) && file.flush(); FO_VERIFY_AND_THROW(written, "Resource footer was not committed");, а не write/flush внутри макроса. Макросы выполняют условия во всех конфигурациях, но действие не должно прятаться под видом проверки.
Если необратимое изменение уже произошло и ложность проверки означает
повреждение мира, использовать бросающий verify поздно: WorkerPool поймал бы
его и продолжил работу с поврежденным состоянием. Такая проверка должна быть
безусловным FO_STRONG_ASSERT, вызывающим ReportExceptionAndExit во всех
профилях сборки.
Текущие примеры: согласованность typed/global registry в EntityManager,
симметрия visibility graph в Critter, post-grant инварианты
EntityLock::Acquire, данные suspended-контекста перед
AngelScriptContextManager::ResumeSpecificContext. Ожидаемые случаи остаются
бросающими проверками: duplicate id при загрузке и штатный
EntityLockWaitAbortedException при завершении.
5. Уровни ошибок и выбор реакции
Все три уровня активны и в release-сборке:
| Уровень | Когда | Механизм |
|---|---|---|
| Ожидаемая ошибка | Предусмотренное неверное значение, недоверенный ввод, отсутствующая или запрещенная цель. | Бросить доменное исключение (ScriptException, DataBaseException и т. п.) до побочного эффекта. |
| Неожиданная, но обрабатываемая | Нарушение инварианта, которое верхний уровень еще может перехватить. | Семейство FO_VERIFY_*; оно всегда пишет отчет, а суффикс выбирает дальнейший control flow. |
| Неожиданная и необрабатываемая | После изменения продолжение означало бы работу с поврежденным миром. | FO_STRONG_ASSERT и детерминированное завершение. |
Вариант FO_VERIFY_* выбирается контекстом:
FO_VERIFY_AND_THROWбросаетVerificationExceptionи допустим только там, где исключение законно распространяется к верхнему catch;FO_VERIFY_AND_CONTINUEсообщает нарушение и продолжает, поэтому подходит дляnoexcept-контекста или пропуска плохого элемента цикла;FO_VERIFY_AND_RETURNсообщает и выходит изvoid-функции;FO_VERIFY_AND_RETURN_VALUEсообщает и возвращает безопасное значение.
В noexcept-области нельзя использовать бросающий вариант. Когда продолжать
опасно, из нее по-прежнему допустим FO_STRONG_ASSERT.
Эшелонированная защита намеренно проверяет одну ошибку на нескольких уровнях:
скриптовый add-метод бросает ожидаемый ScriptException, внутренний mutation
повторяет инвариант через FO_VERIFY_AND_THROW, а несходящийся teardown
останавливается через FO_STRONG_ASSERT. Эти проверки дополняют друг друга.
Практические правила:
- неверные аргументы, недоверенный ввод, переполненная или запрещенная цель требуют доменного исключения, а не завершения процесса;
- входные данные script/RPC/client-writable property проверяются на границе до
глубокого
numeric_castи низкоуровневого verify; нижняя проверка остается backstop-ом; - восстанавливаемый инвариант до изменения использует
FO_VERIFY_AND_THROW; - инвариант после необратимого изменения использует
FO_STRONG_ASSERT; - lifecycle throw-as-signal не откатывает законный результат событий;
scope_fail/scope_exitнужны, когда реальный runtime-отказ иначе рассинхронизирует два представления; rollback-body обязан бытьnoexcept;- лучший вариант часто состоит в порядке validate-first, mutate-last.
Записи DbStorage.Insert/Update/Delete только ставят работу в очередь и не
выполняют синхронный backend I/O. Backend-ошибка обрабатывается асинхронно
через recovery op-log, reconnect и panic shutdown с replay после перезапуска,
поэтому write-through rollback для нее не нужен.
6. Примитивы
scope_exit,scope_fail,scope_successизSource/Essentials/BasicCore.hявляются RAII guard-ами;scope_failвыполняется при unwinding и статически требуетnoexceptcallback.safe_callизSource/Essentials/CommonHelpers.hвызывает функцию, поглощая исключения, и делает rollback/teardown body небросающим.copy_hold_ref(container)снимает ref-counted snapshot коллекции сущностей для re-entrant обхода.- Inline progress guard с
prev_depsтребует строго уменьшать число зависимостей и обнаруживает отсутствие прогресса без искусственного лимита итераций.
7. Тесты
Контракты закреплены Source/Tests/Test_EntityLifecycle.cpp и
Source/Tests/Test_ServerMapOperations.cpp. При изменении lifecycle или
инвариантов запускайте сгенерированную цель unit-тестов проекта-встраивателя и
расширяйте эти suites, не ослабляя assertions.
8. Классификация безопасности исключений по функциям (уровни ES)
Каждому определению функции в Source/**/*.cpp, кроме Source/Tests и
codegen-входов *.template.cpp, можно присвоить уровень фактической гарантии:
| Уровень | Гарантия |
|---|---|
NoThrow |
Исключение не выходит к вызывающему: это обеспечивает noexcept, catch-all или доказуемо небросающее тело. Terminate-on-OOM, FO_STRONG_ASSERT, небросающий Fire и варианты verify без throw не снижают уровень. |
Strong |
При исключении наблюдаемое состояние совпадает с состоянием до вызова: validate-first, read-only или полный rollback. |
Basic |
Исключение может выйти после изменения, но все инварианты и объекты остаются корректными. Lifecycle throw-as-signal намеренно относится сюда. |
None (<reason>) |
Исключение может оставить конкретный нарушенный инвариант или половинчатое состояние. Это кандидат на исправление, а не приемлемая гарантия. |
Движок задает словарь и правила вывода, но не поставляет канонический полный per-function baseline или analyzer. Проект-встраиватель может вести собственный baseline с уровнем, статусом проверки и хешем тела; такой артефакт не является нормативным доказательством движка.
Порядок вывода: noexcept, catch-all или отсутствие реальных throw points дают
NoThrow; все throw points до первого изменения либо полный rollback дают
Strong; исключение после изменения при сохраненных инвариантах дает Basic;
None допустим только с названием конкретного нарушенного инварианта.
Классифицируется тело самой функции с учетом поведения callees; lambdas,
declarations, = default, = delete, header-inline и тесты не входят.
FO_VALIDATE_ENTITY(...) при ES-классификации игнорируется: это временная
диагностическая инфраструктура. Его LOCKED и NOT_DESTROYING не считаются
throw points, а NOT_DESTROYED завершает процесс. Ручные проверки тела
считаются всегда.
noexcept является семантическим контрактом, а не записью текущего уровня.
Не добавляйте его только потому, что тело сейчас классифицировано NoThrow.
Он обоснован для move/swap-контрактов контейнеров, teardown и unwind callbacks,
C/OS ABI callback-ов и документированных небросающих Essentials-примитивов.
Снять случайный noexcept при появлении законной проверки нормально; уровень
и project-owned baseline при этом выводятся заново.
Для обоснованного noexcept учитывайте три опасности:
- Достижимый inline throw вызывает MSVC C4297; используйте небросающий вариант verify или уберите спецификатор.
- AngelScript registration отвергает указатель на
noexcept-функцию какasWRONG_CALLING_CONV; binding-слой не должен получать такой тип. - Класс с несколькими build-selected реализациями должен иметь одинаковую спецификацию в declaration и каждой реализации, иначе возникает C2382.