From b6091f62a2a7a27c9d44e5b51ebcd9680566091b Mon Sep 17 00:00:00 2001 From: mblackman Date: Sat, 6 Jun 2026 21:59:47 -0600 Subject: [PATCH] Keep editor alive when game code throws an exception The editor runs the project in-process, so an uncaught exception from game or Lua code propagated up through the frame loop and crashed the whole editor. Add RunEditorGuarded, which in an editor session catches the exception, logs it to the console, and pauses execution instead of letting it escape; outside editor mode it is a passthrough so the player and headless tooling fail exactly as before. Applied at the seams where game code runs during an editor session: the per-frame Update tick, the input-event dispatches (Lua handlers and UI button callbacks), and the toolbar Play/Stop scene operations, which run during ImGui render outside the Update guard. --- src/Editor/Panels/EditorToolbarPanel.cpp | 11 ++++-- src/Engine/EditorExceptionGuard.h | 45 ++++++++++++++++++++++++ src/Engine/FrameLoop.cpp | 24 ++++++++++--- 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/Engine/EditorExceptionGuard.h diff --git a/src/Editor/Panels/EditorToolbarPanel.cpp b/src/Editor/Panels/EditorToolbarPanel.cpp index 8aa3797..3a43db8 100644 --- a/src/Editor/Panels/EditorToolbarPanel.cpp +++ b/src/Editor/Panels/EditorToolbarPanel.cpp @@ -10,6 +10,7 @@ #include "Editor/EditorPersistence.h" #include "Editor/ExportBuilder.h" #include "Editor/PlayerLauncher.h" +#include "Engine/EditorExceptionGuard.h" #include "Game/Game.h" #include "Game/GameConfig.h" #include "imgui.h" @@ -106,10 +107,14 @@ void DrawToolbar(Game* game, bool& showProjectSelector, bool& openSaveLayoutModa // Disabled while already running so the button reads as the current state. ImGui::BeginDisabled(!engineOptions.isPaused); if (ImGui::Button("Play")) { + // ReloadScene runs the scene's Lua, which can throw. Guard it so a broken scene logs + + // stays paused instead of crashing the editor (toolbar handlers run during ImGui render, + // outside FrameLoop's per-frame Update guard). See EditorExceptionGuard.h. + bool started = true; if (!game->IsSceneRunning() && hasScene) { - game->ReloadScene(); + started = RunEditorGuarded(gameConfig, "ReloadScene", [&] { game->ReloadScene(); }); } - engineOptions.isPaused = false; + engineOptions.isPaused = !started; } ImGui::EndDisabled(); ImGui::SameLine(); @@ -126,7 +131,7 @@ void DrawToolbar(Game* game, bool& showProjectSelector, bool& openSaveLayoutModa } ImGui::SameLine(); if (ImGui::Button("Stop")) { - game->StopScene(); + RunEditorGuarded(gameConfig, "StopScene", [&] { game->StopScene(); }); engineOptions.isPaused = true; // back to the default ready-but-paused state } diff --git a/src/Engine/EditorExceptionGuard.h b/src/Engine/EditorExceptionGuard.h new file mode 100644 index 0000000..da4579d --- /dev/null +++ b/src/Engine/EditorExceptionGuard.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "Game/GameConfig.h" +#include "General/Logger.h" + +namespace octarine::editor { + +// Run a piece of game-driving work (`fn`) that may execute project/Lua code and could throw. +// +// In an editor session a project bug must not take down the whole editor process. When `fn` throws +// there, the exception is caught, logged (so it surfaces in the editor console), and the running +// game is paused — leaving the editor alive so the author can read the error, fix the script, and +// hit Play again. Pausing on catch also stops a throwing per-frame tick from re-raising the same +// exception every frame. +// +// Outside an editor session (the standalone player, headless bench/bake tooling) the exception is +// left to propagate exactly as before: the player runs in its own process, so a crash there never +// reaches the editor, and we don't want to silently swallow failures in shipped/CI paths. +// +// `context` is a short human label for the failing phase (e.g. "Update", "Input", "ReloadScene") +// included in the log line. Returns true when `fn` completed without throwing, false when it was +// caught. +template +bool RunEditorGuarded(GameConfig& config, const char* context, Fn&& fn) { + if (!config.IsEditorMode()) { + fn(); + return true; + } + + try { + fn(); + return true; + } catch (const std::exception& ex) { + Logger::Error(std::string("[editor] Game exception in ") + context + ": " + ex.what() + " — execution paused."); + } catch (...) { + Logger::Error(std::string("[editor] Unknown game exception in ") + context + " — execution paused."); + } + config.GetEngineOptions().isPaused = true; + return false; +} + +} // namespace octarine::editor diff --git a/src/Engine/FrameLoop.cpp b/src/Engine/FrameLoop.cpp index 76db860..84ca171 100644 --- a/src/Engine/FrameLoop.cpp +++ b/src/Engine/FrameLoop.cpp @@ -10,6 +10,7 @@ #include "AssetManager/AssetManager.h" #include "Components/ViewportInfo.h" #include "ECS/Registry.h" +#include "Engine/EditorExceptionGuard.h" #include "Engine/EngineContext.h" #include "Engine/EngineRuntime.h" #include "EventBus/EventBus.h" @@ -83,6 +84,11 @@ void FrameLoop::ProcessInput() { PROFILE_NAMED_SCOPE("Game::ProcessInput"); SDL_Event event; + // Mouse/key event emission fans out to game code (Lua input handlers, UIButtonSystem callbacks), + // so it is guarded the same way as the per-frame Update — a throwing handler pauses rather than + // crashes the editor. See FrameLoop::Update / EditorExceptionGuard.h. + auto& gameConfig = registry_->Get(); + while (SDL_PollEvent(&event)) { #ifdef OCTARINE_WITH_IMGUI ImGui_ImplSDL3_ProcessEvent(&event); @@ -102,17 +108,20 @@ void FrameLoop::ProcessInput() { case SDL_EVENT_KEY_DOWN: case SDL_EVENT_KEY_UP: { KeyInputEvent keyInputEvent = GetKeyInputEvent(&event.key); - event_bus_->EmitEvent(keyInputEvent); + octarine::editor::RunEditorGuarded(gameConfig, "Input", + [&] { event_bus_->EmitEvent(keyInputEvent); }); break; } case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: { SDL_MouseButtonEvent mouseButtonEvent = event.button; - event_bus_->EmitEvent(mouseButtonEvent); + octarine::editor::RunEditorGuarded(gameConfig, "Input", + [&] { event_bus_->EmitEvent(mouseButtonEvent); }); break; } case SDL_EVENT_MOUSE_WHEEL: { - event_bus_->EmitEvent(event.wheel.x, event.wheel.y); + octarine::editor::RunEditorGuarded( + gameConfig, "Input", [&] { event_bus_->EmitEvent(event.wheel.x, event.wheel.y); }); break; } case SDL_EVENT_WINDOW_RESIZED: @@ -143,7 +152,8 @@ void FrameLoop::Update(const float deltaTime) { } #endif - auto& options = registry_->Get().GetEngineOptions(); + auto& gameConfig = registry_->Get(); + auto& options = gameConfig.GetEngineOptions(); // Master volume + mute live at the mixer level so they apply to every track (including loops // already playing). Synced every frame — and outside the pause gate — so toggling mute reacts @@ -174,7 +184,11 @@ void FrameLoop::Update(const float deltaTime) { #endif if (!options.isPaused || options.stepFrame) { - registry_->Update(deltaTime * options.timeScale); + // The per-frame tick runs every game system, including ScriptSystem's Lua calls. In an editor + // session an uncaught exception here is caught + paused instead of crashing the editor; in the + // player/headless paths it propagates unchanged. See EditorExceptionGuard.h. + const float scaledDelta = deltaTime * options.timeScale; + octarine::editor::RunEditorGuarded(gameConfig, "Update", [&] { registry_->Update(scaledDelta); }); options.stepFrame = false; } else { // If paused, we might still want to clear some per-frame signals so they don't get stuck.