diff --git a/docs/changelog.txt b/docs/changelog.txt index da5a2d4d7c..7362ea0630 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -63,6 +63,7 @@ Template for new versions: - `stocks`: the overlay's ``collapse all`` hotkey now toggles, expanding all categories again when everything is collapsed ## Fixes +- Plugin commands that throw uncaught C++ exceptions now report an error instead of crashing the game - Fix broken weather lookup in ``World::ReadCurrentWeather`` - Fixed a possible hang or assertion failure when pressing a hotkey while a DFHack GUI window was open but unfocused - In ``Screen`` module, Fix out-of-bounds color table access when Lua pens use ``COLOR_RESET`` diff --git a/library/PluginManager.cpp b/library/PluginManager.cpp index 34095898db..5052b93637 100644 --- a/library/PluginManager.cpp +++ b/library/PluginManager.cpp @@ -494,6 +494,19 @@ command_result Plugin::invoke(color_ostream &out, const std::string & command, s if (auto cmdIt = std::ranges::find_if(commands, [&](const PluginCommand &cmd) { return cmd.name == command; }); commands.end() != cmdIt) { + // an uncaught exception from a plugin command would propagate + // through runCommand and terminate the game; report it instead + auto call = [&]() -> command_result { + try { + return cmdIt->function(out, parameters); + } catch (const std::exception &e) { + out.printerr("Exception in {} command '{}': {}\n", name, command, e.what()); + return CR_FAILURE; + } catch (...) { + out.printerr("Exception in {} command '{}'\n", name, command); + return CR_FAILURE; + } + }; // running interactive things from some other source than the console would break it if (!out.is_console() && cmdIt->interactive) cr = CR_NEEDS_CONSOLE; @@ -504,15 +517,15 @@ command_result Plugin::invoke(color_ostream &out, const std::string & command, s cr = CR_WRONG_USAGE; } else { - cr = cmdIt->function(out, parameters); + cr = call(); } } else if (cmdIt->unlocked) { - cr = cmdIt->function(out, parameters); + cr = call(); } else { CoreSuspender suspend; - cr = cmdIt->function(out, parameters); + cr = call(); } } } diff --git a/plugins/devel/kittens.cpp b/plugins/devel/kittens.cpp index d9dbf27015..c328492fb5 100644 --- a/plugins/devel/kittens.cpp +++ b/plugins/devel/kittens.cpp @@ -53,6 +53,7 @@ uint64_t timeLast = 0; command_result kittens (color_ostream &out, vector & parameters); command_result ktimer (color_ostream &out, vector & parameters); +command_result throwtest (color_ostream &out, vector & parameters); command_result trackmenu (color_ostream &out, vector & parameters); command_result trackpos (color_ostream &out, vector & parameters); command_result trackstate (color_ostream &out, vector & parameters); @@ -68,6 +69,7 @@ DFhackCExport command_result plugin_init ( color_ostream &out, std::vector & parameters) return CR_OK; } +command_result throwtest (color_ostream &out, vector & parameters) +{ + if (!parameters.empty() && parameters[0] == "nonstd") + throw 42; + throw std::runtime_error("throwtest: test exception"); +} + command_result kittens (color_ostream &out, vector & parameters) { if (parameters.size() >= 1) diff --git a/test/plugins/kittens.lua b/test/plugins/kittens.lua new file mode 100644 index 0000000000..9c3cbd2fac --- /dev/null +++ b/test/plugins/kittens.lua @@ -0,0 +1,31 @@ +config.mode = 'fortress' +config.target = 'kittens' + +-- kittens is a developer plugin and is not built unless BUILD_DEV_PLUGINS is +-- on, so these tests are skipped on builds where throwtest is unavailable +local function has_throwtest() + for _, plugin in ipairs(dfhack.internal.listPlugins()) do + if plugin == 'kittens' then + for _, command in ipairs(dfhack.internal.listCommands(plugin)) do + if command == 'throwtest' then + return true + end + end + end + end + return false +end + +function test.exception_returns_failure() + if not has_throwtest() then return end + local output, status = dfhack.run_command_silent('throwtest') + expect.eq(CR_FAILURE, status) + expect.str_find('test exception', output) +end + +function test.nonstd_exception_returns_failure() + if not has_throwtest() then return end + local output, status = dfhack.run_command_silent('throwtest nonstd') + expect.eq(CR_FAILURE, status) + expect.str_find("Exception in kittens command", output) +end