From 19d591d8329277b6028390ec923ebc9a6aeb6423 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 10 Jul 2026 16:10:07 -0700 Subject: [PATCH] Fix heap-use-after-free in workspace switch animation During rapid workspace switching, empty workspaces can be destroyed synchronously when transactions are completed. If an active animation's filter data still holds a raw pointer to a destroyed workspace, it results in a heap-use-after-free when the layout is next arranged. This patch fixes the issue by registering destroy listeners on both the 'from' and 'to' workspaces in `workspace_switch_data`. When a workspace is destroyed, its pointer in the animation data is set to NULL. The filters and layout code are updated to safely handle NULL workspaces. Additionally, disable animations by default in `ScrollInstance.reset()` to prevent test pollution between test runs. --- sway/tree/workspace.c | 41 +++++++++++++++++++++++++++++++++++++++-- tests/test_utils.py | 5 +++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index 2754728d..64861001 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -796,13 +796,36 @@ struct workspace_switch_data { list_t *from_containers; list_t *to_containers; struct sway_root_filters *root_filters; + struct wl_listener from_destroy; + struct wl_listener to_destroy; }; +static void handle_from_workspace_destroy(struct wl_listener *listener, void *data) { + struct workspace_switch_data *wdata = wl_container_of(listener, wdata, from_destroy); + wdata->from = NULL; + wl_list_remove(&listener->link); + wl_list_init(&listener->link); +} + +static void handle_to_workspace_destroy(struct wl_listener *listener, void *data) { + struct workspace_switch_data *wdata = wl_container_of(listener, wdata, to_destroy); + wdata->to = NULL; + wl_list_remove(&listener->link); + wl_list_init(&listener->link); +} + static void workspace_switch_callback_end(void *callback_data) { struct workspace_switch_data *data = callback_data; data->output->workspace_switching = false; root_filters_destroy(root, data->root_filters); + if (data->from) { + wl_list_remove(&data->from_destroy.link); + } + if (data->to) { + wl_list_remove(&data->to_destroy.link); + } + for (int i = 0; i < data->from_containers->length; ++i) { struct workspace_switch_container_data *cdata = data->from_containers->items[i]; cdata->container->pending.y = cdata->y; @@ -817,7 +840,7 @@ static void workspace_switch_callback_end(void *callback_data) { if (data->from && data->from->output) { node_set_dirty(&data->from->node); } - if (data->to->output) { + if (data->to && data->to->output) { node_set_dirty(&data->to->node); } @@ -843,7 +866,7 @@ static bool switching_output(struct sway_workspace *workspace, } struct sway_output *output = workspace->output; struct sway_output *from_output = data->from ? data->from->output : NULL; - struct sway_output *to_output = data->to->output; + struct sway_output *to_output = data->to ? data->to->output : NULL; if (!output || !from_output || !to_output) { return false; } @@ -1039,6 +1062,20 @@ static void animate_workspace_switch(struct sway_output *output, data->from_containers = create_list(); data->to_containers = create_list(); + data->from_destroy.notify = handle_from_workspace_destroy; + if (data->from) { + wl_signal_add(&data->from->node.events.destroy, &data->from_destroy); + } else { + wl_list_init(&data->from_destroy.link); + } + + data->to_destroy.notify = handle_to_workspace_destroy; + if (data->to) { + wl_signal_add(&data->to->node.events.destroy, &data->to_destroy); + } else { + wl_list_init(&data->to_destroy.link); + } + animation_end(); animation_set_type(ANIMATION_WORKSPACE_SWITCH); diff --git a/tests/test_utils.py b/tests/test_utils.py index 2e4d22dc..b54c1a6a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -85,6 +85,7 @@ f:close() """ +DEFAULT_CONFIG = "workspace 1\nxwayland force\nanimations enabled no\n" class ScrollInstance: proc: subprocess.Popen @@ -203,7 +204,7 @@ def reset(self) -> None: # 3. Reload config to reset defaults try: config_path = self.temp_dir / "config" - config_path.write_text("workspace 1\nxwayland force\n") + config_path.write_text(DEFAULT_CONFIG) self.cmd("reload") self.wait_for_idle() except Exception: @@ -323,7 +324,7 @@ def run_compositor( config_path: Path = temp_dir / "config" if config_content is None: - config_content = "workspace 1\nxwayland force\n" + config_content = DEFAULT_CONFIG config_path.write_text(config_content) env = os.environ.copy()