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: <https://gitlab.freedesktop.org/libinput/libinput/-/merge_requests/1416>
This commit is contained in:
Peter Hutterer 2026-01-23 09:55:34 +10:00 committed by Marge Bot
parent cc1499fbb3
commit 86f19a0978
2 changed files with 97 additions and 0 deletions

View file

@ -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);

View file

@ -25,6 +25,7 @@
#include <fcntl.h>
#include <inttypes.h>
#include <valgrind/valgrind.h>
#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 */
}