diff --git a/README.md b/README.md index 5446fa988..5385a5abf 100644 --- a/README.md +++ b/README.md @@ -1835,8 +1835,10 @@ Place [this definition file](https://github.com/dawsers/scroll/blob/master/scrol in one of your development Lua runtime directories for the Lua LSP server to have access to the API information. -Using the command `lua` you can run Lua scripts that access window manager -properties, execute commands or add callbacks to window events. +Using the command `lua` (for script files) or `lua_eval` (for inline scripts) +you can run Lua scripts that access window manager properties, execute commands +or add callbacks to window events. All calls to `lua_eval` share the same +persistent state. You can assign scripts to keyboard bindings, or add them to your configuration for execution when the configuration loads. diff --git a/completions/bash/scrollmsg b/completions/bash/scrollmsg index 77b75d90f..8a49b9d81 100644 --- a/completions/bash/scrollmsg +++ b/completions/bash/scrollmsg @@ -20,6 +20,8 @@ _scrollmsg() 'get_config' 'send_tick' 'subscribe' + 'lua' + 'lua_eval' ) short=( diff --git a/completions/fish/scrollmsg.fish b/completions/fish/scrollmsg.fish index 0535e7d02..17744551a 100644 --- a/completions/fish/scrollmsg.fish +++ b/completions/fish/scrollmsg.fish @@ -24,3 +24,5 @@ complete -c scrollmsg -s t -l type -fra 'get_config' --description "Gets a JSON- complete -c scrollmsg -s t -l type -fra 'get_seats' --description "Gets a JSON-encoded list of all seats, its properties and all assigned devices." complete -c scrollmsg -s t -l type -fra 'send_tick' --description "Sends a tick event to all subscribed clients." complete -c scrollmsg -s t -l type -fra 'subscribe' --description "Subscribe to a list of event types." +complete -c scrollmsg -s t -l type -fra 'lua' --description "Execute a Lua script file with arguments." +complete -c scrollmsg -s t -l type -fra 'lua_eval' --description "Execute inline Lua code with arguments." diff --git a/completions/zsh/_scrollmsg b/completions/zsh/_scrollmsg index c6e70852e..317bbcca4 100644 --- a/completions/zsh/_scrollmsg +++ b/completions/zsh/_scrollmsg @@ -28,6 +28,8 @@ types=( 'get_config' 'send_tick' 'subscribe' +'lua' +'lua_eval' ) _arguments -s \ diff --git a/include/ipc.h b/include/ipc.h index 6805be8a8..a581198f5 100644 --- a/include/ipc.h +++ b/include/ipc.h @@ -28,25 +28,26 @@ enum ipc_command_type { IPC_GET_TRAILS = 121, IPC_GET_SPACES = 122, IPC_GET_BINDINGS = 123, + IPC_LUA_EXEC = 124, // Events sent from sway to clients. Events have the highest bits set. - IPC_EVENT_WORKSPACE = ((1<<31) | 0), - IPC_EVENT_OUTPUT = ((1<<31) | 1), - IPC_EVENT_MODE = ((1<<31) | 2), - IPC_EVENT_WINDOW = ((1<<31) | 3), - IPC_EVENT_BARCONFIG_UPDATE = ((1<<31) | 4), - IPC_EVENT_BINDING = ((1<<31) | 5), - IPC_EVENT_SHUTDOWN = ((1<<31) | 6), - IPC_EVENT_TICK = ((1<<31) | 7), + IPC_EVENT_WORKSPACE = ((1 << 31) | 0), + IPC_EVENT_OUTPUT = ((1 << 31) | 1), + IPC_EVENT_MODE = ((1 << 31) | 2), + IPC_EVENT_WINDOW = ((1 << 31) | 3), + IPC_EVENT_BARCONFIG_UPDATE = ((1 << 31) | 4), + IPC_EVENT_BINDING = ((1 << 31) | 5), + IPC_EVENT_SHUTDOWN = ((1 << 31) | 6), + IPC_EVENT_TICK = ((1 << 31) | 7), // sway-specific event types - IPC_EVENT_BAR_STATE_UPDATE = ((1<<31) | 20), - IPC_EVENT_INPUT = ((1<<31) | 21), + IPC_EVENT_BAR_STATE_UPDATE = ((1 << 31) | 20), + IPC_EVENT_INPUT = ((1 << 31) | 21), // scroll-specific event types - IPC_EVENT_LUA = ((1<<31) | 29), - IPC_EVENT_SCROLLER = ((1<<31) | 30), - IPC_EVENT_TRAILS = ((1<<31) | 31), + IPC_EVENT_LUA = ((1 << 31) | 29), + IPC_EVENT_SCROLLER = ((1 << 31) | 30), + IPC_EVENT_TRAILS = ((1 << 31) | 31), }; #endif diff --git a/include/sway/commands.h b/include/sway/commands.h index 097c7a219..fd2183ef2 100644 --- a/include/sway/commands.h +++ b/include/sway/commands.h @@ -181,6 +181,7 @@ sway_cmd cmd_layout_transpose; sway_cmd cmd_layout_widths; sway_cmd cmd_log_colors; sway_cmd cmd_lua; +sway_cmd cmd_lua_eval; sway_cmd cmd_mark; sway_cmd cmd_max_render_time; sway_cmd cmd_maximize_if_single; diff --git a/include/sway/lua.h b/include/sway/lua.h index b87e1db67..c3a55744f 100644 --- a/include/sway/lua.h +++ b/include/sway/lua.h @@ -32,6 +32,7 @@ struct sway_lua { list_t *cbs_jump_end; int command_data; list_t *cbs_command_end; + struct sway_container *context_container; }; int luaopen_scroll(lua_State *L); @@ -54,5 +55,11 @@ void lua_execute_ipc_view_cbs(struct sway_view *view, const char *change); void lua_execute_ipc_workspace_cbs(struct sway_workspace *old_ws, struct sway_workspace *new_ws, const char *change); void lua_execute_jump_end_cbs(struct sway_container *container); +struct sway_lua_script *sway_lua_get_or_create_script(const char *name); + +struct json_object; +struct json_object *sway_lua_value_to_json(lua_State *L, int i); +struct json_object *sway_lua_table_to_json(lua_State *L, int index); +void sway_lua_push_json_to_lua(lua_State *L, struct json_object *obj); #endif diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..82cbd5bd3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -n 4 diff --git a/scroll.lua b/scroll.lua index 09330ba42..c763e0982 100644 --- a/scroll.lua +++ b/scroll.lua @@ -77,6 +77,14 @@ function scroll.focused_view() end --- @return integer|nil function scroll.focused_container() end +--- +--- Returns the container ID of the execution context if the command/script +--- was run via criteria (e.g. `[class="XTerm"] lua script.lua`), or nil if +--- there is no context (run globally). +--- +--- @return integer|nil +function scroll.context_container() end + --- --- Returns the focused workspace ID or nil if none. --- @@ -653,4 +661,18 @@ function scroll.add_callback(event, cb_func, cb_data) end --- @return integer function scroll.remove_callback(id) end +--- +--- Returns true if there is an active animation running. +--- +--- @return boolean +--- +function scroll.animating() end + +--- +--- Returns true if there are pending transactions that haven't been applied yet. +--- +--- @return boolean +--- +function scroll.pending_transactions() end + return scroll diff --git a/sway/commands.c b/sway/commands.c index eee9b7c58..8f34428a6 100644 --- a/sway/commands.c +++ b/sway/commands.c @@ -86,6 +86,7 @@ static const struct cmd_handler handlers[] = { { "hide_edge_borders", cmd_hide_edge_borders }, { "input", cmd_input }, { "lua", cmd_lua }, + { "lua_eval", cmd_lua_eval }, { "mode", cmd_mode }, { "mouse_resize_tiling_limit", cmd_mouse_resize_tiling_limit }, { "mouse_warping", cmd_mouse_warping }, diff --git a/sway/commands/lua.c b/sway/commands/lua.c index fe31b8e36..9d9253de0 100644 --- a/sway/commands/lua.c +++ b/sway/commands/lua.c @@ -3,16 +3,7 @@ #include #include "sway/commands.h" #include "log.h" - -static struct sway_lua_script *find_script(list_t *scripts, const char *name) { - for (int i = 0; i < scripts->length; ++i) { - struct sway_lua_script *script = scripts->items[i]; - if (strcmp(script->name, name) == 0) { - return script; - } - } - return NULL; -} +#include "sway/lua.h" struct cmd_results *cmd_lua(int argc, char **argv) { struct cmd_results *error = NULL; @@ -84,21 +75,33 @@ struct cmd_results *cmd_lua(int argc, char **argv) { return res; } + int top = lua_gettop(config->lua.state); + + struct sway_container *old_context_container = config->lua.context_container; + if (config->handler_context.node_overridden) { + config->lua.context_container = config->handler_context.container; + } else { + config->lua.context_container = NULL; + } + int err = luaL_loadfile(config->lua.state, expanded_path); if (err != LUA_OK) { - struct cmd_results *res = cmd_results_new(CMD_FAILURE, "Error %d loading lua script %s", err, expanded_path); - free(expanded_path); - return res; + const char *str = luaL_checkstring(config->lua.state, -1); + if (str) { + res = cmd_results_new( + CMD_FAILURE, "Error %s loading lua script %s", str, expanded_path); + } else { + res = cmd_results_new( + CMD_FAILURE, "Error %d loading lua script %s", err, expanded_path); + } + goto restore_context; } // Search if there is already a state for this script - struct sway_lua_script *script = find_script(config->lua.scripts, expanded_path); + struct sway_lua_script *script = sway_lua_get_or_create_script(expanded_path); if (!script) { - script = malloc(sizeof(struct sway_lua_script)); - script->name = strdup(expanded_path); - lua_createtable(config->lua.state, 0, 0); - script->state = luaL_ref(config->lua.state, LUA_REGISTRYINDEX); - list_add(config->lua.scripts, script); + res = cmd_results_new(CMD_FAILURE, "Failed to allocate memory"); + goto restore_context; } // Create args table before running the script @@ -112,16 +115,81 @@ struct cmd_results *cmd_lua(int argc, char **argv) { err = lua_pcall(config->lua.state, 2, LUA_MULTRET, 0); if (err != LUA_OK) { const char *str = luaL_checkstring(config->lua.state, -1); - struct cmd_results *res; if (str) { res = cmd_results_new(CMD_FAILURE, "Error %s executing lua script %s", str, expanded_path); } else { res = cmd_results_new(CMD_FAILURE, "Error %d executing lua script %s", err, expanded_path); } - free(expanded_path); - return res; + goto restore_context; } + res = cmd_results_new(CMD_SUCCESS, NULL); + +restore_context: + config->lua.context_container = old_context_container; free(expanded_path); - return cmd_results_new(CMD_SUCCESS, NULL); + lua_settop(config->lua.state, top); + return res; +} + +struct cmd_results *cmd_lua_eval(int argc, char **argv) { + struct cmd_results *error = NULL; + if ((error = checkarg(argc, "lua_eval", EXPECTED_AT_LEAST, 1))) { + return error; + } + + int top = lua_gettop(config->lua.state); + + struct sway_container *old_context_container = config->lua.context_container; + if (config->handler_context.node_overridden) { + config->lua.context_container = config->handler_context.container; + } else { + config->lua.context_container = NULL; + } + + struct cmd_results *res = NULL; + + int err = luaL_loadstring(config->lua.state, argv[0]); + if (err != LUA_OK) { + const char *str = luaL_checkstring(config->lua.state, -1); + if (str) { + res = cmd_results_new(CMD_FAILURE, "Error %s loading lua string", str); + } else { + res = cmd_results_new(CMD_FAILURE, "Error %d loading lua string", err); + } + goto restore_context; + } + + // Search if there is already a state for this script + struct sway_lua_script *script = sway_lua_get_or_create_script(""); + if (!script) { + res = cmd_results_new(CMD_FAILURE, "Failed to allocate memory"); + goto restore_context; + } + + // Create args table before running the script + lua_createtable(config->lua.state, argc - 1, 0); + for (int i = 1; i < argc; ++i) { + lua_pushstring(config->lua.state, argv[i]); + lua_rawseti(config->lua.state, -2, i); + } + lua_pushlightuserdata(config->lua.state, script); + + err = lua_pcall(config->lua.state, 2, LUA_MULTRET, 0); + if (err != LUA_OK) { + const char *str = luaL_checkstring(config->lua.state, -1); + if (str) { + res = cmd_results_new(CMD_FAILURE, "Error %s executing lua string", str); + } else { + res = cmd_results_new(CMD_FAILURE, "Error %d executing lua string", err); + } + goto restore_context; + } + + res = cmd_results_new(CMD_SUCCESS, NULL); + +restore_context: + config->lua.context_container = old_context_container; + lua_settop(config->lua.state, top); + return res; } diff --git a/sway/ipc-server.c b/sway/ipc-server.c index 9ed91573c..ed33c226f 100644 --- a/sway/ipc-server.c +++ b/sway/ipc-server.c @@ -1,5 +1,9 @@ // See https://i3wm.org/docs/ipc.html for protocol information #include +#include +#include +#include +#include "sway/lua.h" #include #include #include @@ -980,6 +984,136 @@ void ipc_client_handle_command(struct ipc_client *client, uint32_t payload_lengt goto exit_cleanup; } + case IPC_LUA_EXEC: { + struct json_object *request = json_tokener_parse(buf); + if (request == NULL || !json_object_is_type(request, json_type_object)) { + const char *error = "{\"success\": false, \"error\": \"Failed to parse JSON request\"}"; + ipc_send_reply(client, payload_type, error, (uint32_t)strlen(error)); + if (request) { + json_object_put(request); + } + goto exit_cleanup; + } + + struct json_object *file_obj = NULL; + struct json_object *code_obj = NULL; + struct json_object *args_obj = NULL; + + json_object_object_get_ex(request, "file", &file_obj); + json_object_object_get_ex(request, "code", &code_obj); + json_object_object_get_ex(request, "args", &args_obj); + + if ((file_obj == NULL && code_obj == NULL) || (file_obj != NULL && code_obj != NULL)) { + const char *error = "{\"success\": false, \"error\": \"Must specify exactly one of " + "'file' or 'code'\"}"; + ipc_send_reply(client, payload_type, error, (uint32_t)strlen(error)); + json_object_put(request); + goto exit_cleanup; + } + + if (args_obj != NULL && !json_object_is_type(args_obj, json_type_array)) { + const char *error = "{\"success\": false, \"error\": \"'args' must be a JSON array\"}"; + ipc_send_reply(client, payload_type, error, (uint32_t)strlen(error)); + json_object_put(request); + goto exit_cleanup; + } + + int top = lua_gettop(config->lua.state); + + int err = LUA_OK; + const char *script_name = NULL; + + if (file_obj != NULL) { + script_name = json_object_get_string(file_obj); + err = luaL_loadfile(config->lua.state, script_name); + } else { + script_name = ""; + err = luaL_loadstring(config->lua.state, json_object_get_string(code_obj)); + } + + if (err != LUA_OK) { + const char *str = luaL_checkstring(config->lua.state, -1); + json_object *reply = json_object_new_object(); + json_object_object_add(reply, "success", json_object_new_boolean(false)); + if (str) { + json_object_object_add(reply, "error", json_object_new_string(str)); + } else { + json_object_object_add( + reply, "error", json_object_new_string("Failed to load lua code")); + } + const char *json_string = json_object_to_json_string(reply); + ipc_send_reply(client, payload_type, json_string, (uint32_t)strlen(json_string)); + json_object_put(reply); + json_object_put(request); + lua_settop(config->lua.state, top); + goto exit_cleanup; + } + + struct sway_lua_script *script = sway_lua_get_or_create_script(script_name); + if (!script) { + const char *error = "{\"success\": false, \"error\": \"Failed to allocate memory\"}"; + ipc_send_reply(client, payload_type, error, (uint32_t)strlen(error)); + json_object_put(request); + lua_settop(config->lua.state, top); + goto exit_cleanup; + } + + int nargs = args_obj ? (int)json_object_array_length(args_obj) : 0; + lua_createtable(config->lua.state, nargs, 0); + for (int i = 0; i < nargs; ++i) { + sway_lua_push_json_to_lua(config->lua.state, json_object_array_get_idx(args_obj, i)); + lua_rawseti(config->lua.state, -2, i + 1); + } + lua_pushlightuserdata(config->lua.state, script); + + err = lua_pcall(config->lua.state, 2, LUA_MULTRET, 0); + transaction_commit_dirty(); + if (err != LUA_OK) { + const char *str = luaL_checkstring(config->lua.state, -1); + json_object *reply = json_object_new_object(); + json_object_object_add(reply, "success", json_object_new_boolean(false)); + if (str) { + json_object_object_add(reply, "error", json_object_new_string(str)); + } else { + json_object_object_add( + reply, "error", json_object_new_string("Failed to execute lua code")); + } + const char *json_string = json_object_to_json_string(reply); + ipc_send_reply(client, payload_type, json_string, (uint32_t)strlen(json_string)); + json_object_put(reply); + json_object_put(request); + lua_settop(config->lua.state, top); + goto exit_cleanup; + } + + int top_after = lua_gettop(config->lua.state); + int num_results = top_after - top; + + json_object *reply = json_object_new_object(); + json_object_object_add(reply, "success", json_object_new_boolean(true)); + + if (num_results == 0) { + json_object_object_add(reply, "result", NULL); + } else if (num_results == 1) { + json_object_object_add(reply, "result", sway_lua_value_to_json(config->lua.state, -1)); + } else { + json_object *result_array = json_object_new_array(); + for (int i = 0; i < num_results; ++i) { + json_object_array_add( + result_array, sway_lua_value_to_json(config->lua.state, top + i + 1)); + } + json_object_object_add(reply, "result", result_array); + } + + const char *json_string = json_object_to_json_string(reply); + ipc_send_reply(client, payload_type, json_string, (uint32_t)strlen(json_string)); + json_object_put(reply); + json_object_put(request); + + lua_settop(config->lua.state, top); + goto exit_cleanup; + } + default: sway_log(SWAY_INFO, "Unknown IPC command type %x", payload_type); goto exit_cleanup; diff --git a/sway/lua.c b/sway/lua.c index 681c7dccb..c824046c5 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -13,6 +13,7 @@ #include "sway/desktop/animation.h" #include "sway/desktop/launcher.h" #include "sway/ipc-server.h" +#include "sway/server.h" #if 0 static void print_table(lua_State *L, int index); @@ -129,9 +130,10 @@ static int scroll_state_set_value(lua_State *L) { } static bool lua_is_array(lua_State *L, int index) { + int abs_idx = index < 0 ? (lua_gettop(L) + index + 1) : index; lua_pushnil(L); int n = 0; - while (lua_next(L, index) != 0) { + while (lua_next(L, abs_idx) != 0) { int top = lua_gettop(L); if (lua_type(L, top - 1) != LUA_TNUMBER || lua_tointeger(L, top - 1) != ++n) { lua_pop(L, 2); @@ -142,48 +144,51 @@ static bool lua_is_array(lua_State *L, int index) { return true; } -static json_object *lua_table_to_json(lua_State *L, int index); +json_object *sway_lua_table_to_json(lua_State *L, int index); -static json_object *lua_value_to_json(lua_State *L, int i) { - switch (lua_type(L, i)) { +json_object *sway_lua_value_to_json(lua_State *L, int i) { + int abs_i = i < 0 ? (lua_gettop(L) + i + 1) : i; + switch (lua_type(L, abs_i)) { case LUA_TNUMBER: - if (lua_isinteger(L, i)) { - return json_object_new_int(lua_tointeger(L, i)); + if (lua_isinteger(L, abs_i)) { + return json_object_new_int(lua_tointeger(L, abs_i)); } else { - return json_object_new_double(lua_tonumber(L, i)); + return json_object_new_double(lua_tonumber(L, abs_i)); } case LUA_TSTRING: - return json_object_new_string(lua_tostring(L, i)); + return json_object_new_string(lua_tostring(L, abs_i)); case LUA_TBOOLEAN: - return json_object_new_boolean(lua_toboolean(L, i)); + return json_object_new_boolean(lua_toboolean(L, abs_i)); case LUA_TTABLE: - return lua_table_to_json(L, i); + return sway_lua_table_to_json(L, abs_i); default: return NULL; } } -static json_object *lua_table_to_json(lua_State *L, int index) { +json_object *sway_lua_table_to_json(lua_State *L, int index) { + int abs_idx = index < 0 ? (lua_gettop(L) + index + 1) : index; json_object *result; - bool is_array = lua_is_array(L, index); + bool is_array = lua_is_array(L, abs_idx); if (is_array) { result = json_object_new_array(); } else { result = json_object_new_object(); } lua_pushnil(L); - while (lua_next(L, index) != 0) { + while (lua_next(L, abs_idx) != 0) { int top = lua_gettop(L); // uses 'key' (at top - 1) and 'value' (at top) if (is_array) { - json_object_array_add(result, lua_value_to_json(L, top)); + json_object_array_add(result, sway_lua_value_to_json(L, top)); } else { if (lua_type(L, top - 1) == LUA_TSTRING) { - json_object_object_add(result, lua_tostring(L, top - 1), lua_value_to_json(L, top)); + json_object_object_add( + result, lua_tostring(L, top - 1), sway_lua_value_to_json(L, top)); } else if (lua_type(L, top - 1) == LUA_TNUMBER) { char idx[32]; sprintf(idx, "%lld", lua_tointeger(L, top - 1)); - json_object_object_add(result, idx, lua_value_to_json(L, top)); + json_object_object_add(result, idx, sway_lua_value_to_json(L, top)); } } lua_pop(L, 1); @@ -200,7 +205,7 @@ static int scroll_ipc_send(lua_State *L) { if (!id || !lua_istable(L, 2)) { return 0; } - json_object *data = lua_table_to_json(L, 2); + json_object *data = sway_lua_table_to_json(L, 2); ipc_event_lua(id, data); return 0; } @@ -403,6 +408,12 @@ static int scroll_focused_container(lua_State *L) { return 1; } +static int scroll_context_container(lua_State *L) { + struct sway_container *container = config->lua.context_container; + lua_push_node(L, container ? &container->node : NULL); + return 1; +} + static int scroll_focused_workspace(lua_State *L) { struct sway_node *node = get_focused_node(); struct sway_workspace *workspace = NULL; @@ -1725,7 +1736,20 @@ static int scroll_remove_callback(lua_State *L) { return 0; } +static int scroll_animating(lua_State *L) { + lua_pushboolean(L, animation_animating()); + return 1; +} + +static int scroll_pending_transactions(lua_State *L) { + bool pending = server.queued_transaction != NULL || server.pending_transaction != NULL || + server.dirty_nodes->length > 0; + lua_pushboolean(L, pending); + return 1; +} + // Module functions +/* clang-format off */ static luaL_Reg const scroll_lib[] = { { "log", scroll_log }, { "state_set_value", scroll_state_set_value }, @@ -1736,6 +1760,7 @@ static luaL_Reg const scroll_lib[] = { { "node_get_type", scroll_node_get_type }, { "focused_view", scroll_focused_view }, { "focused_container", scroll_focused_container }, + { "context_container", scroll_context_container }, { "focused_workspace", scroll_focused_workspace }, { "urgent_view", scroll_urgent_view }, { "view_mapped", scroll_view_mapped }, @@ -1796,8 +1821,11 @@ static luaL_Reg const scroll_lib[] = { { "scratchpad_hide", scroll_scratchpad_hide }, { "add_callback", scroll_add_callback }, { "remove_callback", scroll_remove_callback }, + { "animating", scroll_animating }, + { "pending_transactions", scroll_pending_transactions }, { NULL, NULL } }; +/* clang-format on */ // Module Loader int luaopen_scroll(lua_State *L) { @@ -1911,3 +1939,70 @@ void lua_execute_jump_end_cbs(struct sway_container *container) { safe_pcall(config->lua.state, 2); } } + +struct sway_lua_script *sway_lua_get_or_create_script(const char *name) { + for (int i = 0; i < config->lua.scripts->length; ++i) { + struct sway_lua_script *script = config->lua.scripts->items[i]; + if (strcmp(script->name, name) == 0) { + return script; + } + } + struct sway_lua_script *script = malloc(sizeof(struct sway_lua_script)); + if (!script) { + return NULL; + } + script->name = strdup(name); + if (!script->name) { + free(script); + return NULL; + } + + int top = lua_gettop(config->lua.state); + lua_createtable(config->lua.state, 0, 0); + script->state = luaL_ref(config->lua.state, LUA_REGISTRYINDEX); + lua_settop(config->lua.state, top); + + list_add(config->lua.scripts, script); + return script; +} + +void sway_lua_push_json_to_lua(lua_State *L, struct json_object *obj) { + if (!obj) { + lua_pushnil(L); + return; + } + switch (json_object_get_type(obj)) { + case json_type_null: + lua_pushnil(L); + break; + case json_type_boolean: + lua_pushboolean(L, json_object_get_boolean(obj)); + break; + case json_type_double: + lua_pushnumber(L, json_object_get_double(obj)); + break; + case json_type_int: + lua_pushinteger(L, json_object_get_int64(obj)); + break; + case json_type_string: + lua_pushstring(L, json_object_get_string(obj)); + break; + case json_type_array: { + size_t len = json_object_array_length(obj); + lua_createtable(L, (int)len, 0); + for (size_t i = 0; i < len; ++i) { + sway_lua_push_json_to_lua(L, json_object_array_get_idx(obj, i)); + lua_rawseti(L, -2, (int)(i + 1)); + } + break; + } + case json_type_object: { + lua_createtable(L, 0, 0); + json_object_object_foreach(obj, key, val) { + sway_lua_push_json_to_lua(L, val); + lua_setfield(L, -2, key); + } + break; + } + } +} diff --git a/sway/scroll-ipc.7.scd b/sway/scroll-ipc.7.scd index fb906b1ef..bfeccf3cf 100644 --- a/sway/scroll-ipc.7.scd +++ b/sway/scroll-ipc.7.scd @@ -96,6 +96,9 @@ supported. *For all replies, any properties not listed are subject to removal.* |- 123 : GET_BINDINGS : Get a list with the bindings for the current binding mode +|- 124 +: LUA_EXEC +: Execute Lua script file or inline code ## 0. RUN_COMMAND @@ -1820,6 +1823,53 @@ The array contains objects with the following properties: } ``` +## 124. LUA_EXEC + +*MESSAGE*++ +Request the compositor to execute a Lua script file or inline Lua code. + +[- *PROPERTY* +:- *DATA TYPE* +:- *DESCRIPTION* +|- file +: string +:[ Optional. Path to the Lua script file to load. Must specify exactly one of + _file_ or _code_. +|- code +: string +:[ Optional. Inline Lua code string to execute. Must specify exactly one of + _file_ or _code_. +|- args +: array of values +: Optional. List of arguments to pass to the script. + +*REPLY*++ +An object containing the execution success flag and either the return values +or an error message. + +[- *PROPERTY* +:- *DATA TYPE* +:- *DESCRIPTION* +|- success +: boolean +:[ Whether the Lua script executed successfully without runtime errors +|- result +: value or array of values +:[ The return value(s) of the script, converted to JSON. If 0 values were + returned, this is null. If 1 value was returned, this is the value itself. + If multiple values were returned, this is a JSON array of the values (only + on success). +|- error +: string +: A human readable error message (only on failure) + +*Example Reply:* +``` +{ + "success": true, + "result": 42 +} +``` # EVENTS Events are a way for clients to get notified of changes to scroll. A client can diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index a7d9f3da6..4e0710e9a 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -1840,6 +1840,10 @@ lua $lua_scripts/workspace_exec.lua 1 exec kitty Otherwise, they are resolved against the initial working directory of the compositor. See *LUA* for details about the API and some examples. +*lua_eval* [] + Executes the inline Lua _code_ string with optional _args_. See *LUA* for + details about the API and some examples. + # CRITERIA A criteria is a string in the form of, for example: @@ -2099,6 +2103,11 @@ local process_data = scroll.exec_process("kitty") *focused_container()* Returns the focused container ID or nil if none +*context_container()* + Returns the container ID of the execution context if the command/script + was run via criteria (e.g. `[class="XTerm"] lua script.lua`), or nil if + there is no context (run globally). + *focused_workspace()* Returns the focused workspace ID or nil if none @@ -2402,6 +2411,14 @@ local process_data = scroll.exec_process("kitty") Removes a callback set earlier using *add_callback*. _id_ is the unique identifier returned by *add_callback*. +*animating()* + Returns _true_ if there is an active animation running. + +*pending_transactions()* + Returns _true_ if there are pending transactions that haven't been applied + yet. + + Examples: Calling this script from the configuration file, you will get focus on every diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index 56b4f8343..5e308de90 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -1102,12 +1102,13 @@ bool workspace_switch(struct sway_workspace *workspace) { seat_set_focus(seat, next); // old_ws may not have an output because it is being destroyed if empty - if (!layout_overview_workspaces_enabled() && old_ws != workspace && ( - (old_ws->output && old_ws->output == workspace->output) || - (old_ws->output && !workspace->output) || - (!old_ws->output && workspace->output)) && - workspace->split.sibling != old_ws && - animation_enabled() && animation_path_enabled(ANIMATION_WORKSPACE_SWITCH)) { + if (!layout_overview_workspaces_enabled() && old_ws != workspace && + ((old_ws->output && old_ws->output == workspace->output) || + (old_ws->output && !workspace->output) || + (!old_ws->output && workspace->output)) && + workspace->split.sibling != old_ws && animation_enabled() && + animation_path_enabled(ANIMATION_WORKSPACE_SWITCH) && !workspace->node.destroying && + !old_ws->node.destroying) { struct sway_output *output = old_ws->output ? old_ws->output : workspace->output; animate_workspace_switch(output, old_ws, workspace); } else { diff --git a/swaymsg/main.c b/swaymsg/main.c index cbe0634c5..3b5dc6a09 100644 --- a/swaymsg/main.c +++ b/swaymsg/main.c @@ -1,4 +1,4 @@ - +#define _XOPEN_SOURCE 700 #include #include #include @@ -600,6 +600,7 @@ int main(int argc, char **argv) { static bool monitor = false; char *socket_path = NULL; char *cmdtype = NULL; + bool is_eval = false; sway_log_init(SWAY_INFO, NULL); @@ -716,6 +717,11 @@ int main(int argc, char **argv) { type = IPC_GET_SPACES; } else if (strcasecmp(cmdtype, "get_bindings") == 0) { type = IPC_GET_BINDINGS; + } else if (strcasecmp(cmdtype, "lua") == 0) { + type = IPC_LUA_EXEC; + } else if (strcasecmp(cmdtype, "lua_eval") == 0) { + type = IPC_LUA_EXEC; + is_eval = true; } else { if (quiet) { exit(EXIT_FAILURE); @@ -734,7 +740,39 @@ int main(int argc, char **argv) { } char *command = NULL; - if (optind < argc) { + if (type == IPC_LUA_EXEC) { + if (optind >= argc) { + fprintf(stderr, is_eval ? "Missing lua code\n" : "Missing lua file path\n"); + exit(EXIT_FAILURE); + } + + struct json_object *payload = json_object_new_object(); + + if (is_eval) { + json_object_object_add(payload, "code", json_object_new_string(argv[optind])); + } else { + char *resolved_path = realpath(argv[optind], NULL); + if (resolved_path == NULL) { + fprintf(stderr, "File not found: %s\n", argv[optind]); + exit(EXIT_FAILURE); + } + json_object_object_add(payload, "file", json_object_new_string(resolved_path)); + free(resolved_path); + } + + struct json_object *args_array = json_object_new_array(); + for (int i = optind + 1; i < argc; ++i) { + struct json_object *arg_obj = json_tokener_parse(argv[i]); + if (arg_obj == NULL) { + arg_obj = json_object_new_string(argv[i]); + } + json_object_array_add(args_array, arg_obj); + } + json_object_object_add(payload, "args", args_array); + + command = strdup(json_object_to_json_string(payload)); + json_object_put(payload); + } else if (optind < argc) { command = join_args(argv + optind, argc - optind); } else { command = strdup(""); diff --git a/swaymsg/scrollmsg.1.scd b/swaymsg/scrollmsg.1.scd index 659244231..426f3e4f8 100644 --- a/swaymsg/scrollmsg.1.scd +++ b/swaymsg/scrollmsg.1.scd @@ -108,7 +108,17 @@ _scrollmsg_ [options...] [message] *send\_tick* Sends a tick event to all subscribed clients. - +*lua* + Executes a Lua script file. The first argument specifies the path to the Lua + file. Subsequent arguments are parsed as JSON values and passed to the script + as arguments. The return value(s) of the script are converted back to JSON and + returned. + +*lua\_eval* + Executes inline Lua code. The first argument specifies the inline Lua code + string. Subsequent arguments are parsed as JSON values and passed to the script + as arguments. The return value(s) of the script are converted back to JSON and + returned. *subscribe* Subscribe to a list of event types. The argument for this type should be provided in the form of a valid JSON array. If any of the types are invalid diff --git a/tests/conftest.py b/tests/conftest.py index 4531b51cd..9705557cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,69 +3,171 @@ import pytest from typing import Generator from pathlib import Path -from test_utils import ScrollInstance, run_compositor +from test_utils import ScrollInstance, run_compositor, ScrollCompositorFactory def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--scroll", help="the scroll binary to test", default=None) +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo +) -> Generator[None, None, None]: + outcome = yield + rep = outcome.get_result() + setattr(item, "result_" + rep.when, rep) + + +def _build_scroll() -> str: + # Auto-build using Meson/Ninja + print("\nBuilding scroll with Meson/Ninja...") + build_dir = os.path.abspath("./build") + if not os.path.exists(build_dir): + res = subprocess.run( + [ + "meson", + "setup", + "build", + "-Dwerror=false", + "-Db_sanitize=address", + "-Dbuildtype=debugoptimized", + ], + capture_output=True, + text=True, + ) + if res.returncode != 0: + pytest.exit( + f"Failed to setup build:\nStdout: {res.stdout}\nStderr: {res.stderr}" + ) + else: + # Ensure ASan is enabled + res = subprocess.run( + [ + "meson", + "configure", + "build", + "-Db_sanitize=address", + "-Dbuildtype=debugoptimized", + ], + capture_output=True, + text=True, + ) + if res.returncode != 0: + pytest.exit( + "Failed to configure build with ASan:\nStdout:" + f" {res.stdout}\nStderr: {res.stderr}" + ) + + # Run ninja to compile (incremental build) + res = subprocess.run(["ninja", "-C", "build"], capture_output=True, text=True) + if res.returncode != 0: + pytest.exit( + f"Failed to build scroll:\nStdout: {res.stdout}\nStderr: {res.stderr}" + ) + + return os.path.join(build_dir, "sway", "scroll") + + @pytest.fixture(scope="session") def scroll_compositor_binary(request: pytest.FixtureRequest) -> str: binary_path: str = request.config.getoption("scroll") if not binary_path: - # Auto-build using Meson/Ninja - print("\nBuilding scroll with Meson/Ninja...") - build_dir = os.path.abspath("./build") - if not os.path.exists(build_dir): - res = subprocess.run( - ["meson", "setup", "build", "-Dwerror=false", "-Db_sanitize=address"], - capture_output=True, - text=True, - ) - if res.returncode != 0: - pytest.exit( - f"Failed to setup build:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + # Check if we are running under xdist + try: + worker_id = request.getfixturevalue("worker_id") + except Exception: + worker_id = "master" + + if worker_id == "master": + binary_path = _build_scroll() else: - # Ensure ASan is enabled - res = subprocess.run( - ["meson", "configure", "build", "-Db_sanitize=address"], - capture_output=True, - text=True, - ) - if res.returncode != 0: - pytest.exit( - f"Failed to configure build with ASan:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + tmp_path_factory = request.getfixturevalue("tmp_path_factory") + shared_dir = tmp_path_factory.getbasetemp().parent + lock_path = shared_dir / "scroll_build.lock" + status_path = shared_dir / "scroll_build.status" - # Run ninja to compile (incremental build) - res = subprocess.run(["ninja", "-C", "build"], capture_output=True, text=True) - if res.returncode != 0: - pytest.exit( - f"Failed to build scroll:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + import fcntl - binary_path = os.path.join(build_dir, "sway", "scroll") + # Open with 'a' to avoid truncating while another process might have it locked + with open(lock_path, "a") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + if status_path.exists(): + binary_path = status_path.read_text().strip() + else: + binary_path = _build_scroll() + status_path.write_text(binary_path) + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) else: binary_path = os.path.abspath(binary_path) assert os.path.exists(binary_path), f"Binary not found at {binary_path}" + + # Set up PATH to include build directories so that the compositor can find + # our newly built scrollbar, swaymsg, swaynag, etc. + build_dir = Path(binary_path).parent.parent + old_path = os.environ.get("PATH", "") + build_paths = [ + str(build_dir / "sway"), + str(build_dir / "swaymsg"), + str(build_dir / "swaybar"), + str(build_dir / "swaynag"), + ] + os.environ["PATH"] = ":".join(build_paths) + ":" + old_path + return binary_path @pytest.fixture(scope="session") -def scroll_compositor( +def scroll_compositor_factory( scroll_compositor_binary: str, tmp_path_factory: pytest.TempPathFactory -) -> Generator[ScrollInstance, None, None]: +) -> Generator[ScrollCompositorFactory, None, None]: temp_dir: Path = tmp_path_factory.mktemp("scroll") with run_compositor(scroll_compositor_binary, temp_dir) as inst: + yield ScrollCompositorFactory(inst) + + +@pytest.fixture(scope="function") +def scroll_compositor( + request: pytest.FixtureRequest, + scroll_compositor_factory: ScrollCompositorFactory, +) -> Generator[ScrollInstance, None, None]: + ctx = scroll_compositor_factory() + inst = ctx.__enter__() + try: yield inst + finally: + failed = ( + hasattr(request.node, "result_call") and request.node.result_call.failed + ) + if failed: + scroll_compositor_factory.print_log() + ctx.__exit__(None, None, None) @pytest.fixture(scope="function") -def fresh_compositor( +def fresh_compositor_factory( scroll_compositor_binary: str, tmp_path: Path -) -> Generator[ScrollInstance, None, None]: +) -> Generator[ScrollCompositorFactory, None, None]: with run_compositor(scroll_compositor_binary, tmp_path) as inst: + yield ScrollCompositorFactory(inst) + + +@pytest.fixture(scope="function") +def fresh_compositor( + request: pytest.FixtureRequest, + fresh_compositor_factory: ScrollCompositorFactory, +) -> Generator[ScrollInstance, None, None]: + ctx = fresh_compositor_factory() + inst = ctx.__enter__() + try: yield inst + finally: + failed = ( + hasattr(request.node, "result_call") and request.node.result_call.failed + ) + if failed: + fresh_compositor_factory.print_log() + ctx.__exit__(None, None, None) diff --git a/tests/interactive.sh b/tests/interactive.sh index 9ec5c586b..ac283af25 100755 --- a/tests/interactive.sh +++ b/tests/interactive.sh @@ -48,8 +48,8 @@ if [ "$has_config" = "false" ]; then set -- -c "$CONFIG" "$@" fi -# Enable leak detection but use suppressions for known system library leaks. -export ASAN_OPTIONS="detect_leaks=1${ASAN_OPTIONS:+:$ASAN_OPTIONS}" +# Disable leak detection to suppress leak errors in the interactive test. +export ASAN_OPTIONS="detect_leaks=0${ASAN_OPTIONS:+:$ASAN_OPTIONS}" export LSAN_OPTIONS="suppressions=$PROJECT_ROOT/tests/lsan.supp${LSAN_OPTIONS:+:$LSAN_OPTIONS}" echo "Starting scroll..." diff --git a/tests/meson.build b/tests/meson.build index 2b153d5df..0cf0d8b40 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -15,3 +15,6 @@ if xcb_dep.found() install: false, ) endif + + + diff --git a/tests/scrollipc.py b/tests/scrollipc.py index 84204c716..912b5244b 100644 --- a/tests/scrollipc.py +++ b/tests/scrollipc.py @@ -11,6 +11,7 @@ IPC_GET_WORKSPACES: int = 1 IPC_SUBSCRIBE: int = 2 IPC_GET_VERSION: int = 7 +IPC_LUA_EXEC: int = 124 class ScrollIPC: @@ -68,3 +69,12 @@ def get_tree(self) -> dict: if reply_type != 4: raise ValueError(f"Unexpected reply type: {reply_type}") return json.loads(reply_payload) + + def lua_exec(self, payload: dict) -> dict: + self._send(IPC_LUA_EXEC, json.dumps(payload)) + reply_type, reply_payload = self._recv() + if reply_type != IPC_LUA_EXEC: + raise ValueError(f"Unexpected reply type: {reply_type}") + result = json.loads(reply_payload) + assert isinstance(result, dict) + return result diff --git a/tests/test_animation_offset.py b/tests/test_animation_offset.py deleted file mode 100644 index 7cd551080..000000000 --- a/tests/test_animation_offset.py +++ /dev/null @@ -1,129 +0,0 @@ -import time -from typing import Generator -from pathlib import Path -import pytest -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map, run_compositor - - -@pytest.fixture(scope="function") -def offset_animating_compositor( - scroll_compositor_binary: str, tmp_path: Path -) -> Generator[ScrollInstance, None, None]: - config: str = ( - "workspace 1\n" - "xwayland force\n" - "animations enabled yes\n" - # 5 second animation, 10% offset scale - "animations window_move yes 5000 var 3 [ 0.215 0.61 0.355 1 ] off 0.1 6 [0 0.6 0.4 0 1 0 0.4 -0.6 1 -0.6]\n" - ) - with run_compositor(scroll_compositor_binary, tmp_path, config) as inst: - yield inst - - -def test_animation_offset_unintended_move( - offset_animating_compositor: ScrollInstance, -) -> None: - inst = offset_animating_compositor - - try: - with wayland_client(inst, "client1"): - wait_for_client_map(inst, "client1") - time.sleep(0.5) - with wayland_client(inst, "client2"): - wait_for_client_map(inst, "client2") - time.sleep(0.5) - with wayland_client(inst, "client3"): - wait_for_client_map(inst, "client3") - time.sleep(0.5) - - tree = inst.get_tree() - - def find_views(node, result): - if node.get("type") == "con" and node.get("name"): - if ( - node.get("window") - or node.get("app_id") - or ( - node.get("window_properties") - and node["window_properties"].get("title") - ) - ): - result.append( - { - "title": node["name"], - "id": node["id"], - "x": node["rect"]["x"], - } - ) - for child in node.get("nodes", []): - find_views(child, result) - for child in node.get("floating_nodes", []): - find_views(child, result) - - views = [] - find_views(tree, views) - print("Found views:", views) - assert len(views) == 3 - - # Sort by X to identify leftmost, middle, rightmost - sorted_views = sorted(views, key=lambda v: v["x"]) - v1, v2, v3 = sorted_views - print( - f"Leftmost: {v1['title']}, Middle: {v2['title']}, Rightmost: {v3['title']}" - ) - - # Focus rightmost (client3) to avoid scroll during swap - print("Focusing rightmost...") - res = inst.cmd(f"[con_id={v3['id']}] focus") - assert res[0]["success"] - time.sleep(1.0) # wait for focus scroll to settle - - # Swap leftmost and middle by moving leftmost right, using its ID as context - print("Swapping leftmost and middle in background...") - # We use execute_lua to run scroll.command with v1['id'] context - res_lua = inst.execute_lua( - f"return scroll.command({v1['id']}, 'move right')" - ) - print(f"Lua command result: {res_lua}") - - # Get parent container of client3 to query its actual position - parent_id = inst.execute_lua( - f"return scroll.container_get_parent({v3['id']})" - ) - query_id = parent_id if parent_id is not None else v3["id"] - print(f"Querying container {query_id} (parent of {v3['id']})") - - # Query position of client3 during animation - positions = [] - start_time = time.time() - while time.time() - start_time < 2.0: - geom = inst.execute_lua( - f"return scroll.container_get_animated_geometry({query_id})" - ) - positions.append(geom) - time.sleep(0.05) - - print(f"Positions of client3: {positions}") - - # Verify positions are constant - x_values = [p["x"] for p in positions] - y_values = [p["y"] for p in positions] - - first_x = x_values[0] - first_y = y_values[0] - for x in x_values: - assert x == pytest.approx(first_x, abs=1.0), ( - f"client3 moved horizontally: {x_values}" - ) - for y in y_values: - assert y == pytest.approx(first_y, abs=1.0), ( - f"client3 moved vertically: {y_values}" - ) - - print("Test passed: static window did not move.") - - except Exception as e: - print("Scroll Log on failure:") - print(inst.read_log()) - raise e diff --git a/tests/test_clients.py b/tests/test_clients.py index b29a7c690..aa8d47060 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,9 +1,9 @@ import os -import subprocess -import time from pathlib import Path -import pytest +import subprocess from conftest import ScrollInstance +import pytest +from test_utils import wait_for_client_map def test_wayland_client(scroll_compositor: ScrollInstance) -> None: @@ -23,31 +23,12 @@ def test_wayland_client(scroll_compositor: ScrollInstance) -> None: [str(client_path), title, app_id], env=env ) - view_info: dict | None = None - tries: int = 0 - while tries < 50: - view_info = scroll_compositor.execute_lua(""" - local view = scroll.focused_view() - if view then - return { - id = view, - title = scroll.view_get_title(view), - app_id = scroll.view_get_app_id(view) - } - end - """) - if ( - view_info - and view_info.get("title") == title - and view_info.get("app_id") == app_id - ): - break - - time.sleep(0.1) - tries += 1 - - assert tries < 50, "Timed out waiting for client to map or verify" - assert view_info is not None + view_id = wait_for_client_map(scroll_compositor, title) + app_id_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_app_id({view_id})" + ) + assert app_id_actual == app_id + view_info = {"id": view_id} scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") @@ -68,13 +49,7 @@ def test_x11_client(scroll_compositor: ScrollInstance) -> None: xauthority: str | None = scroll_compositor.getenv("XAUTHORITY") # Wait for Xwayland to be ready - xwayland_ready_tries: int = 0 - while xwayland_ready_tries < 50: - if "Xserver is ready" in scroll_compositor.read_log(): - break - time.sleep(0.1) - xwayland_ready_tries += 1 - assert xwayland_ready_tries < 50, "Timed out waiting for Xwayland to be ready" + scroll_compositor.wait_for_log_pattern("Xserver is ready", from_start=True) client_path: Path = Path("./build/tests/x11-test-client").resolve() if not client_path.exists(): @@ -93,36 +68,16 @@ def test_x11_client(scroll_compositor: ScrollInstance) -> None: [str(client_path), title, instance, class_name], env=env ) - view_info: dict | None = None - tries: int = 0 - while tries < 50: - view_info = scroll_compositor.execute_lua(""" - local view = scroll.focused_view() - if view then - return { - id = view, - title = scroll.view_get_title(view), - class = scroll.view_get_class(view), - shell = scroll.view_get_shell(view) - } - end - """) - if ( - view_info - and view_info.get("title") == title - and view_info.get("class") == class_name - ): - assert view_info.get("shell") == "xwayland" - break - - time.sleep(0.1) - tries += 1 - - if tries >= 50: - Path("build/test_x11_compositor.log").write_text(scroll_compositor.read_log()) - print("Wrote compositor log to build/test_x11_compositor.log") - assert tries < 50, "Timed out waiting for X11 client to map or verify" - assert view_info is not None + view_id = wait_for_client_map(scroll_compositor, title) + class_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_class({view_id})" + ) + shell_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_shell({view_id})" + ) + assert class_actual == class_name + assert shell_actual == "xwayland" + view_info = {"id": view_id} scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") diff --git a/tests/test_crash_move.py b/tests/test_crash_move.py index 5250a77a5..67840b645 100644 --- a/tests/test_crash_move.py +++ b/tests/test_crash_move.py @@ -1,5 +1,4 @@ -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map +from test_utils import wayland_client, wait_for_client_map, ScrollInstance def test_move_left_nomode_no_crash(scroll_compositor: ScrollInstance) -> None: @@ -12,12 +11,8 @@ def test_move_left_nomode_no_crash(scroll_compositor: ScrollInstance) -> None: wait_for_client_map(scroll_compositor, "Window 2") # Both windows are open. Now move left nomode. - try: - res = scroll_compositor.cmd("move left nomode") - assert res and res[0]["success"], f"Command failed: {res}" - except Exception as e: - print(f"Compositor log:\n{scroll_compositor.read_log()}") - raise e + res = scroll_compositor.cmd("move left nomode") + assert res and res[0]["success"], f"Command failed: {res}" # Check if compositor process is still alive assert scroll_compositor.proc.poll() is None, "Compositor crashed" diff --git a/tests/test_focused_inactive_null_crash.py b/tests/test_focused_inactive_null_crash.py index c38f5bdb8..c13c1eef2 100644 --- a/tests/test_focused_inactive_null_crash.py +++ b/tests/test_focused_inactive_null_crash.py @@ -1,7 +1,5 @@ -import time import json -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map +from test_utils import wayland_client, wait_for_client_map, ScrollInstance def test_focused_inactive_null_crash(fresh_compositor: ScrollInstance) -> None: @@ -35,7 +33,7 @@ def test_focused_inactive_null_crash(fresh_compositor: ScrollInstance) -> None: # 5. Move w1 to workspace 2 (this detaches it, making col's focused_inactive_child NULL) fresh_compositor.cmd(f"[con_id={w1_id}] move container to workspace 2") - time.sleep(0.1) + fresh_compositor.wait_for_idle() # 6. Focus the parent column print(f"col_id: {col_id}") @@ -69,6 +67,3 @@ def test_focused_inactive_null_crash(fresh_compositor: ScrollInstance) -> None: ret = fresh_compositor.proc.wait(timeout=5) print(f"Compositor exit code: {ret}") assert ret == 0, f"Compositor crashed or exited with error code {ret}" - - print("Compositor Log:") - print(fresh_compositor.read_log()) diff --git a/tests/test_focused_inactive_uaf.py b/tests/test_focused_inactive_uaf.py index 1a19e5fc6..8ca5ebb42 100644 --- a/tests/test_focused_inactive_uaf.py +++ b/tests/test_focused_inactive_uaf.py @@ -1,36 +1,35 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_focused_inactive_uaf(fresh_compositor: ScrollInstance) -> None: +def test_focused_inactive_uaf(scroll_compositor: ScrollInstance) -> None: # 1. Set mode to vertical to stack windows - fresh_compositor.cmd("set_mode v") + scroll_compositor.cmd("set_mode v") # 2. Create first view - with wayland_client(fresh_compositor, "client1") as client1: - wait_for_client_map(fresh_compositor, "client1") - w1_id = fresh_compositor.execute_lua("return scroll.focused_container()") + with wayland_client(scroll_compositor, "client1") as client1: + wait_for_client_map(scroll_compositor, "client1") + w1_id = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"w1_id: {w1_id}") # 3. Create second view - with wayland_client(fresh_compositor, "client2"): - wait_for_client_map(fresh_compositor, "client2") - w2_id = fresh_compositor.execute_lua("return scroll.focused_container()") + with wayland_client(scroll_compositor, "client2"): + wait_for_client_map(scroll_compositor, "client2") + w2_id = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"w2_id: {w2_id}") # Verify they are siblings (same parent) - w1_parent = fresh_compositor.execute_lua( + w1_parent = scroll_compositor.execute_lua( f"return scroll.container_get_parent({w1_id})" ) - w2_parent = fresh_compositor.execute_lua( + w2_parent = scroll_compositor.execute_lua( f"return scroll.container_get_parent({w2_id})" ) print(f"w1_parent: {w1_parent}, w2_parent: {w2_parent}") assert w1_parent == w2_parent, "w1 and w2 should be siblings" # Get Col1 ID (parent ID) - col1_id = fresh_compositor.execute_lua(""" + col1_id = scroll_compositor.execute_lua(""" local outputs = scroll.root_get_outputs() local workspaces = scroll.output_get_workspaces(outputs[1]) local ws1 @@ -47,29 +46,28 @@ def test_focused_inactive_uaf(fresh_compositor: ScrollInstance) -> None: assert col1_id == w1_parent, "Col1 ID should match parent ID" # 4. Focus w1 to make it the focused_inactive_child of the parent - fresh_compositor.cmd(f"[con_id={w1_id}] focus") - focused = fresh_compositor.execute_lua("return scroll.focused_container()") + scroll_compositor.cmd(f"[con_id={w1_id}] focus") + focused = scroll_compositor.execute_lua("return scroll.focused_container()") assert focused == w1_id, ( f"w1 ({w1_id}) should be focused, but got {focused}" ) # 5. Switch to workspace 2 - fresh_compositor.cmd("workspace 2") + scroll_compositor.cmd("workspace 2") # 6. Kill w1 (which is on workspace 1) - fresh_compositor.cmd(f"[con_id={w1_id}] kill") + scroll_compositor.cmd(f"[con_id={w1_id}] kill") - # Wait for client1 to exit (destruction complete) client1.wait(timeout=5) - time.sleep(0.1) + scroll_compositor.wait_for_idle() # 7. Switch back to workspace 1. - fresh_compositor.cmd("workspace 1") + scroll_compositor.cmd("workspace 1") # 8. Run move command targeted at Col1 (which has dangling focused_inactive_child) # This should trigger UAF in container_get_active_view! - fresh_compositor.cmd(f"[con_id={col1_id}] move left nomode") + scroll_compositor.cmd(f"[con_id={col1_id}] move left nomode") # If we survive, let's verify w2 is still there - focused = fresh_compositor.execute_lua("return scroll.focused_container()") + focused = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"Focused after move: {focused}") diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 0ce9e57ee..e01273c15 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,24 +1,5 @@ -import time -from typing import Generator -from pathlib import Path -import pytest from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map, run_compositor - - -@pytest.fixture(scope="function") -def animating_compositor( - scroll_compositor_binary: str, tmp_path: Path -) -> Generator[ScrollInstance, None, None]: - config: str = ( - "workspace 1\n" - "xwayland force\n" - "animations enabled yes\n" - # 2 second animation for window_move - "animations window_move yes 2000 linear\n" - ) - with run_compositor(scroll_compositor_binary, tmp_path, config) as inst: - yield inst +from test_utils import wayland_client, wait_for_client_map def test_static_geometry(scroll_compositor: ScrollInstance) -> None: @@ -29,7 +10,7 @@ def test_static_geometry(scroll_compositor: ScrollInstance) -> None: assert con_id is not None # Wait for any map animation to settle - time.sleep(2.0) + inst.wait_for_idle() geom = inst.execute_lua(f"return scroll.container_get_geometry({con_id})") actual_geom = inst.execute_lua( @@ -89,79 +70,6 @@ def find_node(node, target_id): assert geom["height"] == expected_height -def test_animating_geometry(animating_compositor: ScrollInstance) -> None: - inst = animating_compositor - with wayland_client(inst, "client1"): - v1 = wait_for_client_map(inst, "client1") - c1 = inst.execute_lua(f"return scroll.view_get_container({v1})") - - with wayland_client(inst, "client2"): - wait_for_client_map(inst, "client2") - - # Wait for initial map animations to settle - time.sleep(0.5) - - geom_before = inst.execute_lua( - f"return scroll.container_get_geometry({c1})" - ) - actual_geom_before = inst.execute_lua( - f"return scroll.container_get_animated_geometry({c1})" - ) - assert geom_before == actual_geom_before - - print("Before move:", geom_before) - - # Trigger move - inst.execute_lua(f"scroll.command({c1}, 'move right')") - - # Immediately query geometry - geom_after_trigger = inst.execute_lua( - f"return scroll.container_get_geometry({c1})" - ) - actual_geom_after_trigger = inst.execute_lua( - f"return scroll.container_get_animated_geometry({c1})" - ) - - print("Immediately after trigger (target):", geom_after_trigger) - print("Immediately after trigger (actual):", actual_geom_after_trigger) - - # The target geometry (geom) should have jumped to the final position. - # The actual geometry should still be close to the initial position. - assert geom_after_trigger != geom_before - assert actual_geom_after_trigger["x"] == pytest.approx( - actual_geom_before["x"], abs=10.0 - ) - - # Monitor animation - actual_xs = [] - target_xs = [] - start_time = time.time() - while time.time() - start_time < 2.5: # Animation is 2s - g = inst.execute_lua(f"return scroll.container_get_geometry({c1})") - ag = inst.execute_lua( - f"return scroll.container_get_animated_geometry({c1})" - ) - target_xs.append(g["x"]) - actual_xs.append(ag["x"]) - time.sleep(0.1) - - print("Target Xs:", target_xs) - print("Actual Xs:", actual_xs) - - # Target Xs should all be equal to the final position - final_x = geom_after_trigger["x"] - for tx in target_xs: - assert tx == final_x - - # Actual Xs should start near before_x and end at final_x - assert actual_xs[0] < final_x # Assuming it moved right - assert actual_xs[-1] == pytest.approx(final_x, abs=1.0) - - # Verify it is monotonically increasing (if it moved right) - for i in range(1, len(actual_xs)): - assert actual_xs[i] >= actual_xs[i - 1] - 0.1 - - def test_invalid_geometry(scroll_compositor: ScrollInstance) -> None: inst = scroll_compositor # Invalid ID diff --git a/tests/test_leak_two_clients.py b/tests/test_leak_two_clients.py new file mode 100644 index 000000000..dd760904a --- /dev/null +++ b/tests/test_leak_two_clients.py @@ -0,0 +1,37 @@ +from pathlib import Path +import subprocess +import time +from test_utils import ( + run_compositor, + wait_for_client_map, + wayland_client, + ScrollCompositorFactory, +) + + +def test_leak_two_clients(scroll_compositor_binary: str, tmp_path: Path) -> None: + config_path = Path(__file__).parent.parent / "config.in" + config_content = config_path.read_text() + + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as inst: + factory = ScrollCompositorFactory(inst) + with factory() as fresh_compositor: + # Start two clients and keep them running + with wayland_client(fresh_compositor, "client1"): + wait_for_client_map(fresh_compositor, "client1") + + with wayland_client(fresh_compositor, "client2"): + wait_for_client_map(fresh_compositor, "client2") + + # Let them run a bit + time.sleep(0.5) + + # Terminate compositor while clients are still running + fresh_compositor.proc.terminate() + try: + ret = fresh_compositor.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + fresh_compositor.proc.kill() + ret = fresh_compositor.proc.wait() + + assert ret == 0, f"Compositor exited with code {ret}" diff --git a/tests/test_lua_api.py b/tests/test_lua_api.py index 53e9e2f37..a3147b899 100644 --- a/tests/test_lua_api.py +++ b/tests/test_lua_api.py @@ -1,4 +1,5 @@ from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: @@ -91,3 +92,54 @@ def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: assert len(invalid_output_ws) == 0 assert scroll_compositor.proc.poll() is None + + +def test_lua_context_container(scroll_compositor: ScrollInstance) -> None: + # 1. Without criteria, context_container should return nil (None in Python) + ctx_con_glob: int | None = scroll_compositor.execute_lua( + "return scroll.context_container()" + ) + assert ctx_con_glob is None + + # 2. With criteria, context_container should return the matching container ID + with wayland_client(scroll_compositor, "client1"): + view_id = wait_for_client_map(scroll_compositor, "client1") + con_id = scroll_compositor.execute_lua( + f"return scroll.view_get_container({view_id})" + ) + assert con_id is not None + + # Execute lua command with criteria matching client1 + scroll_compositor.cmd( + f'[con_id={con_id}] lua_eval "_G.ctx_con_crit = scroll.context_container()"' + ) + ctx_con_crit = scroll_compositor.execute_lua("return _G.ctx_con_crit") + assert ctx_con_crit == con_id + + # 3. Nested calls with different contexts + with wayland_client(scroll_compositor, "client2"): + wait_for_client_map(scroll_compositor, "client2") + con2_id = scroll_compositor.execute_lua("return scroll.focused_container()") + assert con2_id is not None + assert con2_id != con_id + + # Execute outer script with con_id criteria matching con_id + # Using lua_eval for both outer and nested calls! + scroll_compositor.cmd( + f'[con_id={con_id}] lua_eval "' + f"_G.nested_context = nil; " + f"_G.outer_context = scroll.context_container(); " + f"scroll.command({con2_id}, [[lua_eval '_G.nested_context = scroll.context_container()']]); " + f'_G.outer_context_post = scroll.context_container()"' + ) + + # Retrieve results + outer_context = scroll_compositor.execute_lua("return _G.outer_context") + nested_context = scroll_compositor.execute_lua("return _G.nested_context") + outer_context_post = scroll_compositor.execute_lua( + "return _G.outer_context_post" + ) + + assert outer_context == con_id + assert nested_context == con2_id + assert outer_context_post == con_id diff --git a/tests/test_lua_eval.py b/tests/test_lua_eval.py new file mode 100644 index 000000000..f229c500f --- /dev/null +++ b/tests/test_lua_eval.py @@ -0,0 +1,114 @@ +import json +import os +import subprocess +import tempfile +from conftest import ScrollInstance + + +def test_lua_eval_command(scroll_compositor: ScrollInstance) -> None: + # Test successful inline execution + res: list = scroll_compositor.cmd("lua_eval \"scroll.command(nil, 'nop')\"") + assert res[0]["success"] is True + + # Test execution failure with error propagation + res = scroll_compositor.cmd("lua_eval \"error('my_eval_test_error')\"") + assert res[0]["success"] is False + assert "my_eval_test_error" in res[0]["error"] + + +def test_lua_ipc_eval(scroll_compositor: ScrollInstance) -> None: + socket_path: str = scroll_compositor.ipc.socket_path + + # Test basic evaluation + res: subprocess.CompletedProcess = subprocess.run( + ["scrollmsg", "-s", socket_path, "-t", "lua_eval", "return 1 + 2"], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + data: dict = json.loads(res.stdout) + assert data["success"] is True + assert data["result"] == 3 + + # Test passing number arguments + res = subprocess.run( + [ + "scrollmsg", + "-s", + socket_path, + "-t", + "lua_eval", + "local args = ...; return args[1] + args[2]", + "10", + "20", + ], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + data = json.loads(res.stdout) + assert data["success"] is True + assert data["result"] == 30 + + # Test passing JSON object argument + res = subprocess.run( + [ + "scrollmsg", + "-s", + socket_path, + "-t", + "lua_eval", + "local args = ...; return args[1].x", + '{"x": 42}', + ], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + data = json.loads(res.stdout) + assert data["success"] is True + assert data["result"] == 42 + + # Test multiple return values + res = subprocess.run( + ["scrollmsg", "-s", socket_path, "-t", "lua_eval", "return 1, 2, 'hello'"], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + data = json.loads(res.stdout) + assert data["success"] is True + assert data["result"] == [1, 2, "hello"] + + # Test error handling + res = subprocess.run( + ["scrollmsg", "-s", socket_path, "-t", "lua_eval", "error('ipc_eval_error')"], + capture_output=True, + text=True, + ) + assert res.returncode == 2 + data = json.loads(res.stdout) + assert data["success"] is False + assert "ipc_eval_error" in data["error"] + + +def test_lua_ipc_file(scroll_compositor: ScrollInstance) -> None: + socket_path: str = scroll_compositor.ipc.socket_path + + # Create a temporary lua file to execute + with tempfile.NamedTemporaryFile(suffix=".lua", delete=False) as f: + f.write(b"local args = ...; return (args[1] or 0) * 2") + temp_file_path: str = f.name + + try: + res: subprocess.CompletedProcess = subprocess.run( + ["scrollmsg", "-s", socket_path, "-t", "lua", temp_file_path, "21"], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + data: dict = json.loads(res.stdout) + assert data["success"] is True + assert data["result"] == 42 + finally: + os.unlink(temp_file_path) diff --git a/tests/test_lua_path.py b/tests/test_lua_path.py index 2fb6461b8..b0d0c69f0 100644 --- a/tests/test_lua_path.py +++ b/tests/test_lua_path.py @@ -1,7 +1,4 @@ -import time -from pathlib import Path -from conftest import ScrollInstance -from test_utils import run_compositor +from test_utils import ScrollInstance, ScrollCompositorFactory def test_lua_tilde_expansion(scroll_compositor: ScrollInstance) -> None: @@ -28,58 +25,58 @@ def test_lua_tilde_expansion(scroll_compositor: ScrollInstance) -> None: def test_lua_relative_path_config_load( - scroll_compositor: ScrollInstance, tmp_path: Path + scroll_compositor_factory: ScrollCompositorFactory, ) -> None: - binary_path = scroll_compositor.proc.args[0] - script_path = tmp_path / "test_relative.lua" - script_path.write_text('scroll.log("RELATIVE_LOAD_SUCCESS")') + with scroll_compositor_factory() as scroll_compositor: + home_dir = scroll_compositor.temp_dir + script_path = home_dir / "test_relative.lua" + script_path.write_text('scroll.log("RELATIVE_LOAD_SUCCESS")') - config = "workspace 1\nxwayland force\nlua test_relative.lua\n" - with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_LOAD_SUCCESS" in inst.read_log() + config = "workspace 1\nxwayland force\nlua test_relative.lua\n" + with scroll_compositor.assert_logs_match("RELATIVE_LOAD_SUCCESS"): + scroll_compositor.reload_config(config) def test_lua_relative_path_subdir_config_load( - scroll_compositor: ScrollInstance, tmp_path: Path + scroll_compositor_factory: ScrollCompositorFactory, ) -> None: - binary_path = scroll_compositor.proc.args[0] - subdir = tmp_path / "scripts" - subdir.mkdir() - script_path = subdir / "test_relative2.lua" - script_path.write_text('scroll.log("RELATIVE_SUBDIR_LOAD_SUCCESS")') + with scroll_compositor_factory() as scroll_compositor: + home_dir = scroll_compositor.temp_dir + subdir = home_dir / "scripts" + subdir.mkdir(exist_ok=True) + script_path = subdir / "test_relative2.lua" + script_path.write_text('scroll.log("RELATIVE_SUBDIR_LOAD_SUCCESS")') - config = "workspace 1\nxwayland force\nlua scripts/test_relative2.lua\n" - with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_SUBDIR_LOAD_SUCCESS" in inst.read_log() + config = "workspace 1\nxwayland force\nlua scripts/test_relative2.lua\n" + with scroll_compositor.assert_logs_match("RELATIVE_SUBDIR_LOAD_SUCCESS"): + scroll_compositor.reload_config(config) def test_lua_relative_glob_config_load( - scroll_compositor: ScrollInstance, tmp_path: Path + scroll_compositor_factory: ScrollCompositorFactory, ) -> None: - binary_path = scroll_compositor.proc.args[0] - subdir = tmp_path / "scripts" - subdir.mkdir() - script_path = subdir / "test_glob1.lua" - script_path.write_text('scroll.log("RELATIVE_GLOB_LOAD_SUCCESS")') + with scroll_compositor_factory() as scroll_compositor: + home_dir = scroll_compositor.temp_dir + subdir = home_dir / "scripts" + subdir.mkdir(exist_ok=True) + script_path = subdir / "test_glob1.lua" + script_path.write_text('scroll.log("RELATIVE_GLOB_LOAD_SUCCESS")') - config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" - with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_GLOB_LOAD_SUCCESS" in inst.read_log() + config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" + with scroll_compositor.assert_logs_match("RELATIVE_GLOB_LOAD_SUCCESS"): + scroll_compositor.reload_config(config) def test_lua_relative_glob_multiple_config_load( - scroll_compositor: ScrollInstance, tmp_path: Path + scroll_compositor_factory: ScrollCompositorFactory, ) -> None: - binary_path = scroll_compositor.proc.args[0] - subdir = tmp_path / "scripts" - subdir.mkdir() - (subdir / "test_glob1.lua").write_text('scroll.log("G1")') - (subdir / "test_glob2.lua").write_text('scroll.log("G2")') - - config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" - with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "Path expanded to multiple files" in inst.read_log() + with scroll_compositor_factory() as scroll_compositor: + home_dir = scroll_compositor.temp_dir + subdir = home_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / "test_glob1.lua").write_text('scroll.log("G1")') + (subdir / "test_glob2.lua").write_text('scroll.log("G2")') + + config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" + with scroll_compositor.assert_logs_match("Path expanded to multiple files"): + scroll_compositor.reload_config(config) diff --git a/tests/test_move_cleanup_crash.py b/tests/test_move_cleanup_crash.py index e7d9949ed..514cc1ae0 100644 --- a/tests/test_move_cleanup_crash.py +++ b/tests/test_move_cleanup_crash.py @@ -1,42 +1,26 @@ -import time -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map +from test_utils import wayland_client, wait_for_client_map, ScrollInstance -def test_move_cleanup_uaf_crash(fresh_compositor: ScrollInstance) -> None: +def test_move_cleanup_uaf_crash(scroll_compositor: ScrollInstance) -> None: # 1. Open Window 1 on Workspace 1 - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") + with wayland_client(scroll_compositor, "Window 1"): + wait_for_client_map(scroll_compositor, "Window 1") # Make Window 1 floating - res = fresh_compositor.cmd("floating enable") + res = scroll_compositor.cmd("floating enable") assert res and res[0]["success"], f"floating enable failed: {res}" # 2. Switch to Workspace 3 (so Workspace 1 becomes inactive) - res = fresh_compositor.cmd("workspace 3") + res = scroll_compositor.cmd("workspace 3") assert res and res[0]["success"], f"workspace 3 failed: {res}" - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 3. Use criteria to move Window 1 from Workspace 1 to Workspace 2. # This should make Workspace 1 empty and inactive, so it gets destroyed. # Then the move command cleanup code should UAF on Workspace 1. - try: - res = fresh_compositor.cmd( - '[title="Window 1"] move container to workspace 2' - ) - assert res and res[0]["success"], f"move failed: {res}" - except Exception as e: - print(f"Compositor log:\n{fresh_compositor.read_log()}") - raise e + res = scroll_compositor.cmd('[title="Window 1"] move container to workspace 2') + assert res and res[0]["success"], f"move failed: {res}" # Check if compositor process is still alive - log_content = fresh_compositor.read_log() - print(f"Compositor log:\n{log_content}") - if ( - fresh_compositor.proc.poll() is not None - or "node_table not initialized" in log_content - ): - pass - - assert fresh_compositor.proc.poll() is None, "Compositor crashed" + assert scroll_compositor.proc.poll() is None, "Compositor crashed" diff --git a/tests/test_normal_exit.py b/tests/test_normal_exit.py index 0a51e8e7b..302fd078d 100644 --- a/tests/test_normal_exit.py +++ b/tests/test_normal_exit.py @@ -1,5 +1,7 @@ +from pathlib import Path +import subprocess import time -from conftest import ScrollInstance +from test_utils import run_compositor, ScrollCompositorFactory, ScrollInstance def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: @@ -10,25 +12,46 @@ def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: except EOFError: # Expected if compositor exits before flushing reply pass - except Exception as e: - print(f"Compositor log:\n{fresh_compositor.read_log()}") - raise e # Wait for compositor to exit - tries = 0 - poll = None - while tries < 50: - poll = fresh_compositor.proc.poll() - if poll is not None: - break - time.sleep(0.1) - tries += 1 + try: + poll = fresh_compositor.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + poll = None assert poll is not None, "Compositor did not exit" log_content = fresh_compositor.read_log() - if poll != 0 or "node_table not initialized" in log_content: - print(f"Compositor log:\n{log_content}") - assert poll == 0, f"Compositor exited with non-zero code: {poll}" assert "node_table not initialized" not in log_content + + +def test_normal_exit_with_bar(scroll_compositor_binary: str, tmp_path: Path) -> None: + config_content = """ +workspace 1 +xwayland force +animations enabled no +bar { + scrollbar_command scrollbar +} +""" + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as inst: + factory = ScrollCompositorFactory(inst) + with factory() as fresh_compositor: + # Let it run a bit to ensure swaybar starts + time.sleep(0.5) + + # Send exit command + try: + res = fresh_compositor.cmd("exit") + assert res and res[0]["success"], f"Exit command failed: {res}" + except EOFError: + pass + + ret = fresh_compositor.proc.wait(timeout=5) + assert ret == 0, f"Compositor exited with code {ret}" + + log_content = fresh_compositor.read_log() + assert "ERROR: LeakSanitizer" not in log_content, ( + "Leak detected in compositor or helper process" + ) diff --git a/tests/test_shutdown_events.py b/tests/test_shutdown_events.py new file mode 100644 index 000000000..f6e7f27fb --- /dev/null +++ b/tests/test_shutdown_events.py @@ -0,0 +1,118 @@ +import json +from pathlib import Path +import socket +import struct +from test_utils import ( + run_compositor, + wait_for_client_map, + wayland_client, + ScrollCompositorFactory, +) + + +def test_shutdown_events_verification( + scroll_compositor_binary: str, tmp_path: Path +) -> None: + config_path: Path = Path(__file__).parent.parent / "config.in" + config_content: str = config_path.read_text() + + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as inst: + factory = ScrollCompositorFactory(inst) + with factory() as fc: + # Connect a second socket for events subscription + event_socket: socket.socket = socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM + ) + event_socket.connect(fc.ipc.socket_path) + + # Helper functions for the custom subscription socket + def send_msg(msg_type: int, payload: str) -> None: + payload_bytes: bytes = payload.encode("utf-8") + length: int = len(payload_bytes) + header: bytes = struct.pack("<6sII", b"i3-ipc", length, msg_type) + event_socket.sendall(header + payload_bytes) + + def recv_msg() -> tuple[int, str]: + header_data: bytes = b"" + while len(header_data) < 14: + chunk: bytes = event_socket.recv(14 - len(header_data)) + if not chunk: + raise EOFError("Socket closed") + header_data += chunk + magic: bytes + length: int + msg_type: int + magic, length, msg_type = struct.unpack("<6sII", header_data) + payload_data: bytes = b"" + while len(payload_data) < length: + chunk = event_socket.recv(length - len(payload_data)) + if not chunk: + raise EOFError("Socket closed") + payload_data += chunk + return msg_type, payload_data.decode("utf-8") + + # Subscribe to workspace, window, and shutdown events + # 2 is IPC_SUBSCRIBE + send_msg(2, json.dumps(["workspace", "window", "shutdown"])) + msg_type, payload = recv_msg() + assert msg_type == 2 + assert json.loads(payload)["success"] is True + + # Start two clients to have some active views/workspaces + with wayland_client(fc, "client1"): + wait_for_client_map(fc, "client1") + with wayland_client(fc, "client2"): + wait_for_client_map(fc, "client2") + + # Drain all pending events on subscription socket before terminating + event_socket.setblocking(False) + try: + while True: + header_data: bytes = event_socket.recv(14) + if len(header_data) == 14: + magic, length, msg_type = struct.unpack( + "<6sII", header_data + ) + payload_data = b"" + event_socket.setblocking(True) + while len(payload_data) < length: + chunk = event_socket.recv(length - len(payload_data)) + if not chunk: + break + payload_data += chunk + event_socket.setblocking(False) + except BlockingIOError: + pass + + event_socket.setblocking(True) + + # Now terminate the compositor by sending the exit command + try: + fc.cmd("exit") + except (EOFError, BrokenPipeError, ConnectionResetError): + # The socket might close immediately during exit processing, which is fine + pass + + # Read all events sent during shutdown until socket EOF + shutdown_events: list[tuple[int, dict]] = [] + try: + while True: + msg_type, payload = recv_msg() + shutdown_events.append((msg_type, json.loads(payload))) + except (EOFError, BrokenPipeError, ConnectionResetError): + pass # EOF or connection error is expected when compositor exits + + print(f"Events received during shutdown: {shutdown_events}") + + shutdown_msg_type: int = (1 << 31) | 6 + # Verify that if any events are received, they are only shutdown events and nothing unexpected + unexpected_events: list[tuple[int, dict]] = [] + for mtype, p in shutdown_events: + if mtype == shutdown_msg_type: + assert p["change"] == "exit" + else: + unexpected_events.append((mtype, p)) + + assert not unexpected_events, ( + f"Received unexpected events: {unexpected_events}" + ) diff --git a/tests/test_shutdown_lua_callbacks.py b/tests/test_shutdown_lua_callbacks.py new file mode 100644 index 000000000..6f135e99f --- /dev/null +++ b/tests/test_shutdown_lua_callbacks.py @@ -0,0 +1,89 @@ +from pathlib import Path +import re +from test_utils import ( + run_compositor, + wait_for_client_map, + wayland_client, + ScrollCompositorFactory, +) + + +def test_shutdown_lua_callbacks_verification( + scroll_compositor_binary: str, tmp_path: Path +) -> None: + config_path: Path = Path(__file__).parent.parent / "config.in" + config_content: str = config_path.read_text() + + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as inst: + factory = ScrollCompositorFactory(inst) + with factory() as fc: + # Register callbacks for all events, logging them to the scroll debug log + res = fc.execute_lua(""" + scroll.add_callback("view_map", function(view, data) + scroll.log("LUA_CALLBACK: view_map " .. tostring(view)) + end, nil) + scroll.add_callback("view_unmap", function(view, data) + scroll.log("LUA_CALLBACK: view_unmap " .. tostring(view)) + end, nil) + scroll.add_callback("view_focus", function(view, data) + scroll.log("LUA_CALLBACK: view_focus " .. tostring(view)) + end, nil) + scroll.add_callback("workspace_create", function(ws, data) + scroll.log("LUA_CALLBACK: workspace_create " .. tostring(ws)) + end, nil) + scroll.add_callback("workspace_focus", function(ws, data) + scroll.log("LUA_CALLBACK: workspace_focus " .. tostring(ws)) + end, nil) + """) + print(f"execute_lua result: {res}") + + # Start two clients to populate windows and workspaces + with wayland_client(fc, "client1"): + wait_for_client_map(fc, "client1") + with wayland_client(fc, "client2"): + wait_for_client_map(fc, "client2") + + # Record the log length before exit command + log_before_exit: str = fc.read_log() + log_len_before_exit: int = len(log_before_exit) + + # Now terminate the compositor via the exit command + try: + fc.cmd("exit") + except (EOFError, BrokenPipeError, ConnectionResetError): + pass + + # Wait for compositor to exit + fc.proc.wait(timeout=5) + + # Read all new log lines generated during shutdown + full_log: str = fc.read_log() + shutdown_log: str = full_log[log_len_before_exit:] + + # Extract all callback events triggered during shutdown + callback_pattern = re.compile(r"LUA_CALLBACK: (\w+)") + triggered_callbacks: list[str] = callback_pattern.findall( + shutdown_log + ) + + print(f"Triggered callbacks during shutdown: {triggered_callbacks}") + + # During shutdown, we unmap the existing views, so 'view_unmap' callbacks are expected. + # However, we should NOT see any new focus events ('view_focus', 'workspace_focus') + # or creation events ('workspace_create') being invoked. + unexpected_callbacks: list[str] = [ + cb + for cb in triggered_callbacks + if cb + in ( + "view_map", + "view_focus", + "workspace_create", + "workspace_focus", + ) + ] + + assert not unexpected_callbacks, ( + "Unexpected Lua callbacks invoked during shutdown:" + f" {unexpected_callbacks}" + ) diff --git a/tests/test_space_aba.py b/tests/test_space_aba.py index c01e83e30..270edc76e 100644 --- a/tests/test_space_aba.py +++ b/tests/test_space_aba.py @@ -1,41 +1,43 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_space_aba(fresh_compositor: ScrollInstance) -> None: - # 1. Create client1 on WS 1 - with wayland_client(fresh_compositor, "client1"): - wait_for_client_map(fresh_compositor, "client1") - - # Save space "sp1" - fresh_compositor.cmd("space_save sp1") - - # client1 is closed now. - # Wait for it to be fully destroyed. - time.sleep(0.2) - - # 2. Create client2 on WS 1. - # Hopefully it reuses client1's view struct address. - with wayland_client(fresh_compositor, "client2"): - wait_for_client_map(fresh_compositor, "client2") - - # Switch to WS 2 - fresh_compositor.cmd("workspace 2") - - # Load space "sp1" on WS 2. - # If ABA bug occurs, it might find client2 (matching old client1 address) - # and move it to WS 2. - fresh_compositor.cmd("space_load sp1 load") - - # Check if client2 is visible on WS 2. - # If it was moved to WS 2, it should be focused because space_load focuses restored containers. - focused_title = fresh_compositor.execute_lua(""" - local view = scroll.focused_view() - return view and scroll.view_get_title(view) - """) - print(f"Focused title after space_load: {focused_title}") - - assert focused_title != "client2", ( - "ABA bug: client2 was incorrectly moved to WS 2!" - ) +def test_space_aba(scroll_compositor: ScrollInstance) -> None: + inst = scroll_compositor + try: + # 1. Create client1 on WS 1 + with wayland_client(inst, "client1"): + wait_for_client_map(inst, "client1") + + # Save space "sp1" + inst.cmd("space_save sp1") + + # client1 is closed now. + inst.wait_for_idle() + + # 2. Create client2 on WS 1. + # Hopefully it reuses client1's view struct address. + with wayland_client(inst, "client2"): + wait_for_client_map(inst, "client2") + + # Switch to WS 2 + inst.cmd("workspace 2") + + # Load space "sp1" on WS 2. + # If ABA bug occurs, it might find client2 (matching old client1 address) + # and move it to WS 2. + inst.cmd("space_load sp1 load") + + # Check if client2 is visible on WS 2. + # If it was moved to WS 2, it should be focused because space_load focuses restored containers. + focused_title = inst.execute_lua(""" + local view = scroll.focused_view() + return view and scroll.view_get_title(view) + """) + print(f"Focused title after space_load: {focused_title}") + + assert focused_title != "client2", ( + "ABA bug: client2 was incorrectly moved to WS 2!" + ) + finally: + inst.cmd("space delete sp1") diff --git a/tests/test_space_crash.py b/tests/test_space_crash.py index 6e35559e6..ca0a2f45b 100644 --- a/tests/test_space_crash.py +++ b/tests/test_space_crash.py @@ -1,83 +1,66 @@ -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map +from test_utils import wayland_client, wait_for_client_map, ScrollInstance -def test_space_restore_uaf_crash(fresh_compositor: ScrollInstance) -> None: +def test_space_restore_uaf_crash(scroll_compositor: ScrollInstance) -> None: + inst = scroll_compositor # 1. Open Window 1 on Workspace 1 - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") + with wayland_client(inst, "Window 1"): + wait_for_client_map(inst, "Window 1") # Make Window 1 floating - res = fresh_compositor.cmd("floating enable") + res = inst.cmd("floating enable") assert res and res[0]["success"], f"floating enable failed: {res}" # Save layout "space1" on Workspace 1 - res = fresh_compositor.cmd("space save space1") + res = inst.cmd("space save space1") assert res and res[0]["success"], f"space save failed: {res}" # 2. Switch to Workspace 2 - res = fresh_compositor.cmd("workspace 2") + res = inst.cmd("workspace 2") assert res and res[0]["success"], f"workspace 2 failed: {res}" # Move Window 1 to Workspace 2 - # (It should still be floating? Yes, moving floating window to workspace works) - # Actually, we can just move it. - # Wait, if we are on Workspace 2, we can't easily move it here unless we focus it. - # But we switched to Workspace 2, so focus is on Workspace 2 (empty). - # We should go back to Workspace 1, move it to Workspace 2, then go to Workspace 2. - res = fresh_compositor.cmd("workspace 1") + res = inst.cmd("workspace 1") assert res and res[0]["success"], f"workspace 1 failed: {res}" - res = fresh_compositor.cmd("move container to workspace 2") + res = inst.cmd("move container to workspace 2") assert res and res[0]["success"], f"move to ws 2 failed: {res}" - res = fresh_compositor.cmd("workspace 2") + res = inst.cmd("workspace 2") assert res and res[0]["success"], f"workspace 2 failed: {res}" # Now Window 1 is floating on Workspace 2. # We must make it tiled on Workspace 2. - res = fresh_compositor.cmd("floating disable") + res = inst.cmd("floating disable") assert res and res[0]["success"], f"floating disable failed: {res}" # Set mode to vertical to stack next window - res = fresh_compositor.cmd("set_mode v") + res = inst.cmd("set_mode v") assert res and res[0]["success"], f"set_mode v failed: {res}" # Open Window 2 on Workspace 2 - with wayland_client(fresh_compositor, "Window 2"): - wait_for_client_map(fresh_compositor, "Window 2") + with wayland_client(inst, "Window 2"): + wait_for_client_map(inst, "Window 2") # Now we have on Workspace 2: Split (V) -> [Window 1, Window 2] # Move Window 2 to Workspace 3 (so Window 1 is only child of V-split) - res = fresh_compositor.cmd("move container to workspace 3") + res = inst.cmd("move container to workspace 3") assert res and res[0]["success"], f"move to ws 3 failed: {res}" # Now Window 1 is the only child of V-split on Workspace 2. # Workspace 2 has no other windows. # 3. Go to Workspace 1 - res = fresh_compositor.cmd("workspace 1") + res = inst.cmd("workspace 1") assert res and res[0]["success"], f"workspace 1 failed: {res}" # 4. Restore layout "space1" - # This should try to restore Window 1 as floating on Workspace 1. - # It will detach Window 1 from Workspace 2, reaping V-split and destroying Workspace 2. - # Then it will call arrange_container with dangling Workspace 2 pointer. - try: - res = fresh_compositor.cmd("space restore space1") - assert res and res[0]["success"], f"space restore failed: {res}" - except Exception as e: - print(f"Compositor log:\n{fresh_compositor.read_log()}") - raise e + res = inst.cmd("space restore space1") + assert res and res[0]["success"], f"space restore failed: {res}" # Check if compositor process is still alive - log_content = fresh_compositor.read_log() - print(f"Compositor log:\n{log_content}") - if ( - fresh_compositor.proc.poll() is not None - or "node_table not initialized" in log_content - ): - pass - - assert fresh_compositor.proc.poll() is None, "Compositor crashed" + assert inst.proc.poll() is None, "Compositor crashed" + + # Clean up space + inst.cmd("space delete space1") diff --git a/tests/test_utils.py b/tests/test_utils.py index 27df53ed9..95b1cf8e2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,88 +3,11 @@ import subprocess import re import pytest -from typing import Generator, Any +from typing import Generator, Any, Dict, Optional from contextlib import contextmanager from pathlib import Path from scrollipc import ScrollIPC -RUNNER_LUA_CONTENT: str = """ -local args = ... -local output_path = args[1] -local user_code_path = args[2] - -local function escape_str(s) - return '"' .. s:gsub('\\\\', '\\\\\\\\'):gsub('"', '\\\\"'):gsub('\\n', '\\\\n'):gsub('\\r', '\\\\r'):gsub('\\t', '\\\\t') .. '"' -end - -local function serialize(val) - if val == nil then return "null" end - if type(val) == "boolean" then return val and "true" or "false" end - if type(val) == "number" then return tostring(val) end - if type(val) == "string" then return escape_str(val) end - if type(val) == "table" then - local is_list = true - local max_idx = 0 - local count = 0 - for k, v in pairs(val) do - count = count + 1 - if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then - is_list = false - break - end - if k > max_idx then max_idx = k end - end - if is_list and max_idx == count then - local parts = {} - for i = 1, max_idx do - table.insert(parts, serialize(val[i])) - end - return "[" .. table.concat(parts, ",") .. "]" - else - local parts = {} - for k, v in pairs(val) do - if type(k) == "string" then - table.insert(parts, escape_str(k) .. ":" .. serialize(v)) - end - end - return "{" .. table.concat(parts, ",") .. "}" - end - end - return "null" -end - -local chunk, err = loadfile(user_code_path) -local success, result -local results -if chunk then - results = { pcall(chunk) } - success = results[1] -else - success = false - results = { false, "Error loading code: " .. tostring(err) } -end - -local f = io.open(output_path, "w") -if success then - f:write("SUCCESS\\n") - if #results <= 1 then - f:write("null") - elseif #results == 2 then - f:write(serialize(results[2])) - else - local parts = {} - for i = 2, #results do - table.insert(parts, serialize(results[i])) - end - f:write("[" .. table.concat(parts, ",") .. "]") - end -else - f:write("ERROR\\n") - f:write(tostring(results[2])) -end -f:close() -""" - class ScrollInstance: proc: subprocess.Popen @@ -109,48 +32,81 @@ def get_tree(self) -> dict: def read_log(self) -> str: return self.log_path.read_text() - def execute_lua(self, code: str) -> Any: - import json - - runner_path = self.temp_dir / "exec_runner.lua" - if not runner_path.exists(): - runner_path.write_text(RUNNER_LUA_CONTENT) - - if not hasattr(self, "_lua_execute_counter"): - self._lua_execute_counter = 0 - counter = self._lua_execute_counter - self._lua_execute_counter += 1 + def reload_config(self, config_content: str) -> None: + config_path = self.temp_dir / "config" + config_path.write_text(config_content) + self.cmd("reload") + self.wait_for_idle() - user_code_path = self.temp_dir / f"user_code_{counter}.lua" - output_path = self.temp_dir / f"exec_{counter}.out" - - user_code_path.write_text(code) - - res = self.cmd(f"lua {runner_path} {output_path} {user_code_path}") - assert res[0]["success"], f"Failed to run lua command: {res}" + def execute_lua(self, code: str) -> Any: + payload: dict = {"code": code} + res: dict = self.ipc.lua_exec(payload) + if res["success"]: + return res.get("result") + else: + raise RuntimeError(f"Lua execution failed: {res.get('error')}") - assert output_path.exists(), ( - f"Output file not created: {output_path}. Compositor log:\\n{self.read_log()}" - ) - output_content = output_path.read_text() + def getenv(self, var: str) -> str | None: + return self.execute_lua(f'return os.getenv("{var}")') - lines = output_content.splitlines() - if not lines: - raise RuntimeError( - f"Lua runner output is empty. Compositor log:\\n{self.read_log()}" + def wait_for_idle(self, timeout: float = 5.0) -> None: + start = time.time() + while time.time() - start < timeout: + pending = self.execute_lua( + "return scroll.pending_transactions() or scroll.animating()" ) - status = lines[0] - result_str = "\\n".join(lines[1:]) + if not pending: + return + time.sleep(0.005) + raise TimeoutError("Timeout waiting for compositor to become idle") - if status == "SUCCESS": - if not result_str: - return None - return json.loads(result_str) - else: - raise RuntimeError(f"Lua execution failed: {result_str}") + def wait_for_transactions(self, timeout: float = 5.0) -> None: + start = time.time() + while time.time() - start < timeout: + if not self.execute_lua("return scroll.pending_transactions()"): + return + time.sleep(0.005) + raise TimeoutError("Timeout waiting for transactions") - def getenv(self, var: str) -> str | None: - return self.execute_lua(f'return os.getenv("{var}")') + def reset(self) -> None: + # 1. Kill all views to clean up leftover windows + try: + self.cmd("kill all") + except Exception: + pass + + # 2. Clean up extra outputs + try: + tree = self.get_tree() + outputs: list[str] = [] + for child in tree.get("nodes", []): + if child.get("type") == "output" and child.get("name") != "__i3": + outputs.append(child["name"]) + + if "HEADLESS-1" in outputs: + for out in outputs: + if out != "HEADLESS-1" and out.startswith("HEADLESS-"): + self.cmd(f"output {out} unplug") + self.wait_for_idle() + except Exception: + pass + + # 3. Reload config to reset defaults + try: + config_path = self.temp_dir / "config" + config_path.write_text("workspace 1\nxwayland force\n") + self.cmd("reload") + self.wait_for_idle() + except Exception: + pass + + # 4. Reset workspaces (recreate workspace 1) + try: + self.cmd("workspace __temp") + self.cmd("workspace 1") + self.wait_for_idle() + except Exception: + pass @contextmanager def assert_logs_match( @@ -171,6 +127,83 @@ def assert_logs_match( ) time.sleep(0.1) + def wait_for_log_pattern( + self, pattern: str, timeout: float = 5.0, from_start: bool = False + ) -> None: + compiled_pattern = re.compile(pattern) + start_time = time.time() + initial_log_len = 0 if from_start else len(self.read_log()) + while True: + current_log: str = self.read_log() + log_to_search = current_log if from_start else current_log[initial_log_len:] + if compiled_pattern.search(log_to_search): + return + if time.time() - start_time > timeout: + raise AssertionError( + f"Pattern '{pattern}' not found in log output within" + f" {timeout}s.\nLog searched was:\n{log_to_search}" + ) + time.sleep(0.1) + + +class ScrollCompositorFactory: + _session: ScrollInstance + _active_context: Optional["ScrollCompositorContextManager"] + + def __init__(self, session: ScrollInstance): + self._session = session + self._active_context = None + + def print_log(self) -> None: + try: + print( + f"Compositor log (PID {self._session.proc.pid}):\n{self._session.read_log()}" + ) + except Exception as log_err: + print(f"Failed to read compositor log: {log_err}") + + def __call__(self, config: str | None = None) -> "ScrollCompositorContextManager": + return ScrollCompositorContextManager(self, self._session, config) + + +class ScrollCompositorContextManager: + _factory: ScrollCompositorFactory + _session: ScrollInstance + _config: Optional[str] + + def __init__( + self, + factory: ScrollCompositorFactory, + session: ScrollInstance, + config: Optional[str] = None, + ): + self._factory = factory + self._session = session + self._config = config + + def __enter__(self) -> ScrollInstance: + if self._factory._active_context is not None: + raise RuntimeError( + "ScrollInstance is already active in another context manager" + ) + self._factory._active_context = self + + if self._config is not None: + config_path = self._session.temp_dir / "config" + config_path.write_text(self._config) + self._session.cmd("reload") + self._session.wait_for_idle() + + return self._session + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + try: + if exc_type is not None: + self._factory.print_log() + self._session.reset() + finally: + self._factory._active_context = None + @contextmanager def run_compositor( @@ -191,9 +224,11 @@ def run_compositor( tests_dir = Path(__file__).parent.resolve() supp_path = tests_dir / "lsan.supp" if "LSAN_OPTIONS" in env: - env["LSAN_OPTIONS"] = f"suppressions={supp_path}:{env['LSAN_OPTIONS']}" + env["LSAN_OPTIONS"] = ( + f"detect_leaks=0:suppressions={supp_path}:{env['LSAN_OPTIONS']}" + ) else: - env["LSAN_OPTIONS"] = f"suppressions={supp_path}" + env["LSAN_OPTIONS"] = f"detect_leaks=0:suppressions={supp_path}" if "DISPLAY" in env: del env["DISPLAY"] if "WAYLAND_DISPLAY" in env: @@ -282,3 +317,33 @@ def wait_for_client_map(compositor: ScrollInstance, title: str) -> int: time.sleep(0.05) tries += 1 raise RuntimeError(f"Client '{title}' did not map") + + +def find_node_by_title_contains( + node: Dict[str, Any], title_sub: str +) -> Optional[Dict[str, Any]]: + name = node.get("name") + if name and title_sub in name: + return node + for child in node.get("nodes", []): + res = find_node_by_title_contains(child, title_sub) + if res: + return res + for child in node.get("floating_nodes", []): + res = find_node_by_title_contains(child, title_sub) + if res: + return res + return None + + +def wait_for_title_contains_map( + compositor: ScrollInstance, title_sub: str, timeout: float = 15.0 +) -> Dict[str, Any]: + start = time.time() + while time.time() - start < timeout: + tree = compositor.get_tree() + node = find_node_by_title_contains(tree, title_sub) + if node: + return node + time.sleep(0.2) + raise RuntimeError(f"Client with title containing '{title_sub}' did not map.") diff --git a/tests/test_workspace_animation_uaf.py b/tests/test_workspace_animation_uaf.py new file mode 100644 index 000000000..59a94dcb6 --- /dev/null +++ b/tests/test_workspace_animation_uaf.py @@ -0,0 +1,24 @@ +import time +from test_utils import ScrollCompositorFactory + + +def test_workspace_switch_empty_uaf( + scroll_compositor_factory: ScrollCompositorFactory, +) -> None: + config = ( + "workspace 1\n" + "xwayland force\n" + "animations enabled yes\n" + "animations workspace_switch yes 5000\n" + ) + with scroll_compositor_factory(config) as scroll_compositor: + scroll_compositor.cmd("workspace 2; workspace 1") + # Sleep a bit to let the animation start and run a few frames, triggering UAF + time.sleep(0.5) + + # Check if the compositor process crashed + if scroll_compositor.proc.poll() is not None: + print("=== COMPOSITOR LOG (ASAN) ===") + print(scroll_compositor.read_log()) + print("=============================") + assert False, "Compositor crashed" diff --git a/tests/test_workspace_split_uaf.py b/tests/test_workspace_split_uaf.py index cb0345171..dc33dbd68 100644 --- a/tests/test_workspace_split_uaf.py +++ b/tests/test_workspace_split_uaf.py @@ -1,59 +1,48 @@ -import time -from conftest import ScrollInstance -from test_utils import wayland_client, wait_for_client_map +from test_utils import wayland_client, wait_for_client_map, ScrollCompositorFactory -def test_workspace_split_uaf_crash(fresh_compositor: ScrollInstance) -> None: - try: +def test_workspace_split_uaf_crash( + scroll_compositor_factory: ScrollCompositorFactory, +) -> None: + config = "workspace 1\nxwayland force\nanimations enabled off\n" + with scroll_compositor_factory(config) as scroll_compositor: # 1. Open Window 1 on Workspace 1 (active on HEADLESS-1) - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") + with wayland_client(scroll_compositor, "Window 1"): + wait_for_client_map(scroll_compositor, "Window 1") # 2. Split workspace 1. This creates workspace 2 as sibling. # Workspace 1 has Window 1, Workspace 2 is empty. - res = fresh_compositor.cmd("workspace split") + res = scroll_compositor.cmd("workspace split") assert res and res[0]["success"], f"workspace split failed: {res}" # 3. Create a second output HEADLESS-2. # It should get a default workspace (probably 3). - res = fresh_compositor.cmd("create_output") + res = scroll_compositor.cmd("create_output") assert res and res[0]["success"], f"create_output failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 4. Unplug HEADLESS-1. # Workspaces 1 and 2 should be evacuated. # Workspace 1 (non-empty) is moved to HEADLESS-2. # Workspace 2 (empty) is destroyed. - res = fresh_compositor.cmd("output HEADLESS-1 unplug") + res = scroll_compositor.cmd("output HEADLESS-1 unplug") assert res and res[0]["success"], f"unplug failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 5. Focus Workspace 3 on HEADLESS-2 (so Workspace 1 becomes inactive) - res = fresh_compositor.cmd("workspace 3") + res = scroll_compositor.cmd("workspace 3") assert res and res[0]["success"], f"workspace 3 failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 6. Move Window 1 from Workspace 1 to Workspace 3. # Since Window 1 is moved out of Workspace 1, and Workspace 1 is inactive, # it should trigger workspace_consider_destroy(Workspace 1). # Workspace 1 is empty, and it is split (sibling was Workspace 2). # It will try to access Workspace 2 (which is destroyed) -> UAF. - res = fresh_compositor.cmd( + res = scroll_compositor.cmd( '[title="Window 1"] move container to workspace 3' ) assert res and res[0]["success"], f"move container failed: {res}" # Check if compositor process is still alive - log_content = fresh_compositor.read_log() - print(f"Compositor log:\n{log_content}") - assert fresh_compositor.proc.poll() is None, "Compositor crashed" - except Exception as e: - print(f"Test failed with exception: {e}") - try: - print(f"Compositor log:\n{fresh_compositor.read_log()}") - except Exception as log_err: - print(f"Failed to read compositor log: {log_err}") - raise e + assert scroll_compositor.proc.poll() is None, "Compositor crashed"