From 86f19a09783b82f8d927d265661ff80fc99e0405 Mon Sep 17 00:00:00 2001 From: Peter Hutterer Date: Fri, 23 Jan 2026 09:55:34 +1000 Subject: [PATCH] lua: install a timeout hook before any pcalls to prevent infinite loops If a lua pcall takes longer than one second, kill the plugin. How often to call the timeout handler is a trade-off but we err on the side of "possibly too high" since the overwhelmingly vast majority of plugins will never trigger it anyway. Gemini suggests that Lua 5.4 can do ~500k ops per second for string concat (the slowest listed), Claude suggests 1 to 10 million ops per second. The test in this patch on my 4y old cheap desktop runs the timeout hook roughly every 37ms. Any normal plugin will be well and truly done with its work by then. Closes: #1245 Part-of: --- src/libinput-plugin-lua.c | 24 +++++++++++++ test/test-plugins-lua.c | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/libinput-plugin-lua.c b/src/libinput-plugin-lua.c index 79f718d6..ac828f7f 100644 --- a/src/libinput-plugin-lua.c +++ b/src/libinput-plugin-lua.c @@ -116,6 +116,8 @@ struct libinput_lua_plugin { struct libinput_plugin_timer *timer; bool in_timer_func; + + usec_t lua_pcall_timeout_end; }; static struct libinput_lua_plugin * @@ -291,12 +293,34 @@ out: lua_pop(L, 1); } +static void +lua_timeout_hook(lua_State *L, lua_Debug *debug) +{ + struct libinput_lua_plugin *plugin = lua_get_libinput_lua_plugin(L); + struct libinput *libinput = lua_get_libinput(L); + usec_t now = libinput_now(libinput); + + if (usec_cmp(now, plugin->lua_pcall_timeout_end) > 0) { + luaL_error(L, "Plugin execution timeout (exceeded 1 second)"); + } +} + static bool libinput_lua_pcall(struct libinput_lua_plugin *plugin, int narg, int nres) { lua_State *L = plugin->L; + struct libinput *libinput = lua_get_libinput(L); + + plugin->lua_pcall_timeout_end = usec_add_millis(libinput_now(libinput), 1000); + + /* Hook is called every 10M instructions (10-1000ms, depending operations) */ + lua_sethook(L, lua_timeout_hook, LUA_MASKCOUNT, 10000000); int rc = lua_pcall(L, narg, nres, 0); + + /* Clear the hook */ + lua_sethook(L, NULL, 0, 0); + plugin->lua_pcall_timeout_end = usec_from_millis(0); if (rc != LUA_OK) { auto libinput_plugin = plugin->parent; const char *errormsg = lua_tostring(L, -1); diff --git a/test/test-plugins-lua.c b/test/test-plugins-lua.c index 948d7844..4d687203 100644 --- a/test/test-plugins-lua.c +++ b/test/test-plugins-lua.c @@ -25,6 +25,7 @@ #include #include +#include #include "util-files.h" #include "util-strings.h" @@ -1101,6 +1102,76 @@ START_TEST(lua_disable_wheel_debouncing) } END_TEST +START_TEST(lua_remove_plugin_on_timeout) +{ + enum when when = litest_test_param_get_i32(test_env->params, "when"); + _destroy_(tmpdir) *tmpdir = tmpdir_create(NULL); + _autofree_ char *lua = strdup_printf( + "libinput:register({1})\n" + "function infinite_loop(device)\n" + " local i = 0\n" + " while true do\n" + " i = i + 1\n" + " end\n" + "end\n" + "function new_device(device)\n" + " device:connect(\"evdev-frame\", infinite_loop)\n" + "end\n" + "%s libinput:connect(\"new-evdev-device\", infinite_loop)\n" + "%s libinput:connect(\"new-evdev-device\", new_device)\n", + when == DEVICE_NEW ? "" : "--", + when == FIRST_FRAME ? "" : "--"); + + _autofree_ char *path = litest_write_plugin(tmpdir->path, lua); + _litest_context_destroy_ struct libinput *li = + litest_create_context_with_plugindir(tmpdir->path); + _destroy_(litest_device) *dev = NULL; + + litest_with_logcapture(li, capture) { + libinput_plugin_system_load_plugins(li, + LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE); + litest_drain_events(li); + + usec_t before = usec_from_now(); + + dev = litest_add_device(li, LITEST_MOUSE); + litest_drain_events(li); + + if (when == FIRST_FRAME) { + litest_event(dev, EV_KEY, BTN_LEFT, 1); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + litest_dispatch(li); + litest_event(dev, EV_KEY, BTN_LEFT, 0); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + litest_dispatch(li); + } + + usec_t after = usec_from_now(); + usec_t elapsed = usec_delta(after, before); + + /* verify the timeout kicked in around ~1 second (but less than 2s) */ + litest_assert_int_ge(usec_to_millis(elapsed), 1000U); + if (!RUNNING_ON_VALGRIND) + litest_assert_int_lt(usec_to_millis(elapsed), 2000U); + + size_t index = 0; + litest_assert(strv_find_substring(capture->errors, + "Plugin execution timeout", + &index)); + litest_assert_str_in("exceeded 1 second", capture->errors[index]); + + litest_assert(strv_find_substring(capture->errors, + "unloading after error", + NULL)); + } + /* device events should go through now */ + litest_event(dev, EV_KEY, BTN_LEFT, 1); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + litest_dispatch(li); + litest_assert_button_event(li, BTN_LEFT, LIBINPUT_BUTTON_STATE_PRESSED); +} +END_TEST + TEST_COLLECTION(lua) { /* clang-format off */ @@ -1173,6 +1244,8 @@ TEST_COLLECTION(lua) litest_add_parametrized_no_device(lua_disable_button_debounce, params); litest_add_parametrized_no_device(lua_disable_touchpad_jump_detection, params); litest_add_parametrized_no_device(lua_disable_wheel_debouncing, params); + + litest_add_parametrized_no_device(lua_remove_plugin_on_timeout, params); } /* clang-format on */ }