From 8ecff1e2e165a0f8901e2b489fda82cdc1339216 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Thu, 17 Sep 2026 18:55:07 +0200 Subject: [PATCH 1/2] guard plugin command invocation against uncaught exceptions A plugin command that throws (e.g. std::stoi on bad input, .at() on an unresolvable index) used to propagate through Core::runCommand and terminate the game. Catch exceptions in Plugin::invoke and report them as command failures instead. The kittens devel plugin gains a throwtest command to exercise both the std::exception and non-std paths. --- docs/changelog.txt | 1 + library/PluginManager.cpp | 19 ++++++++++++++++--- plugins/devel/kittens.cpp | 9 +++++++++ test/plugins/kittens.lua | 14 ++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 test/plugins/kittens.lua 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..628c31e6d5 --- /dev/null +++ b/test/plugins/kittens.lua @@ -0,0 +1,14 @@ +config.mode = 'fortress' +config.target = 'kittens' + +function test.exception_returns_failure() + 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() + local output, status = dfhack.run_command_silent('throwtest nonstd') + expect.eq(CR_FAILURE, status) + expect.str_find("Exception in kittens command", output) +end From 17875c25b8090a7d9accdc6bcad1a7d6fe807335 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Thu, 17 Sep 2026 19:21:25 +0200 Subject: [PATCH 2/2] test: skip kittens tests when the devel plugin is not built --- test/plugins/kittens.lua | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/plugins/kittens.lua b/test/plugins/kittens.lua index 628c31e6d5..9c3cbd2fac 100644 --- a/test/plugins/kittens.lua +++ b/test/plugins/kittens.lua @@ -1,13 +1,30 @@ 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)