From 665e4c92fcafff001e93d4e554d8d01eee7e8740 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Wed, 17 Jun 2026 16:43:24 -0700 Subject: [PATCH 01/26] Prevent compositor crash on Lua callback errors If a Lua script registers a callback (e.g. for workspace focus or window mapping) and that callback raises an error, the compositor previously crashed with "unprotected lua error" because it used the unprotected `lua_call` API. This change introduces a `safe_pcall` helper that wraps `lua_pcall`. It is now used for all callback executions. If an error occurs, it is logged as an error, but the compositor continues running. --- sway/lua.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/sway/lua.c b/sway/lua.c index 545e83fc..e4be414d 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -72,6 +72,15 @@ static int scroll_log(lua_State *L) { return 0; } +static void safe_pcall(lua_State *L, int nargs) { + int err = lua_pcall(L, nargs, 0, 0); + if (err != LUA_OK) { + const char *msg = lua_tostring(L, -1); + sway_log(SWAY_ERROR, "Lua error: %s", msg ? msg : "unknown error"); + lua_pop(L, 1); + } +} + static int scroll_state_get_value(lua_State *L) { int argc = lua_gettop(L); if (argc < 2) { @@ -272,7 +281,7 @@ static int scroll_command(lua_State *L) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, config->lua.command_data); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } return 1; } @@ -1657,7 +1666,7 @@ void lua_execute_view_map_cbs(struct sway_view *view) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, view); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1667,7 +1676,7 @@ void lua_execute_view_unmap_cbs(struct sway_view *view) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, view); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1677,7 +1686,7 @@ void lua_execute_view_urgent_cbs(struct sway_view *view) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, view); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1688,7 +1697,7 @@ void lua_execute_view_focus_cbs(struct sway_view *view) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, view); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1698,7 +1707,7 @@ void lua_execute_view_float_cbs(struct sway_view *view) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, view); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1708,7 +1717,7 @@ void lua_execute_workspace_create_cbs(struct sway_workspace *workspace) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, workspace); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1718,7 +1727,7 @@ void lua_execute_workspace_focus_cbs(struct sway_workspace *workspace) { lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); lua_pushlightuserdata(config->lua.state, workspace); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } @@ -1730,7 +1739,7 @@ void lua_execute_ipc_view_cbs(struct sway_view *view, const char *change) { lua_pushlightuserdata(config->lua.state, view); lua_pushstring(config->lua.state, change); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 3, 0); + safe_pcall(config->lua.state, 3); } } } @@ -1744,7 +1753,7 @@ void lua_execute_ipc_workspace_cbs(struct sway_workspace *old_ws, lua_pushlightuserdata(config->lua.state, new_ws); lua_pushstring(config->lua.state, change); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 4, 0); + safe_pcall(config->lua.state, 4); } } @@ -1758,6 +1767,6 @@ void lua_execute_jump_end_cbs(struct sway_container *container) { lua_pushnil(config->lua.state); } lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); - lua_call(config->lua.state, 2, 0); + safe_pcall(config->lua.state, 2); } } From c63c533c5e8e6e1e0c7872ce76a0efdbdb4fa29b Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Wed, 17 Jun 2026 16:43:25 -0700 Subject: [PATCH 02/26] Fix visibility bounds check in filter_container_visible Containers that exactly touched the right or bottom edge of the output were being incorrectly considered invisible due to using `>=` instead of `>` in the bounds check. For example, on a 1280x720 screen, a single tiling window filling the screen has geometry x=0, y=0, width=1280, height=720. Its bottom edge is at y + height = 720, which is equal to maxy. The check: `y + height >= maxy` evaluated to true, marking the container as invisible. This caused commands like `jump` (which filter for visible containers) to fail to detect any windows when a single window was fullscreen or tiling and filling the screen. Reproduction: 1. Start scroll/sway with a single tiling window open. 2. Run the `jump` command. 3. Observe that jump mode is not entered (no labels appear, input is not captured). Fix this by using strict inequality `>` for the upper bounds check, so containers that touch the edge but do not exceed it are considered visible. --- sway/tree/layout.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sway/tree/layout.c b/sway/tree/layout.c index cf9819e2..4292ffa2 100644 --- a/sway/tree/layout.c +++ b/sway/tree/layout.c @@ -1570,9 +1570,9 @@ static bool filter_container_visible(struct sway_workspace *workspace, maxy = workspace->y + workspace->height; } if (container->pending.x < minx || - container->pending.x + container->pending.width * scale >= maxx || + container->pending.x + container->pending.width * scale > maxx || container->pending.y < miny || - container->pending.y + container->pending.height * scale >= maxy) { + container->pending.y + container->pending.height * scale > maxy) { return false; } return true; From 96aea8ee308c36b0b5faee674003ea45baaa0ca1 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 13:54:58 -0700 Subject: [PATCH 03/26] layout: reap empty parent after moving container This commit fixes a bug in layout move commands (`layout_move_container_nomode` and `layout_move_container_to_workspace`) where they were calling `container_reap_empty` on the parent container. However, these parent containers were already being reaped/destroyed as part of the normal tree rearrangement (e.g., when the last child is moved out of a split container, or during simplification). The duplicate call resulted in trying to destroy the same container twice. When safety checks (node hash map) are enabled, this double reap triggers a "Node not present in table" abort in `node_map_remove` because the node is removed from the map during the first reap and is not present during the second. Without the node hash map safety checks, the double reap does not cause a crash or trigger ASan. The duplicate call to `container_begin_destroy` is safe because it returns early if `con->node.destroying` is already true, and the duplicate `node_set_dirty` also returns early. The container is only added to the transaction once and thus freed once. We removed the redundant `container_reap_empty` calls. Reproduction instructions: 1. Enable safety checks (node hash map) in `node.c`. 2. Start scroll. 3. Open two windows (they will be tiled vertically by default). 4. Focus the second window. 5. Run the command: `move left nomode`. 6. The compositor will abort/crash with "Node not present in table" in `node_map_remove`. --- sway/tree/layout.c | 3 --- 1 file changed, 3 deletions(-) diff --git a/sway/tree/layout.c b/sway/tree/layout.c index 4292ffa2..e36e7e3e 100644 --- a/sway/tree/layout.c +++ b/sway/tree/layout.c @@ -861,7 +861,6 @@ void layout_move_container_to_workspace(struct sway_container *container, struct container_detach_update_parent_fullscreen_layout(parent, container); container_update_representation(parent); node_set_dirty(&parent->node); - container_reap_empty(parent); layout_add_view(workspace, active, container); node_set_dirty(&container->node); } @@ -1390,8 +1389,6 @@ static bool layout_move_container_nomode(struct sway_container *container, enum struct sway_container *active = new_parent->current.focused_inactive_child; int new_index = layout_insert_compute_index(new_parent->pending.children, active, pos); layout_insert_into_container(new_parent, container, new_index); - // Delete old parent container - container_reap_empty(parent); } apply_container_sizes(new_parent, layout_toggle_size_width_fraction(workspace), layout_toggle_size_height_fraction(workspace), OPERATION_FOCUS); From 16f3b4e8126cf889f6df2019822b8af45418a560 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 13:55:09 -0700 Subject: [PATCH 04/26] tree: introduce node hash map for O(1) lookups and safety This commit introduces a safe lookup mechanism for container/view IDs. Instead of casting arbitrary integers to pointers (which could lead to crashes if invalid or stale IDs are used), we now maintain a hash map (node_table) mapping unique IDs to actual sway_node pointers. The node map is initialized during server startup (`server_init`) and finalized during shutdown in `main` after the root node has been destroyed. All nodes are registered in the map upon creation (`node_init`) and removed upon destruction (`node_fini`). This enables: - O(1) node lookup for `swap container with con_id`. - O(1) node lookup for criteria matching by `con_id`. --- include/khashl.h | 506 +++++++++++++++++++++++++++++++++++++++ include/sway/tree/node.h | 5 + sway/commands/swap.c | 17 +- sway/criteria.c | 13 + sway/main.c | 2 + sway/server.c | 2 + sway/tree/container.c | 2 +- sway/tree/node.c | 78 ++++++ sway/tree/output.c | 2 +- sway/tree/root.c | 2 + sway/tree/workspace.c | 2 +- 11 files changed, 622 insertions(+), 9 deletions(-) create mode 100644 include/khashl.h diff --git a/include/khashl.h b/include/khashl.h new file mode 100644 index 00000000..4e05416c --- /dev/null +++ b/include/khashl.h @@ -0,0 +1,506 @@ +/* The MIT License + + Copyright (c) 2019- by Attractive Chaos + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +#ifndef __AC_KHASHL_H +#define __AC_KHASHL_H + +#define AC_VERSION_KHASHL_H "r40" + +#include +#include +#include + +/************************************ + * Compiler specific configurations * + ************************************/ + +#if UINT_MAX == 0xffffffffu +typedef unsigned int khint32_t; +#elif ULONG_MAX == 0xffffffffu +typedef unsigned long khint32_t; +#endif + +#if ULONG_MAX == ULLONG_MAX +typedef unsigned long khint64_t; +#else +typedef unsigned long long khint64_t; +#endif + +#ifndef kh_inline +#ifdef _MSC_VER +#define kh_inline __inline +#else +#define kh_inline inline +#endif +#endif /* kh_inline */ + +#ifndef klib_unused +#if (defined __clang__ && __clang_major__ >= 3) || (defined __GNUC__ && __GNUC__ >= 3) +#define klib_unused __attribute__ ((__unused__)) +#else +#define klib_unused +#endif +#endif /* klib_unused */ + +#define KH_LOCAL static kh_inline klib_unused + +typedef khint32_t khint_t; +typedef const char *kh_cstr_t; + +/*********************** + * Configurable macros * + ***********************/ + +#ifndef kh_max_count /* set the max load factor */ +#define kh_max_count(cap) (((cap)>>1) + ((cap)>>2)) /* default load factor: 75% */ +#endif + +#ifndef kh_packed /* pack the key-value struct */ +#define kh_packed __attribute__ ((__packed__)) +#endif + +#if !defined(Kmalloc) || !defined(Kcalloc) || !defined(Krealloc) || !defined(Kfree) +#define Kmalloc(km, type, cnt) ((type*)malloc((cnt) * sizeof(type))) +#define Kcalloc(km, type, cnt) ((type*)calloc((cnt), sizeof(type))) +#define Krealloc(km, type, ptr, cnt) ((type*)realloc((ptr), (cnt) * sizeof(type))) +#define Kfree(km, ptr) free(ptr) +#endif + +/**************************** + * Simple private functions * + ****************************/ + +#define __kh_used(flag, i) (flag[i>>5] >> (i&0x1fU) & 1U) +#define __kh_set_used(flag, i) (flag[i>>5] |= 1U<<(i&0x1fU)) +#define __kh_set_unused(flag, i) (flag[i>>5] &= ~(1U<<(i&0x1fU))) + +#define __kh_fsize(m) ((m) < 32? 1 : (m)>>5) + +static kh_inline khint_t __kh_splitmix32(khint_t *x) { khint_t z = (*x += 0x9e3779b9U); z = (z ^ (z >> 16)) * 0x21f0aaadU; z = (z ^ (z >> 15)) * 0x735a2d97U; return z ^ (z >> 15); } +static kh_inline khint_t __kh_h2b(khint_t hash, khint_t salt, khint_t bits) { return (hash ^ salt) * 2654435769U >> (32 - bits); } /* Fibonacci hashing */ + +/******************* + * Hash table base * + *******************/ + +#define __KHASHL_TYPE(HType, khkey_t) \ + typedef struct HType { \ + void *km; \ + unsigned short bits, salt; \ + khint_t count; \ + khint32_t *used; \ + khkey_t *keys; \ + } HType; + +#define __KHASHL_PROTOTYPES(HType, prefix, khkey_t) \ + extern HType *prefix##_init(void); \ + extern HType *prefix##_init2(void *km); \ + extern void prefix##_destroy(HType *h); \ + extern void prefix##_clear(HType *h); \ + extern khint_t prefix##_getp(const HType *h, const khkey_t *key); \ + extern int prefix##_resize(HType *h, khint_t new_n_buckets); \ + extern khint_t prefix##_putp(HType *h, const khkey_t *key, int *absent); \ + extern void prefix##_del(HType *h, khint_t k); + +#define __KHASHL_IMPL_BASIC(SCOPE, HType, prefix) \ + SCOPE HType *prefix##_init3(void *km, khint_t seed) { \ + HType *h = Kcalloc(km, HType, 1); \ + h->km = km; \ + if (seed != 0) h->salt = __kh_splitmix32(&seed); \ + return h; \ + } \ + SCOPE HType *prefix##_init2(void *km) { return prefix##_init3(km, 0); } \ + SCOPE HType *prefix##_init(void) { return prefix##_init2(0); } \ + SCOPE void prefix##_destroy(HType *h) { \ + if (!h) return; \ + Kfree(h->km, (void*)h->keys); Kfree(h->km, h->used); \ + Kfree(h->km, h); \ + } \ + SCOPE void prefix##_clear(HType *h) { \ + if (h && h->used) { \ + khint_t n_buckets = (khint_t)1U << h->bits; \ + memset(h->used, 0, __kh_fsize(n_buckets) * sizeof(khint32_t)); \ + h->count = 0; \ + } \ + } + +#define __KHASHL_IMPL_GET(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + SCOPE khint_t prefix##_getp_core(const HType *h, const khkey_t *key, khint_t hash) { \ + khint_t i, last, n_buckets, mask; \ + if (h->keys == 0) return 0; \ + n_buckets = (khint_t)1U << h->bits; \ + mask = n_buckets - 1U; \ + i = last = __kh_h2b(hash, h->salt, h->bits); \ + while (__kh_used(h->used, i) && !__hash_eq(h->keys[i], *key)) { \ + i = (i + 1U) & mask; \ + if (i == last) return n_buckets; \ + } \ + return !__kh_used(h->used, i)? n_buckets : i; \ + } \ + SCOPE khint_t prefix##_getp(const HType *h, const khkey_t *key) { return prefix##_getp_core(h, key, __hash_fn(*key)); } \ + SCOPE khint_t prefix##_get(const HType *h, khkey_t key) { return prefix##_getp_core(h, &key, __hash_fn(key)); } + +#define __KHASHL_IMPL_RESIZE(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + SCOPE int prefix##_resize(HType *h, khint_t new_n_buckets) { \ + khint32_t *new_used = 0; \ + khint_t j = 0, x = new_n_buckets, n_buckets, new_bits, new_mask; \ + while ((x >>= 1) != 0) ++j; \ + if (new_n_buckets & (new_n_buckets - 1)) ++j; \ + new_bits = j > 2? j : 2; \ + if (new_bits == h->bits) return 0; /* same size; no need to rehash */ \ + new_n_buckets = (khint_t)1U << new_bits; \ + if (h->count > kh_max_count(new_n_buckets)) return 0; /* requested size is too small */ \ + new_used = Kmalloc(h->km, khint32_t, __kh_fsize(new_n_buckets)); \ + if (!new_used) return -1; /* not enough memory */ \ + memset(new_used, 0, __kh_fsize(new_n_buckets) * sizeof(khint32_t)); \ + n_buckets = h->keys? (khint_t)1U<bits : 0U; \ + if (n_buckets < new_n_buckets) { /* expand */ \ + khkey_t *new_keys = Krealloc(h->km, khkey_t, h->keys, new_n_buckets); \ + if (!new_keys) { Kfree(h->km, new_used); return -1; } \ + h->keys = new_keys; \ + } \ + new_mask = new_n_buckets - 1; \ + for (j = 0; j != n_buckets; ++j) { \ + khkey_t key; \ + if (!__kh_used(h->used, j)) continue; \ + key = h->keys[j]; \ + __kh_set_unused(h->used, j); \ + while (1) { /* kick-out process; sort of like in Cuckoo hashing */ \ + khint_t i; \ + i = __kh_h2b(__hash_fn(key), h->salt, new_bits); \ + while (__kh_used(new_used, i)) i = (i + 1U) & new_mask; \ + __kh_set_used(new_used, i); \ + if (i < n_buckets && __kh_used(h->used, i)) { /* kick out the existing element */ \ + { khkey_t tmp = h->keys[i]; h->keys[i] = key; key = tmp; } \ + __kh_set_unused(h->used, i); /* mark it as deleted in the old hash table */ \ + } else { /* write the element and jump out of the loop */ \ + h->keys[i] = key; \ + break; \ + } \ + } \ + } \ + if (n_buckets > new_n_buckets) { /* shrink the hash table */ \ + khkey_t *new_keys = Krealloc(h->km, khkey_t, h->keys, new_n_buckets); \ + if (!new_keys) { Kfree(h->km, new_used); return -1; } \ + h->keys = new_keys; \ + } \ + Kfree(h->km, h->used); /* free the working space */ \ + h->used = new_used, h->bits = new_bits; \ + return 0; \ + } + +#define __KHASHL_IMPL_PUT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + SCOPE khint_t prefix##_putp_core(HType *h, const khkey_t *key, khint_t hash, int *absent) { \ + khint_t n_buckets, i, last, mask; \ + n_buckets = h->keys? (khint_t)1U<bits : 0U; \ + *absent = -1; \ + if (h->count >= kh_max_count(n_buckets)) { /* rehashing */ \ + if (prefix##_resize(h, n_buckets + 1U) < 0) \ + return n_buckets; \ + n_buckets = (khint_t)1U<bits; \ + } /* TODO: to implement automatically shrinking; resize() already support shrinking */ \ + mask = n_buckets - 1; \ + i = last = __kh_h2b(hash, h->salt, h->bits); \ + while (__kh_used(h->used, i) && !__hash_eq(h->keys[i], *key)) { \ + i = (i + 1U) & mask; \ + if (i == last) break; \ + } \ + if (!__kh_used(h->used, i)) { /* not present at all */ \ + h->keys[i] = *key; \ + __kh_set_used(h->used, i); \ + ++h->count; \ + *absent = 1; \ + } else *absent = 0; /* Don't touch h->keys[i] if present */ \ + return i; \ + } \ + SCOPE khint_t prefix##_putp(HType *h, const khkey_t *key, int *absent) { return prefix##_putp_core(h, key, __hash_fn(*key), absent); } \ + SCOPE khint_t prefix##_put(HType *h, khkey_t key, int *absent) { return prefix##_putp_core(h, &key, __hash_fn(key), absent); } + +#define __KHASHL_IMPL_DEL(SCOPE, HType, prefix, khkey_t, __hash_fn) \ + SCOPE int prefix##_del(HType *h, khint_t i) { \ + khint_t j = i, k, mask, n_buckets; \ + if (h->keys == 0) return 0; \ + n_buckets = (khint_t)1U<bits; \ + mask = n_buckets - 1U; \ + while (1) { \ + j = (j + 1U) & mask; \ + if (j == i || !__kh_used(h->used, j)) break; /* j==i only when the table is completely full */ \ + k = __kh_h2b(__hash_fn(h->keys[j]), h->salt, h->bits); \ + if ((j > i && (k <= i || k > j)) || (j < i && (k <= i && k > j))) \ + h->keys[i] = h->keys[j], i = j; \ + } \ + __kh_set_unused(h->used, i); \ + --h->count; \ + return 1; \ + } + +#define KHASHL_DECLARE(HType, prefix, khkey_t) \ + __KHASHL_TYPE(HType, khkey_t) \ + __KHASHL_PROTOTYPES(HType, prefix, khkey_t) + +#define KHASHL_INIT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + __KHASHL_TYPE(HType, khkey_t) \ + __KHASHL_IMPL_BASIC(SCOPE, HType, prefix) \ + __KHASHL_IMPL_GET(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + __KHASHL_IMPL_RESIZE(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + __KHASHL_IMPL_PUT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + __KHASHL_IMPL_DEL(SCOPE, HType, prefix, khkey_t, __hash_fn) + +/*************************** + * Ensemble of hash tables * + ***************************/ + +typedef struct { + khint_t sub, pos; +} kh_ensitr_t; + +#define KHASHE_INIT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + KHASHL_INIT(KH_LOCAL, HType##_sub, prefix##_sub, khkey_t, __hash_fn, __hash_eq) \ + typedef struct HType { \ + void *km; \ + khint64_t count:54, bits:8; \ + HType##_sub *sub; \ + } HType; \ + SCOPE HType *prefix##_init3(void *km, int bits, khint_t seed) { \ + HType *g; \ + g = Kcalloc(km, HType, 1); \ + if (!g) return 0; \ + g->bits = bits, g->km = km; \ + g->sub = Kcalloc(km, HType##_sub, 1U<sub[i].salt = __kh_splitmix32(&rng); \ + } \ + return g; \ + } \ + SCOPE HType *prefix##_init2(void *km, int bits) { return prefix##_init3(km, bits, 0); } \ + SCOPE HType *prefix##_init(int bits) { return prefix##_init2(0, bits); } \ + SCOPE void prefix##_destroy(HType *g) { \ + int t; \ + if (!g) return; \ + for (t = 0; t < 1<bits; ++t) { Kfree(g->km, (void*)g->sub[t].keys); Kfree(g->km, g->sub[t].used); } \ + Kfree(g->km, g->sub); Kfree(g->km, g); \ + } \ + SCOPE kh_ensitr_t prefix##_getp(const HType *g, const khkey_t *key) { \ + khint_t hash, low, ret; \ + kh_ensitr_t r; \ + HType##_sub *h; \ + hash = __hash_fn(*key); \ + low = hash & ((1U<bits) - 1); \ + h = &g->sub[low]; \ + ret = prefix##_sub_getp_core(h, key, hash); \ + if (ret == kh_end(h)) r.sub = low, r.pos = (khint_t)-1; \ + else r.sub = low, r.pos = ret; \ + return r; \ + } \ + SCOPE kh_ensitr_t prefix##_get(const HType *g, const khkey_t key) { return prefix##_getp(g, &key); } \ + SCOPE kh_ensitr_t prefix##_putp(HType *g, const khkey_t *key, int *absent) { \ + khint_t hash, low, ret; \ + kh_ensitr_t r; \ + HType##_sub *h; \ + hash = __hash_fn(*key); \ + low = hash & ((1U<bits) - 1); \ + h = &g->sub[low]; \ + ret = prefix##_sub_putp_core(h, key, hash, absent); \ + if (*absent) ++g->count; \ + r.sub = low, r.pos = ret; \ + return r; \ + } \ + SCOPE kh_ensitr_t prefix##_put(HType *g, const khkey_t key, int *absent) { return prefix##_putp(g, &key, absent); } \ + SCOPE int prefix##_del(HType *g, kh_ensitr_t itr) { \ + HType##_sub *h = &g->sub[itr.sub]; \ + int ret; \ + ret = prefix##_sub_del(h, itr.pos); \ + if (ret) --g->count; \ + return ret; \ + } \ + SCOPE void prefix##_clear(HType *g) { \ + int i; \ + for (i = 0; i < 1U<bits; ++i) prefix##_sub_clear(&g->sub[i]); \ + g->count = 0; \ + } \ + SCOPE void prefix##_resize(HType *g, khint64_t new_n_buckets) { \ + khint_t j; \ + for (j = 0; j < 1U<bits; ++j) \ + prefix##_sub_resize(&g->sub[j], new_n_buckets >> g->bits); \ + } + +/***************************** + * More convenient interface * + *****************************/ + +/* common */ + +#define KHASHL_SET_INIT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; } kh_packed HType##_s_bucket_t; \ + static kh_inline khint_t prefix##_s_hash(HType##_s_bucket_t x) { return __hash_fn(x.key); } \ + static kh_inline int prefix##_s_eq(HType##_s_bucket_t x, HType##_s_bucket_t y) { return __hash_eq(x.key, y.key); } \ + KHASHL_INIT(KH_LOCAL, HType, prefix##_s, HType##_s_bucket_t, prefix##_s_hash, prefix##_s_eq) \ + SCOPE HType *prefix##_init(void) { return prefix##_s_init(); } \ + SCOPE HType *prefix##_init2(void *km) { return prefix##_s_init2(km); } \ + SCOPE HType *prefix##_init3(void *km, khint_t seed) { return prefix##_s_init3(km, seed); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_s_destroy(h); } \ + SCOPE void prefix##_resize(HType *h, khint_t new_n_buckets) { prefix##_s_resize(h, new_n_buckets); } \ + SCOPE khint_t prefix##_get(const HType *h, khkey_t key) { HType##_s_bucket_t t; t.key = key; return prefix##_s_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, khint_t k) { return prefix##_s_del(h, k); } \ + SCOPE khint_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_s_bucket_t t; t.key = key; return prefix##_s_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_s_clear(h); } + +#define KHASHL_MAP_INIT(SCOPE, HType, prefix, khkey_t, kh_val_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; kh_val_t val; } kh_packed HType##_m_bucket_t; \ + static kh_inline khint_t prefix##_m_hash(HType##_m_bucket_t x) { return __hash_fn(x.key); } \ + static kh_inline int prefix##_m_eq(HType##_m_bucket_t x, HType##_m_bucket_t y) { return __hash_eq(x.key, y.key); } \ + KHASHL_INIT(KH_LOCAL, HType, prefix##_m, HType##_m_bucket_t, prefix##_m_hash, prefix##_m_eq) \ + SCOPE HType *prefix##_init(void) { return prefix##_m_init(); } \ + SCOPE HType *prefix##_init2(void *km) { return prefix##_m_init2(km); } \ + SCOPE HType *prefix##_init3(void *km, khint_t seed) { return prefix##_m_init3(km, seed); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_m_destroy(h); } \ + SCOPE void prefix##_resize(HType *h, khint_t new_n_buckets) { prefix##_m_resize(h, new_n_buckets); } \ + SCOPE khint_t prefix##_get(const HType *h, khkey_t key) { HType##_m_bucket_t t; t.key = key; return prefix##_m_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, khint_t k) { return prefix##_m_del(h, k); } \ + SCOPE khint_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_m_bucket_t t; t.key = key; return prefix##_m_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_m_clear(h); } + +/* cached hashes to trade memory for performance when hashing and comparison are expensive */ + +#define __kh_cached_hash(x) ((x).hash) + +#define KHASHL_CSET_INIT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; khint_t hash; } kh_packed HType##_cs_bucket_t; \ + static kh_inline int prefix##_cs_eq(HType##_cs_bucket_t x, HType##_cs_bucket_t y) { return x.hash == y.hash && __hash_eq(x.key, y.key); } \ + KHASHL_INIT(KH_LOCAL, HType, prefix##_cs, HType##_cs_bucket_t, __kh_cached_hash, prefix##_cs_eq) \ + SCOPE HType *prefix##_init(void) { return prefix##_cs_init(); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_cs_destroy(h); } \ + SCOPE khint_t prefix##_get(const HType *h, khkey_t key) { HType##_cs_bucket_t t; t.key = key; t.hash = __hash_fn(key); return prefix##_cs_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, khint_t k) { return prefix##_cs_del(h, k); } \ + SCOPE khint_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_cs_bucket_t t; t.key = key, t.hash = __hash_fn(key); return prefix##_cs_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_cs_clear(h); } + +#define KHASHL_CMAP_INIT(SCOPE, HType, prefix, khkey_t, kh_val_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; kh_val_t val; khint_t hash; } kh_packed HType##_cm_bucket_t; \ + static kh_inline int prefix##_cm_eq(HType##_cm_bucket_t x, HType##_cm_bucket_t y) { return x.hash == y.hash && __hash_eq(x.key, y.key); } \ + KHASHL_INIT(KH_LOCAL, HType, prefix##_cm, HType##_cm_bucket_t, __kh_cached_hash, prefix##_cm_eq) \ + SCOPE HType *prefix##_init(void) { return prefix##_cm_init(); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_cm_destroy(h); } \ + SCOPE khint_t prefix##_get(const HType *h, khkey_t key) { HType##_cm_bucket_t t; t.key = key; t.hash = __hash_fn(key); return prefix##_cm_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, khint_t k) { return prefix##_cm_del(h, k); } \ + SCOPE khint_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_cm_bucket_t t; t.key = key, t.hash = __hash_fn(key); return prefix##_cm_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_cm_clear(h); } + +/* ensemble for huge hash tables */ + +#define KHASHE_SET_INIT(SCOPE, HType, prefix, khkey_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; } kh_packed HType##_es_bucket_t; \ + static kh_inline khint_t prefix##_es_hash(HType##_es_bucket_t x) { return __hash_fn(x.key); } \ + static kh_inline int prefix##_es_eq(HType##_es_bucket_t x, HType##_es_bucket_t y) { return __hash_eq(x.key, y.key); } \ + KHASHE_INIT(KH_LOCAL, HType, prefix##_es, HType##_es_bucket_t, prefix##_es_hash, prefix##_es_eq) \ + SCOPE HType *prefix##_init(int bits) { return prefix##_es_init(bits); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_es_destroy(h); } \ + SCOPE void prefix##_resize(HType *h, khint64_t new_n_buckets) { prefix##_es_resize(h, new_n_buckets); } \ + SCOPE kh_ensitr_t prefix##_get(const HType *h, khkey_t key) { HType##_es_bucket_t t; t.key = key; return prefix##_es_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, kh_ensitr_t k) { return prefix##_es_del(h, k); } \ + SCOPE kh_ensitr_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_es_bucket_t t; t.key = key; return prefix##_es_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_es_clear(h); } + +#define KHASHE_MAP_INIT(SCOPE, HType, prefix, khkey_t, kh_val_t, __hash_fn, __hash_eq) \ + typedef struct { khkey_t key; kh_val_t val; } kh_packed HType##_em_bucket_t; \ + static kh_inline khint_t prefix##_em_hash(HType##_em_bucket_t x) { return __hash_fn(x.key); } \ + static kh_inline int prefix##_em_eq(HType##_em_bucket_t x, HType##_em_bucket_t y) { return __hash_eq(x.key, y.key); } \ + KHASHE_INIT(KH_LOCAL, HType, prefix##_em, HType##_em_bucket_t, prefix##_em_hash, prefix##_em_eq) \ + SCOPE HType *prefix##_init(int bits) { return prefix##_em_init(bits); } \ + SCOPE void prefix##_destroy(HType *h) { prefix##_em_destroy(h); } \ + SCOPE void prefix##_resize(HType *h, khint64_t new_n_buckets) { prefix##_em_resize(h, new_n_buckets); } \ + SCOPE kh_ensitr_t prefix##_get(const HType *h, khkey_t key) { HType##_em_bucket_t t; t.key = key; return prefix##_em_getp(h, &t); } \ + SCOPE int prefix##_del(HType *h, kh_ensitr_t k) { return prefix##_em_del(h, k); } \ + SCOPE kh_ensitr_t prefix##_put(HType *h, khkey_t key, int *absent) { HType##_em_bucket_t t; t.key = key; return prefix##_em_putp(h, &t, absent); } \ + SCOPE void prefix##_clear(HType *h) { prefix##_em_clear(h); } + +/************************** + * Public macro functions * + **************************/ + +#define kh_bucket(h, x) ((h)->keys[x]) +#define kh_size(h) ((h)->count) +#define kh_capacity(h) ((h)->keys? 1U<<(h)->bits : 0U) +#define kh_end(h) kh_capacity(h) + +#define kh_key(h, x) ((h)->keys[x].key) +#define kh_val(h, x) ((h)->keys[x].val) +#define kh_exist(h, x) __kh_used((h)->used, (x)) + +#define kh_foreach(h, x) for ((x) = 0; (x) != kh_end(h); ++(x)) if (kh_exist((h), (x))) + +#define kh_ens_key(g, x) kh_key(&(g)->sub[(x).sub], (x).pos) +#define kh_ens_val(g, x) kh_val(&(g)->sub[(x).sub], (x).pos) +#define kh_ens_exist(g, x) kh_exist(&(g)->sub[(x).sub], (x).pos) +#define kh_ens_is_end(x) ((x).pos == (khint_t)-1) +#define kh_ens_size(g) ((g)->count) + +#define kh_ens_foreach(g, x) for ((x).sub = 0; (x).sub != 1<<(g)->bits; ++(x).sub) for ((x).pos = 0; (x).pos != kh_end(&(g)->sub[(x).sub]); ++(x).pos) if (kh_ens_exist((g), (x))) + +/************************************** + * Common hash and equality functions * + **************************************/ + +#define kh_eq_generic(a, b) ((a) == (b)) +#define kh_eq_str(a, b) (strcmp((a), (b)) == 0) +#define kh_hash_dummy(x) ((khint_t)(x)) + +static kh_inline khint_t kh_hash_uint32(khint_t x) { /* murmur finishing */ + x ^= x >> 16; + x *= 0x85ebca6bU; + x ^= x >> 13; + x *= 0xc2b2ae35U; + x ^= x >> 16; + return x; +} + +static kh_inline khint_t kh_hash_uint64(khint64_t x) { /* splitmix64; see https://nullprogram.com/blog/2018/07/31/ for inversion */ + x ^= x >> 30; + x *= 0xbf58476d1ce4e5b9ULL; + x ^= x >> 27; + x *= 0x94d049bb133111ebULL; + x ^= x >> 31; + return (khint_t)x; +} + +static kh_inline khint_t kh_hash_str(kh_cstr_t s) { /* FNV1a */ + khint_t h = 2166136261U; + const unsigned char *t = (const unsigned char*)s; + for (; *t; ++t) + h ^= *t, h *= 16777619; + return h; +} + +static kh_inline khint_t kh_hash_bytes(int len, const unsigned char *s) { + khint_t h = 2166136261U; + int i; + for (i = 0; i < len; ++i) + h ^= s[i], h *= 16777619; + return h; +} + +#endif /* __AC_KHASHL_H */ diff --git a/include/sway/tree/node.h b/include/sway/tree/node.h index e2dbcdf0..a5452865 100644 --- a/include/sway/tree/node.h +++ b/include/sway/tree/node.h @@ -87,4 +87,9 @@ void scene_node_disown_children(struct wlr_scene_tree *tree); struct wlr_scene_tree *alloc_scene_tree(struct wlr_scene_tree *parent, bool *failed); +void node_fini(struct sway_node *node); +struct sway_node *node_by_id(size_t id); +void node_map_init(void); +void node_map_fini(void); #endif + diff --git a/sway/commands/swap.c b/sway/commands/swap.c index c0b0d0b9..1caea2b9 100644 --- a/sway/commands/swap.c +++ b/sway/commands/swap.c @@ -8,15 +8,12 @@ #include "sway/tree/root.h" #include "sway/tree/view.h" #include "sway/tree/workspace.h" +#include "sway/tree/node.h" #include "stringop.h" static const char expected_syntax[] = "Expected 'swap container with id|con_id|mark '"; -static bool test_con_id(struct sway_container *container, void *data) { - size_t *con_id = data; - return container->node.id == *con_id; -} #if WLR_HAS_XWAYLAND static bool test_id(struct sway_container *container, void *data) { @@ -58,8 +55,16 @@ struct cmd_results *cmd_swap(int argc, char **argv) { other = root_find_container(test_id, &id); #endif } else if (strcasecmp(argv[2], "con_id") == 0) { - size_t con_id = atoi(value); - other = root_find_container(test_con_id, &con_id); + char *endptr; + size_t con_id = strtoul(value, &endptr, 10); + if (*value == '\0' || *endptr != '\0') { + free(value); + return cmd_results_new(CMD_INVALID, "Invalid container ID"); + } + struct sway_node *node = node_by_id(con_id); + if (node && node->type == N_CONTAINER) { + other = node->sway_container; + } } else if (strcasecmp(argv[2], "mark") == 0) { other = root_find_container(test_mark, value); } else { diff --git a/sway/criteria.c b/sway/criteria.c index 6be6e704..be171ea2 100644 --- a/sway/criteria.c +++ b/sway/criteria.c @@ -11,6 +11,7 @@ #include "sway/tree/root.h" #include "sway/tree/view.h" #include "sway/tree/workspace.h" +#include "sway/tree/node.h" #include "stringop.h" #include "list.h" #include "log.h" @@ -513,6 +514,18 @@ static void criteria_get_containers_iterator(struct sway_container *container, list_t *criteria_get_containers(struct criteria *criteria) { list_t *matches = create_list(); + if (criteria->con_id) { + struct sway_node *node = node_by_id(criteria->con_id); + if (node && node->type == N_CONTAINER) { + struct sway_container *container = node->sway_container; + struct match_data data = { + .criteria = criteria, + .matches = matches, + }; + criteria_get_containers_iterator(container, &data); + } + return matches; + } struct match_data data = { .criteria = criteria, .matches = matches, diff --git a/sway/main.c b/sway/main.c index a9438926..a4c53aa0 100644 --- a/sway/main.c +++ b/sway/main.c @@ -20,6 +20,7 @@ #include "sway/swaynag.h" #include "sway/desktop/transaction.h" #include "sway/tree/root.h" +#include "sway/tree/node.h" #include "sway/ipc-server.h" #include "ipc-client.h" #include "log.h" @@ -401,6 +402,7 @@ int main(int argc, char **argv) { server_fini(&server); root_destroy(root); root = NULL; + node_map_fini(); free(config_path); free_config(config); diff --git a/sway/server.c b/sway/server.c index 5d446579..f8124dac 100644 --- a/sway/server.c +++ b/sway/server.c @@ -63,6 +63,7 @@ #include "sway/server.h" #include "sway/input/cursor.h" #include "sway/tree/root.h" +#include "sway/tree/node.h" #if WLR_HAS_XWAYLAND #include @@ -241,6 +242,7 @@ static void handle_new_foreign_toplevel_capture_request(struct wl_listener *list } bool server_init(struct sway_server *server) { + node_map_init(); sway_log(SWAY_DEBUG, "Initializing Wayland server"); server->wl_display = wl_display_create(); server->wl_event_loop = wl_display_get_event_loop(server->wl_display); diff --git a/sway/tree/container.c b/sway/tree/container.c index fd9abadc..36c01d58 100644 --- a/sway/tree/container.c +++ b/sway/tree/container.c @@ -546,7 +546,7 @@ void container_begin_destroy(struct sway_container *con) { container_fullscreen_disable(con); } - wl_signal_emit_mutable(&con->node.events.destroy, &con->node); + node_fini(&con->node); container_end_mouse_operation(con); diff --git a/sway/tree/node.c b/sway/tree/node.c index 48ae325e..16358db7 100644 --- a/sway/tree/node.c +++ b/sway/tree/node.c @@ -1,3 +1,4 @@ +#include #include "sway/output.h" #include "sway/server.h" #include "sway/tree/container.h" @@ -6,14 +7,91 @@ #include "sway/tree/workspace.h" #include "log.h" +#include "khashl.h" + +typedef struct sway_node *sway_node_ptr; + +static kh_inline khint_t node_hash_fn(sway_node_ptr node) { + return node->id; +} + +static kh_inline int node_eq_fn(sway_node_ptr a, sway_node_ptr b) { + return a->id == b->id; +} + +KHASHL_INIT(KH_LOCAL, node_map_t, node_kh, sway_node_ptr, node_hash_fn, node_eq_fn) + +static node_map_t *node_table = NULL; + +void node_map_init(void) { + if (node_table) { + sway_abort("node_table already initialized"); + } + node_table = node_kh_init(); + if (!node_table) { + sway_abort("Failed to allocate node map table"); + } +} + +void node_map_fini(void) { + if (!node_table) { + sway_abort("node_table not initialized"); + } + node_kh_destroy(node_table); + node_table = NULL; +} + +static void node_map_add(struct sway_node *node) { + if (!node_table) { + sway_abort("node_table not initialized"); + } + int ret; + node_kh_put(node_table, node, &ret); + if (ret == -1) { + sway_abort("Failed to insert node into hash map (OOM)"); + } +} + +struct sway_node *node_by_id(size_t id) { + if (!node_table) { + return NULL; + } + struct sway_node dummy = {.id = id}; + khint_t k = node_kh_get(node_table, &dummy); + if (k == kh_end(node_table)) { + return NULL; + } + return kh_bucket(node_table, k); +} + +static void node_map_remove(struct sway_node *node) { + if (!node_table) { + sway_abort("node_table not initialized"); + } + khint_t k = node_kh_get(node_table, node); + if (k == kh_end(node_table)) { + sway_abort("Node not present in table"); + } + node_kh_del(node_table, k); +} + +void node_fini(struct sway_node *node) { + node_map_remove(node); + wl_signal_emit_mutable(&node->events.destroy, node); +} + + + void node_init(struct sway_node *node, enum sway_node_type type, void *thing) { static size_t next_id = 1; node->id = next_id++; node->type = type; node->sway_root = thing; wl_signal_init(&node->events.destroy); + node_map_add(node); } + const char *node_type_to_str(enum sway_node_type type) { switch (type) { case N_ROOT: diff --git a/sway/tree/output.c b/sway/tree/output.c index 90ec9331..1c69b7c8 100644 --- a/sway/tree/output.c +++ b/sway/tree/output.c @@ -299,7 +299,7 @@ void output_begin_destroy(struct sway_output *output) { return; } sway_log(SWAY_DEBUG, "Destroying output '%s'", output->wlr_output->name); - wl_signal_emit_mutable(&output->node.events.destroy, &output->node); + node_fini(&output->node); node_set_dirty(&output->node); output->node.destroying = true; diff --git a/sway/tree/root.c b/sway/tree/root.c index cf7170a4..b4cdd79f 100644 --- a/sway/tree/root.c +++ b/sway/tree/root.c @@ -13,6 +13,7 @@ #include "sway/tree/container.h" #include "sway/tree/root.h" #include "sway/tree/workspace.h" +#include "sway/tree/node.h" #include "list.h" #include "log.h" #include "util.h" @@ -83,6 +84,7 @@ void root_destroy(struct sway_root *root) { list_free(root->non_desktop_outputs); list_free(root->outputs); wlr_scene_node_destroy(&root->root_scene->tree.node); + node_fini(&root->node); free(root); } diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index 49a830e7..2ea915ea 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -161,7 +161,7 @@ void workspace_destroy(struct sway_workspace *workspace) { void workspace_begin_destroy(struct sway_workspace *workspace) { sway_log(SWAY_DEBUG, "Destroying workspace '%s'", workspace->name); ipc_event_workspace(NULL, workspace, "empty"); // intentional - wl_signal_emit_mutable(&workspace->node.events.destroy, &workspace->node); + node_fini(&workspace->node); if (workspace->output) { workspace_detach(workspace); From bbf1387e1100917759b482b1bcc08b23f6692533 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:01:00 -0700 Subject: [PATCH 05/26] Fix scratchpad UAF and double reap This commit fixes a use-after-free and double-reap crash when sending a tiled container to the scratchpad. When the container is tiled, `root_scratchpad_add_container` calls `container_set_floating`, which detaches the container from its tiled parent and reaps the parent if it becomes empty. However, `root_scratchpad_add_container` was then: 1. Accessing the `parent` pointer (which might have been freed during reap), causing a use-after-free. 2. Calling `container_reap_empty` on the parent again, causing a double-reap (abort in `node_map_remove`). 3. Calling `arrange_container(parent)` on the potentially freed parent. We fix this by: 1. Removing the redundant `container_reap_empty(parent)` call, as the parent is already reaped in `container_set_floating` (if it was tiled) or it is NULL (if it was already floating). 2. Using the safe node lookup (`node_by_id`) to verify if the parent still exists before calling `arrange_container(parent)`. If it was destroyed, we fallback to arranging the workspace. Reproduction instructions: 1. Enable safety checks (node hash map) in `node.c`. 2. Start sway. 3. Open two windows in a split container (e.g. open one window, split it, open a second window). 4. Focus the first window and run `move scratchpad`. 5. Focus the second window and run `move scratchpad` (this makes the parent split container empty, triggering the reap). 6. The compositor will crash due to UAF in `arrange_container`, or abort with "Node not present in table" if safety checks are enabled. --- sway/tree/root.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/sway/tree/root.c b/sway/tree/root.c index b4cdd79f..464a7f17 100644 --- a/sway/tree/root.c +++ b/sway/tree/root.c @@ -106,6 +106,9 @@ void root_scratchpad_add_container(struct sway_container *con, struct sway_works struct sway_container *parent = con->pending.parent; struct sway_workspace *workspace = con->pending.workspace; + size_t parent_id = parent ? parent->node.id : 0; + bool was_tiled = !container_is_floating(con); + set_container_transform(workspace, con); // Clear the fullscreen mode when sending to the scratchpad @@ -114,13 +117,22 @@ void root_scratchpad_add_container(struct sway_container *con, struct sway_works } // When a tiled window is sent to scratchpad, center and resize it. - if (!container_is_floating(con)) { + if (was_tiled) { container_set_floating(con, true); container_floating_set_default_size(con); container_floating_move_to_center(con); } container_detach(con); + // parent might have been reaped and destroyed in container_set_floating. + // Verify it still exists before using it. + parent = NULL; + if (parent_id) { + struct sway_node *node = node_by_id(parent_id); + if (node && node->type == N_CONTAINER) { + parent = node->sway_container; + } + } con->scratchpad = true; list_add(root->scratchpad, con); if (ws) { From 84eac14df8c6fa87f257b16c94d7198553a05704 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:21:27 -0700 Subject: [PATCH 06/26] Fix use-after-free of focused_inactive_child in container When a container is detached from its parent, we must clear it from the parent's pending focused_inactive_child pointer if it was pointing to it. Otherwise, the parent's pending focused_inactive_child remains dangling and can lead to use-after-free when container_get_active_view is called. The transaction model keeps current.focused_inactive_child safe because it is rebuilt from seat focus stack during transaction commits. However, pending.focused_inactive_child is maintained manually and was not being cleared on detach. --- sway/tree/container.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sway/tree/container.c b/sway/tree/container.c index 36c01d58..01f6e123 100644 --- a/sway/tree/container.c +++ b/sway/tree/container.c @@ -1513,6 +1513,10 @@ void container_detach(struct sway_container *child) { struct sway_container *old_parent = child->pending.parent; struct sway_workspace *old_workspace = child->pending.workspace; + + if (old_parent && old_parent->pending.focused_inactive_child == child) { + old_parent->pending.focused_inactive_child = NULL; + } list_t *siblings = container_get_siblings(child); if (siblings) { int index = list_find(siblings, child); From bb9e0a5509fd75a4c5ce21fc6345c6a99e8c825b Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 16:39:30 -0700 Subject: [PATCH 07/26] Fix memory leaks in swaybar and compositor exit This commit fixes several memory leaks in swaybar and sway compositor on exit. Swaybar fixes: - Call `pango_cairo_font_map_set_default(NULL)`, `cairo_debug_reset_static_data()`, and `FcFini()` on exit to release Pango/Cairo/Fontconfig static caches. - Free the DBus signal match slots and object vtable slots in watcher and host instead of leaving them floating, which pinned the bus and leaked memory. - Free the async property call slots when they complete. - Free the parsed JSON header in `i3bar_handle_readable` when it is invalid or after parsing it. Compositor exit fixes: - Free the pango font description in `free_config`. - Call `cairo_debug_reset_static_data()` and `FcFini()` in `sway/main.c` on exit. - Added fontconfig dependency to sway target. Reproduction (swaybar): 1. Run `swaybar` under LSan/ASan. 2. Feed it status lines with invalid/unsupported i3bar protocol JSON headers, or exit swaybar. 3. LSan will report leaks of parsed JSON headers in `status_line.c` and systemd/dbus match slots in `tray/`. Pango and Cairo static caches also leak. Reproduction (compositor): 1. Run sway under LSan. 2. Exit sway. 3. LSan will report leaks in pango (font description) and fontconfig (static data). --- include/swaybar/tray/host.h | 11 +++++ include/swaybar/tray/watcher.h | 2 + meson.build | 1 + sway/commands/include.c | 1 + sway/config.c | 1 + sway/main.c | 3 ++ sway/meson.build | 1 + swaybar/main.c | 6 +++ swaybar/meson.build | 1 + swaybar/status_line.c | 80 +++++++++++++++++----------------- swaybar/tray/host.c | 20 ++++----- swaybar/tray/item.c | 1 + swaybar/tray/watcher.c | 14 +++--- 13 files changed, 84 insertions(+), 58 deletions(-) diff --git a/include/swaybar/tray/host.h b/include/swaybar/tray/host.h index 2d4cf82b..fa6e22c2 100644 --- a/include/swaybar/tray/host.h +++ b/include/swaybar/tray/host.h @@ -2,6 +2,14 @@ #define _SWAYBAR_TRAY_HOST_H #include +#include "config.h" +#if HAVE_LIBSYSTEMD +#include +#elif HAVE_LIBELOGIND +#include +#elif HAVE_BASU +#include +#endif struct swaybar_tray; @@ -9,6 +17,9 @@ struct swaybar_host { struct swaybar_tray *tray; char *service; char *watcher_interface; + sd_bus_slot *reg_slot; + sd_bus_slot *unreg_slot; + sd_bus_slot *watcher_slot; }; bool init_host(struct swaybar_host *host, char *protocol, struct swaybar_tray *tray); diff --git a/include/swaybar/tray/watcher.h b/include/swaybar/tray/watcher.h index 8f276da8..50393c90 100644 --- a/include/swaybar/tray/watcher.h +++ b/include/swaybar/tray/watcher.h @@ -10,6 +10,8 @@ struct swaybar_watcher { list_t *hosts; list_t *items; int version; + sd_bus_slot *vtable_slot; + sd_bus_slot *signal_slot; }; struct swaybar_watcher *create_watcher(char *protocol, sd_bus *bus); diff --git a/meson.build b/meson.build index 974a4071..5b573ca7 100644 --- a/meson.build +++ b/meson.build @@ -68,6 +68,7 @@ wayland_cursor = dependency('wayland-cursor') wayland_protos = dependency('wayland-protocols', version: '>=1.41', default_options: ['tests=false']) xkbcommon = dependency('xkbcommon', version: '>=1.5.0') cairo = dependency('cairo') +fontconfig = dependency('fontconfig') pango = dependency('pango') pangocairo = dependency('pangocairo') gdk_pixbuf = dependency('gdk-pixbuf-2.0', required: get_option('gdk-pixbuf')) diff --git a/sway/commands/include.c b/sway/commands/include.c index e0d0c064..94c42d12 100644 --- a/sway/commands/include.c +++ b/sway/commands/include.c @@ -9,6 +9,7 @@ struct cmd_results *cmd_include(int argc, char **argv) { char *files = join_args(argv, argc); // We don't care if the included config(s) fails to load. load_include_configs(files, config, &config->swaynag_config_errors); + free(files); return cmd_results_new(CMD_SUCCESS, NULL); } diff --git a/sway/config.c b/sway/config.c index d579022d..81d5e58b 100644 --- a/sway/config.c +++ b/sway/config.c @@ -174,6 +174,7 @@ void free_config(struct sway_config *config) { free(config->floating_scroll_left_cmd); free(config->floating_scroll_right_cmd); free(config->font); + pango_font_description_free(config->font_description); free(config->swaybg_command); free(config->swaynag_command); free((char *)config->current_config_path); diff --git a/sway/main.c b/sway/main.c index a4c53aa0..e700a11c 100644 --- a/sway/main.c +++ b/sway/main.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -412,6 +413,8 @@ int main(int argc, char **argv) { } pango_cairo_font_map_set_default(NULL); + cairo_debug_reset_static_data(); + FcFini(); return exit_value; } diff --git a/sway/meson.build b/sway/meson.build index cb03a4d2..e62db4ec 100644 --- a/sway/meson.build +++ b/sway/meson.build @@ -221,6 +221,7 @@ sway_sources = files( sway_deps = [ cairo, drm, + fontconfig, jsonc, libevdev, libinput, diff --git a/swaybar/main.c b/swaybar/main.c index e1b0ceca..7ec049d4 100644 --- a/swaybar/main.c +++ b/swaybar/main.c @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include #include "swaybar/bar.h" #include "ipc-client.h" #include "log.h" @@ -100,5 +103,8 @@ int main(int argc, char **argv) { swaybar.running = true; bar_run(&swaybar); bar_teardown(&swaybar); + pango_cairo_font_map_set_default(NULL); + cairo_debug_reset_static_data(); + FcFini(); return 0; } diff --git a/swaybar/meson.build b/swaybar/meson.build index 34bbdeea..f3ef54f0 100644 --- a/swaybar/meson.build +++ b/swaybar/meson.build @@ -8,6 +8,7 @@ tray_files = have_tray ? [ swaybar_deps = [ cairo, + fontconfig, gdk_pixbuf, jsonc, math, diff --git a/swaybar/status_line.c b/swaybar/status_line.c index e542e606..0e7ca6ed 100644 --- a/swaybar/status_line.c +++ b/swaybar/status_line.c @@ -65,50 +65,52 @@ bool status_handle_readable(struct status_line *status) { // the header must be sent completely the first time round char *newline = strchr(status->buffer, '\n'); - json_object *header, *version; - if (newline != NULL - && (header = json_tokener_parse(status->buffer)) - && json_object_object_get_ex(header, "version", &version) - && json_object_get_int(version) == 1) { - sway_log(SWAY_DEBUG, "Using i3bar protocol."); - status->protocol = PROTOCOL_I3BAR; - - json_object *click_events; - if (json_object_object_get_ex(header, "click_events", &click_events) - && json_object_get_boolean(click_events)) { - sway_log(SWAY_DEBUG, "Enabling click events."); - status->click_events = true; - if (write(status->write_fd, "[\n", 2) != 2) { - status_error(status, "[failed to write to status command]"); - json_object_put(header); - return true; + json_object *header = NULL, *version; + if (newline != NULL && (header = json_tokener_parse(status->buffer))) { + if (json_object_object_get_ex(header, "version", &version) + && json_object_get_int(version) == 1) { + sway_log(SWAY_DEBUG, "Using i3bar protocol."); + status->protocol = PROTOCOL_I3BAR; + + json_object *click_events; + if (json_object_object_get_ex(header, "click_events", &click_events) + && json_object_get_boolean(click_events)) { + sway_log(SWAY_DEBUG, "Enabling click events."); + status->click_events = true; + if (write(status->write_fd, "[\n", 2) != 2) { + status_error(status, "[failed to write to status command]"); + json_object_put(header); + return true; + } } - } - json_object *float_event_coords; - if (json_object_object_get_ex(header, "float_event_coords", &float_event_coords) - && json_object_get_boolean(float_event_coords)) { - sway_log(SWAY_DEBUG, "Enabling floating-point coordinates."); - status->float_event_coords = true; - } + json_object *float_event_coords; + if (json_object_object_get_ex(header, "float_event_coords", &float_event_coords) + && json_object_get_boolean(float_event_coords)) { + sway_log(SWAY_DEBUG, "Enabling floating-point coordinates."); + status->float_event_coords = true; + } - json_object *signal; - if (json_object_object_get_ex(header, "stop_signal", &signal)) { - status->stop_signal = json_object_get_int(signal); - sway_log(SWAY_DEBUG, "Setting stop signal to %d", status->stop_signal); - } - if (json_object_object_get_ex(header, "cont_signal", &signal)) { - status->cont_signal = json_object_get_int(signal); - sway_log(SWAY_DEBUG, "Setting cont signal to %d", status->cont_signal); - } + json_object *signal; + if (json_object_object_get_ex(header, "stop_signal", &signal)) { + status->stop_signal = json_object_get_int(signal); + sway_log(SWAY_DEBUG, "Setting stop signal to %d", status->stop_signal); + } + if (json_object_object_get_ex(header, "cont_signal", &signal)) { + status->cont_signal = json_object_get_int(signal); + sway_log(SWAY_DEBUG, "Setting cont signal to %d", status->cont_signal); + } - json_object_put(header); + json_object_put(header); - wl_list_init(&status->blocks); - status->tokener = json_tokener_new(); - status->buffer_index = strlen(newline + 1); - memmove(status->buffer, newline + 1, status->buffer_index + 1); - return i3bar_handle_readable(status); + wl_list_init(&status->blocks); + status->tokener = json_tokener_new(); + status->buffer_index = strlen(newline + 1); + memmove(status->buffer, newline + 1, status->buffer_index + 1); + return i3bar_handle_readable(status); + } else { + json_object_put(header); + } } sway_log(SWAY_DEBUG, "Using text protocol."); diff --git a/swaybar/tray/host.c b/swaybar/tray/host.c index 79b54606..498d5272 100644 --- a/swaybar/tray/host.c +++ b/swaybar/tray/host.c @@ -143,8 +143,7 @@ bool init_host(struct swaybar_host *host, char *protocol, return false; } - sd_bus_slot *reg_slot = NULL, *unreg_slot = NULL, *watcher_slot = NULL; - int ret = sd_bus_match_signal(tray->bus, ®_slot, host->watcher_interface, + int ret = sd_bus_match_signal(tray->bus, &host->reg_slot, host->watcher_interface, watcher_path, host->watcher_interface, "StatusNotifierItemRegistered", handle_sni_registered, tray); if (ret < 0) { @@ -152,7 +151,7 @@ bool init_host(struct swaybar_host *host, char *protocol, strerror(-ret)); goto error; } - ret = sd_bus_match_signal(tray->bus, &unreg_slot, host->watcher_interface, + ret = sd_bus_match_signal(tray->bus, &host->unreg_slot, host->watcher_interface, watcher_path, host->watcher_interface, "StatusNotifierItemUnregistered", handle_sni_unregistered, tray); if (ret < 0) { @@ -161,7 +160,7 @@ bool init_host(struct swaybar_host *host, char *protocol, goto error; } - ret = sd_bus_match_signal(tray->bus, &watcher_slot, "org.freedesktop.DBus", + ret = sd_bus_match_signal(tray->bus, &host->watcher_slot, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged", handle_new_watcher, host); if (ret < 0) { @@ -186,21 +185,20 @@ bool init_host(struct swaybar_host *host, char *protocol, goto error; } - sd_bus_slot_set_floating(reg_slot, 0); - sd_bus_slot_set_floating(unreg_slot, 0); - sd_bus_slot_set_floating(watcher_slot, 0); - sway_log(SWAY_DEBUG, "Registered %s", host->service); return true; error: - sd_bus_slot_unref(reg_slot); - sd_bus_slot_unref(unreg_slot); - sd_bus_slot_unref(watcher_slot); + sd_bus_slot_unref(host->reg_slot); + sd_bus_slot_unref(host->unreg_slot); + sd_bus_slot_unref(host->watcher_slot); finish_host(host); return false; } void finish_host(struct swaybar_host *host) { + sd_bus_slot_unref(host->reg_slot); + sd_bus_slot_unref(host->unreg_slot); + sd_bus_slot_unref(host->watcher_slot); sd_bus_release_name(host->tray->bus, host->service); free(host->service); free(host->watcher_interface); diff --git a/swaybar/tray/item.c b/swaybar/tray/item.c index 12929743..05b0d95e 100644 --- a/swaybar/tray/item.c +++ b/swaybar/tray/item.c @@ -167,6 +167,7 @@ static int get_property_callback(sd_bus_message *msg, void *data, } cleanup: wl_list_remove(&d->link); + sd_bus_slot_unref(d->slot); free(data); return ret; } diff --git a/swaybar/tray/watcher.c b/swaybar/tray/watcher.c index 28496403..13d5dc4d 100644 --- a/swaybar/tray/watcher.c +++ b/swaybar/tray/watcher.c @@ -171,15 +171,14 @@ struct swaybar_watcher *create_watcher(char *protocol, sd_bus *bus) { watcher->interface = format_str("org.%s.StatusNotifierWatcher", protocol); - sd_bus_slot *signal_slot = NULL, *vtable_slot = NULL; - int ret = sd_bus_add_object_vtable(bus, &vtable_slot, obj_path, + int ret = sd_bus_add_object_vtable(bus, &watcher->vtable_slot, obj_path, watcher->interface, watcher_vtable, watcher); if (ret < 0) { sway_log(SWAY_ERROR, "Failed to add object vtable: %s", strerror(-ret)); goto error; } - ret = sd_bus_match_signal(bus, &signal_slot, "org.freedesktop.DBus", + ret = sd_bus_match_signal(bus, &watcher->signal_slot, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged", handle_lost_service, watcher); if (ret < 0) { @@ -200,9 +199,6 @@ struct swaybar_watcher *create_watcher(char *protocol, sd_bus *bus) { goto error; } - sd_bus_slot_set_floating(signal_slot, 0); - sd_bus_slot_set_floating(vtable_slot, 0); - watcher->bus = bus; watcher->hosts = create_list(); watcher->items = create_list(); @@ -210,8 +206,8 @@ struct swaybar_watcher *create_watcher(char *protocol, sd_bus *bus) { sway_log(SWAY_DEBUG, "Registered %s", watcher->interface); return watcher; error: - sd_bus_slot_unref(signal_slot); - sd_bus_slot_unref(vtable_slot); + sd_bus_slot_unref(watcher->signal_slot); + sd_bus_slot_unref(watcher->vtable_slot); destroy_watcher(watcher); return NULL; } @@ -220,6 +216,8 @@ void destroy_watcher(struct swaybar_watcher *watcher) { if (!watcher) { return; } + sd_bus_slot_unref(watcher->signal_slot); + sd_bus_slot_unref(watcher->vtable_slot); list_free_items_and_destroy(watcher->hosts); list_free_items_and_destroy(watcher->items); free(watcher->interface); From da91a4275a7e96137c8e111805fa02de1c324381 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 16:39:21 -0700 Subject: [PATCH 08/26] Fix use-after-free in transactions and node destruction on shutdown This commit resolves a use-after-free (UAF) crash that can occur during compositor shutdown when there are still active client windows. - Added check in `transaction_destroy` to verify if the node is still valid before accessing it, specifically handling cases where nodes are destroyed during shutdown before pending transactions are finished. - Ensure that nodes with active transaction references (`node->ntxnrefs > 0`) are not immediately freed if they are accessed by pending transactions. - Introduced `transaction_shut_down` to clean up pending/queued transactions and clear dirty flags on shutdown. - Implemented recursive node destruction in `root_destroy` (`container_destroy_recursive`, `workspace_destroy_recursive`, `output_destroy_recursive`) to ensure all nodes are properly marked as destroying and their transaction references are cleared. - Properly destroy input manager and seats on compositor teardown. Reproduction: 1. Build sway with ASan enabled (`-Db_sanitize=address`). 2. Open multiple clients (e.g., two terminals). 3. Exit/kill the compositor. 4. ASan will report a use-after-free (UAF) error. This occurs because during shutdown, nodes (containers/workspaces) are destroyed while they still have pending transaction references (`node->ntxnrefs > 0`). The transaction destruction attempts to access these already-freed nodes. Alternatively, run `pytest tests/test_leak_two_clients.py` which specifically reproduces this UAF on shutdown. --- include/sway/desktop/transaction.h | 2 ++ include/sway/input/input-manager.h | 1 + include/sway/tree/node.h | 1 + sway/desktop/transaction.c | 40 +++++++++++++++++++++++ sway/input/input-manager.c | 11 +++++++ sway/input/seat.c | 2 ++ sway/main.c | 13 ++++++-- sway/server.c | 9 ++++++ sway/tree/container.c | 1 + sway/tree/node.c | 11 +++++++ sway/tree/output.c | 1 + sway/tree/root.c | 51 ++++++++++++++++++++++++++++++ sway/tree/workspace.c | 1 + 13 files changed, 142 insertions(+), 2 deletions(-) diff --git a/include/sway/desktop/transaction.h b/include/sway/desktop/transaction.h index dd7edb7a..c4f46b7b 100644 --- a/include/sway/desktop/transaction.h +++ b/include/sway/desktop/transaction.h @@ -23,6 +23,7 @@ struct sway_transaction_instruction; struct sway_view; +struct sway_server; /** * Find all dirty containers, create and commit a transaction containing them, @@ -61,4 +62,5 @@ bool transaction_notify_view_ready_by_geometry(struct sway_view *view, void arrange_popups(struct wlr_scene_tree *popups); +void transaction_shut_down(struct sway_server *server); #endif diff --git a/include/sway/input/input-manager.h b/include/sway/input/input-manager.h index 5113844d..47af5795 100644 --- a/include/sway/input/input-manager.h +++ b/include/sway/input/input-manager.h @@ -40,6 +40,7 @@ struct sway_input_manager { struct sway_input_manager *input_manager_create(struct sway_server *server); void input_manager_finish(struct sway_input_manager *input); +void input_manager_destroy(struct sway_input_manager *input); bool input_manager_has_focus(struct sway_node *node); diff --git a/include/sway/tree/node.h b/include/sway/tree/node.h index a5452865..db0b2e71 100644 --- a/include/sway/tree/node.h +++ b/include/sway/tree/node.h @@ -59,6 +59,7 @@ const char *node_type_to_str(enum sway_node_type type); * next transaction then unmarked as dirty. */ void node_set_dirty(struct sway_node *node); +void node_clear_dirty(struct sway_node *node); bool node_is_view(struct sway_node *node); diff --git a/sway/desktop/transaction.c b/sway/desktop/transaction.c index 325a3022..ab47bee7 100644 --- a/sway/desktop/transaction.c +++ b/sway/desktop/transaction.c @@ -967,3 +967,43 @@ void transaction_commit_dirty(void) { void transaction_commit_dirty_client(void) { _transaction_commit_dirty(false); } + +void transaction_shut_down(struct sway_server *server) { + if (server->dirty_nodes) { + for (int i = 0; i < server->dirty_nodes->length; ++i) { + struct sway_node *node = server->dirty_nodes->items[i]; + node->dirty = false; + } + } + + if (server->queued_transaction) { + transaction_destroy(server->queued_transaction); + server->queued_transaction = NULL; + } + + if (server->pending_transaction) { + transaction_destroy(server->pending_transaction); + server->pending_transaction = NULL; + } + + if (server->dirty_nodes) { + for (int i = server->dirty_nodes->length - 1; i >= 0; --i) { + struct sway_node *node = server->dirty_nodes->items[i]; + if (node->destroying && node->ntxnrefs == 0) { + switch (node->type) { + case N_OUTPUT: + output_destroy(node->sway_output); + break; + case N_WORKSPACE: + workspace_destroy(node->sway_workspace); + break; + case N_CONTAINER: + container_destroy(node->sway_container); + break; + default: + break; + } + } + } + } +} diff --git a/sway/input/input-manager.c b/sway/input/input-manager.c index ffcf8fc5..bfb2c1b8 100644 --- a/sway/input/input-manager.c +++ b/sway/input/input-manager.c @@ -501,6 +501,17 @@ void input_manager_finish(struct sway_input_manager *input) { wl_list_remove(&input->transient_seat_create.link); } +void input_manager_destroy(struct sway_input_manager *input) { + if (!input) { + return; + } + struct sway_seat *seat, *next_seat; + wl_list_for_each_safe(seat, next_seat, &input->seats, link) { + seat_destroy(seat); + } + free(input); +} + bool input_manager_has_focus(struct sway_node *node) { struct sway_seat *seat = NULL; wl_list_for_each(seat, &server.input->seats, link) { diff --git a/sway/input/seat.c b/sway/input/seat.c index ab31b674..9fd91aec 100644 --- a/sway/input/seat.c +++ b/sway/input/seat.c @@ -73,6 +73,8 @@ void seat_destroy(struct sway_seat *seat) { static void handle_seat_destroy(struct wl_listener *listener, void *data) { struct sway_seat *seat = wl_container_of(listener, seat, destroy); + seatop_end(seat); + if (seat == config->handler_context.seat) { config->handler_context.seat = input_manager_get_default_seat(); } diff --git a/sway/main.c b/sway/main.c index e700a11c..03985c2c 100644 --- a/sway/main.c +++ b/sway/main.c @@ -31,6 +31,8 @@ static bool terminate_request = false; static int exit_value = 0; static struct rlimit original_nofile_rlimit = {0}; +static struct wl_event_source *sigterm_source = NULL; +static struct wl_event_source *sigint_source = NULL; struct sway_server server = {0}; struct sway_debug debug = {0}; @@ -156,8 +158,8 @@ static void restore_signals(void) { } static void init_signals(void) { - wl_event_loop_add_signal(server.wl_event_loop, SIGTERM, term_signal, NULL); - wl_event_loop_add_signal(server.wl_event_loop, SIGINT, term_signal, NULL); + sigterm_source = wl_event_loop_add_signal(server.wl_event_loop, SIGTERM, term_signal, NULL); + sigint_source = wl_event_loop_add_signal(server.wl_event_loop, SIGINT, term_signal, NULL); struct sigaction sa_ign = { .sa_handler = SIG_IGN }; // avoid need to reap children @@ -400,6 +402,13 @@ int main(int argc, char **argv) { shutdown: sway_log(SWAY_INFO, "Shutting down sway"); + if (sigterm_source) { + wl_event_source_remove(sigterm_source); + } + if (sigint_source) { + wl_event_source_remove(sigint_source); + } + server_fini(&server); root_destroy(root); root = NULL; diff --git a/sway/server.c b/sway/server.c index f8124dac..42de179c 100644 --- a/sway/server.c +++ b/sway/server.c @@ -64,6 +64,7 @@ #include "sway/input/cursor.h" #include "sway/tree/root.h" #include "sway/tree/node.h" +#include "sway/desktop/transaction.h" #if WLR_HAS_XWAYLAND #include @@ -557,8 +558,16 @@ void server_fini(struct sway_server *server) { #endif wl_display_destroy_clients(server->wl_display); wlr_backend_destroy(server->backend); + transaction_shut_down(server); + input_manager_destroy(server->input); + server->input = NULL; + wlr_allocator_destroy(server->allocator); + server->allocator = NULL; + wlr_renderer_destroy(server->renderer); + server->renderer = NULL; wl_display_destroy(server->wl_display); list_free(server->dirty_nodes); + server->dirty_nodes = NULL; free(server->socket); } diff --git a/sway/tree/container.c b/sway/tree/container.c index 01f6e123..0a4a6565 100644 --- a/sway/tree/container.c +++ b/sway/tree/container.c @@ -512,6 +512,7 @@ void container_destroy(struct sway_container *con) { "which is still referenced by transactions")) { return; } + node_clear_dirty(&con->node); free(con->title); free(con->formatted_title); free(con->title_format); diff --git a/sway/tree/node.c b/sway/tree/node.c index 16358db7..9bf065c1 100644 --- a/sway/tree/node.c +++ b/sway/tree/node.c @@ -77,6 +77,7 @@ static void node_map_remove(struct sway_node *node) { void node_fini(struct sway_node *node) { node_map_remove(node); + node_clear_dirty(node); wl_signal_emit_mutable(&node->events.destroy, node); } @@ -114,6 +115,16 @@ void node_set_dirty(struct sway_node *node) { list_add(server.dirty_nodes, node); } +void node_clear_dirty(struct sway_node *node) { + node->dirty = false; + if (server.dirty_nodes) { + int index = list_find(server.dirty_nodes, node); + if (index != -1) { + list_del(server.dirty_nodes, index); + } + } +} + bool node_is_view(struct sway_node *node) { return node->type == N_CONTAINER && node->sway_container->view; } diff --git a/sway/tree/output.c b/sway/tree/output.c index 1c69b7c8..3265c8b0 100644 --- a/sway/tree/output.c +++ b/sway/tree/output.c @@ -265,6 +265,7 @@ void output_destroy(struct sway_output *output) { "which is still referenced by transactions")) { return; } + node_clear_dirty(&output->node); destroy_scene_layers(output); list_free(output->workspaces); diff --git a/sway/tree/root.c b/sway/tree/root.c index 464a7f17..c40b5732 100644 --- a/sway/tree/root.c +++ b/sway/tree/root.c @@ -79,8 +79,59 @@ struct sway_root *root_create(struct wl_display *wl_display) { return root; } +static void container_destroy_recursive(struct sway_container *con) { + if (con->pending.children) { + for (int i = 0; i < con->pending.children->length; ++i) { + container_destroy_recursive(con->pending.children->items[i]); + } + } + con->node.destroying = true; + con->node.ntxnrefs = 0; + container_destroy(con); +} + +static void workspace_destroy_recursive(struct sway_workspace *ws) { + if (ws->tiling) { + for (int i = 0; i < ws->tiling->length; ++i) { + container_destroy_recursive(ws->tiling->items[i]); + } + } + if (ws->floating) { + for (int i = 0; i < ws->floating->length; ++i) { + container_destroy_recursive(ws->floating->items[i]); + } + } + ws->node.destroying = true; + ws->node.ntxnrefs = 0; + workspace_destroy(ws); +} + +static void output_destroy_recursive(struct sway_output *output) { + if (output->workspaces) { + for (int i = 0; i < output->workspaces->length; ++i) { + workspace_destroy_recursive(output->workspaces->items[i]); + } + } + output->node.destroying = true; + output->node.ntxnrefs = 0; + output->wlr_output = NULL; + output_destroy(output); +} + void root_destroy(struct sway_root *root) { + if (root->scratchpad) { + for (int i = 0; i < root->scratchpad->length; ++i) { + container_destroy_recursive(root->scratchpad->items[i]); + } + } list_free(root->scratchpad); + + if (root->fallback_output) { + root->fallback_output->wlr_output = NULL; + root->fallback_output->node.destroying = true; + output_destroy_recursive(root->fallback_output); + } + list_free(root->non_desktop_outputs); list_free(root->outputs); wlr_scene_node_destroy(&root->root_scene->tree.node); diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index 2ea915ea..d40f6eca 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -142,6 +142,7 @@ void workspace_destroy(struct sway_workspace *workspace) { "which is still referenced by transactions")) { return; } + node_clear_dirty(&workspace->node); scene_node_disown_children(workspace->layers.tiling); scene_node_disown_children(workspace->layers.fullscreen); From f4ea1aa7ea5fe79b783c6b3be2fb9f34a6de619b Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 16:39:10 -0700 Subject: [PATCH 09/26] Fix memory leaks in transactions This commit resolves memory leaks of workspaces, containers, and outputs state lists during transaction application and destruction. - Instead of deep copying, we transfer ownership of the lists from the transaction instructions to the node's current state during `transaction_apply` (setting the instruction's list pointers to NULL). - Updated `transaction_destroy` to safely free the private lists associated with transaction instructions (which will only be non-NULL if the transaction was not applied). Reproduction: 1. Build sway with ASan/LSan enabled (`-Db_sanitize=address`). 2. Run sway with a status bar (like swaybar) and some clients. 3. Perform actions that trigger transactions (e.g., open and close windows, change focus). 4. Exit sway. 5. LSan will report memory leaks of `list_t` structures (and their item arrays) allocated in `copy_list` or transaction state initialization, specifically for `workspaces` (in `apply_output_state`), `floating`/`tiling` (in `apply_workspace_state`), and `children` (in `apply_container_state`). Alternatively, run the integration test `pytest tests/test_leak_two_clients.py` under ASan/LSan. --- sway/desktop/transaction.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sway/desktop/transaction.c b/sway/desktop/transaction.c index ab47bee7..69851903 100644 --- a/sway/desktop/transaction.c +++ b/sway/desktop/transaction.c @@ -55,6 +55,20 @@ static void transaction_destroy(struct sway_transaction *transaction) { struct sway_transaction_instruction *instruction = transaction->instructions->items[i]; struct sway_node *node = instruction->node; + switch (node->type) { + case N_OUTPUT: + list_free(instruction->output_state.workspaces); + break; + case N_WORKSPACE: + list_free(instruction->workspace_state.floating); + list_free(instruction->workspace_state.tiling); + break; + case N_CONTAINER: + list_free(instruction->container_state.children); + break; + default: + break; + } node->ntxnrefs--; if (node->instruction == instruction) { node->instruction = NULL; @@ -216,6 +230,7 @@ static void apply_output_state(struct sway_output *output, struct sway_output_state *state) { list_free(output->current.workspaces); memcpy(&output->current, state, sizeof(struct sway_output_state)); + state->workspaces = NULL; } static void apply_workspace_state(struct sway_workspace *ws, @@ -223,6 +238,8 @@ static void apply_workspace_state(struct sway_workspace *ws, list_free(ws->current.floating); list_free(ws->current.tiling); memcpy(&ws->current, state, sizeof(struct sway_workspace_state)); + state->floating = NULL; + state->tiling = NULL; } static void apply_container_state(struct sway_container *container, @@ -236,6 +253,7 @@ static void apply_container_state(struct sway_container *container, list_free(container->current.children); memcpy(&container->current, state, sizeof(struct sway_container_state)); + state->children = NULL; if (view) { if (view->saved_surface_tree) { From b6c8306f1c7bd96b44354f61de8b299b57eeabdf Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 13:55:21 -0700 Subject: [PATCH 10/26] lua: use node IDs instead of light userdata pointers for safety Passing raw C pointers (light userdata) to Lua scripts is unsafe because if the underlying C object (workspace, container, view) is destroyed, the Lua script will hold a dangling pointer. Using this pointer in subsequent API calls will crash the compositor. We fix this by using node IDs (size_t) to reference nodes in Lua, and looking them up safely using node_by_id(). Reproduction: 1. Register a Lua callback that receives a container pointer. 2. Store this pointer in a global Lua variable. 3. Destroy the container. 4. Call a Lua API function passing the stored pointer. 5. The compositor will crash due to invalid pointer dereference. Alternatively, run the integration test 'pytest tests/test_lua_safety.py'. --- scroll.lua | 156 ++++++++--------- sway/lua.c | 433 ++++++++++++++++++++-------------------------- sway/scroll.5.scd | 29 ++-- 3 files changed, 283 insertions(+), 335 deletions(-) diff --git a/scroll.lua b/scroll.lua index efe2137c..1db7849f 100644 --- a/scroll.lua +++ b/scroll.lua @@ -49,56 +49,56 @@ function scroll.ipc_send(id, data) end --- --- Returns all the results/errors in an array. --- ---- @param context userdata|nil +--- @param context integer|nil --- @param command string --- --- @return string[] function scroll.command(context, command) end --- ---- Returns the focused view pointer or nil if none. +--- Returns the focused view ID or nil if none. --- ---- @return userdata|nil +--- @return integer|nil function scroll.focused_view() end --- ---- Returns the focused container pointer or nil if none. +--- Returns the focused container ID or nil if none. --- ---- @return userdata|nil +--- @return integer|nil function scroll.focused_container() end --- ---- Returns the focused workspace pointer or nil if none. +--- Returns the focused workspace ID or nil if none. --- ---- @return userdata|nil +--- @return integer|nil function scroll.focused_workspace() end --- ---- Returns the current urgent view pointer or nil if none. +--- Returns the current urgent view ID or nil if none. --- ---- @return userdata|nil +--- @return integer|nil function scroll.urgent_view() end --- --- Returns true if view is mapped (exists), otherwise returns false --- ---- @param view userdata +--- @param view integer --- --- @return boolean function scroll.view_mapped(view) end --- ---- Returns a pointer to the container associated to view, or nil if none. +--- Returns the container ID associated to view, or nil if none. --- ---- @param view userdata +--- @param view integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.view_get_container(view) end --- --- Returns the app_id string for view, or nil if any error happens --- ---- @param view userdata +--- @param view integer --- --- @return string|nil function scroll.view_get_app_id(view) end @@ -106,7 +106,7 @@ function scroll.view_get_app_id(view) end --- --- Returns the title string for view, or nil if any error happens --- ---- @param view userdata +--- @param view integer --- --- @return string|nil function scroll.view_get_title(view) end @@ -114,7 +114,7 @@ function scroll.view_get_title(view) end --- --- Returns the pid number for view, or nil if any error happens --- ---- @param view userdata +--- @param view integer --- --- @return integer|nil function scroll.view_get_pid(view) end @@ -123,15 +123,15 @@ function scroll.view_get_pid(view) end --- Returns the view whose pid is the parent of the application running --- in view, or nil if it has no parent view. --- ---- @param view userdata +--- @param view integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.view_get_parent_view(view) end --- --- If the view has the urgent attribute set, return true, otherwise false. --- ---- @param view userdata +--- @param view integer --- --- @return boolean function scroll.view_get_urgent(view) end @@ -139,7 +139,7 @@ function scroll.view_get_urgent(view) end --- --- Sets the view urgent attribute --- ---- @param view userdata +--- @param view integer --- @param urgent boolean --- --- @return integer @@ -149,7 +149,7 @@ function scroll.view_set_urgent(view, urgent) end --- Returns `xdg_shell` if the application running in view is a Wayland --- application. If it is an X windows application, returns `xwayland`. --- ---- @param view userdata +--- @param view integer --- --- @return string|nil function scroll.view_get_shell(view) end @@ -157,7 +157,7 @@ function scroll.view_get_shell(view) end --- --- Returns the tag string property for view, or nil if any error happens. --- ---- @param view userdata +--- @param view integer --- --- @return string|nil function scroll.view_get_tag(view) end @@ -165,7 +165,7 @@ function scroll.view_get_tag(view) end --- --- Close/kill view --- ---- @param view userdata +--- @param view integer --- --- @return number function scroll.view_close(view) end @@ -173,23 +173,23 @@ function scroll.view_close(view) end --- --- Sets the focus on container. --- ---- @param container userdata +--- @param container integer --- --- @return integer function scroll.container_set_focus(container) end --- ---- Returns the container's parent workspace pointer, or nil if none. +--- Returns the container's parent workspace ID, or nil if none. --- ---- @param container userdata +--- @param container integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.container_get_workspace(container) end --- --- Returns an array with all the marks associated to container. --- ---- @param container userdata +--- @param container integer --- --- @return string[] function scroll.container_get_marks(container) end @@ -197,7 +197,7 @@ function scroll.container_get_marks(container) end --- --- Returns true if the container is floating, false if it is tiled. --- ---- @param container userdata +--- @param container integer --- --- @return boolean function scroll.container_get_floating(container) end @@ -205,7 +205,7 @@ function scroll.container_get_floating(container) end --- --- Returns the numerical value for the container's opacity. --- ---- @param container userdata +--- @param container integer --- --- @return number function scroll.container_get_opacity(container) end @@ -213,7 +213,7 @@ function scroll.container_get_opacity(container) end --- --- Returns true if the container is sticky, otherwise false. --- ---- @param container userdata +--- @param container integer --- --- @return boolean function scroll.container_get_sticky(container) end @@ -221,7 +221,7 @@ function scroll.container_get_sticky(container) end --- --- Returns true if the container is in the scratchpad, otherwise false. --- ---- @param container userdata +--- @param container integer --- --- @return boolean function scroll.container_get_scratchpad(container) end @@ -230,7 +230,7 @@ function scroll.container_get_scratchpad(container) end --- Returns the value for the container's width fraction. --- This value is used to compute the width of the container. --- ---- @param container userdata +--- @param container integer --- --- @return number function scroll.container_get_width_fraction(container) end @@ -239,7 +239,7 @@ function scroll.container_get_width_fraction(container) end --- Returns the value for the container's height fraction. --- This value is used to compute the height of the container. --- ---- @param container userdata +--- @param container integer --- --- @return number function scroll.container_get_height_fraction(container) end @@ -247,7 +247,7 @@ function scroll.container_get_height_fraction(container) end --- --- Returns a floating point value with the container's width in pixels. --- ---- @param container userdata +--- @param container integer --- --- @return number function scroll.container_get_width(container) end @@ -255,7 +255,7 @@ function scroll.container_get_width(container) end --- --- Returns a floating point value with the container's height in pixels. --- ---- @param container userdata +--- @param container integer --- --- @return number function scroll.container_get_height(container) end @@ -265,7 +265,7 @@ function scroll.container_get_height(container) end --- Values can be `none`, `workspace` (covers only its workspace extents) --- or `global` (covers all outputs). --- ---- @param container userdata +--- @param container integer --- --- @return string function scroll.container_get_fullscreen_mode(container) end @@ -275,7 +275,7 @@ function scroll.container_get_fullscreen_mode(container) end --- Values can be `disabled` or `enabled`. --- See the `fullscreen application` command for details. --- ---- @param container userdata +--- @param container integer --- --- @return string function scroll.container_get_fullscreen_app_mode(container) end @@ -288,7 +288,7 @@ function scroll.container_get_fullscreen_app_mode(container) end --- but this state can still be "disabled", so the compositor knows the --- container should become non-full screen when the request ends. --- ---- @param container userdata +--- @param container integer --- --- @return string function scroll.container_get_fullscreen_view_mode(container) end @@ -298,7 +298,7 @@ function scroll.container_get_fullscreen_view_mode(container) end --- Values can be `disabled` or `enabled`. --- See the `fullscreen layout` command for details. --- ---- @param container userdata +--- @param container integer --- --- @return string function scroll.container_get_fullscreen_layout_mode(container) end @@ -308,7 +308,7 @@ function scroll.container_get_fullscreen_layout_mode(container) end --- Values can be `none`, `beginning` or `end`. --- See the pin command for details. --- ---- @param container userdata +--- @param container integer --- --- @return string function scroll.container_get_pin_mode(container) end @@ -317,9 +317,9 @@ function scroll.container_get_pin_mode(container) end --- Returns the container parent of container or nil if it is a top --- level container. --- ---- @param container userdata +--- @param container integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.container_get_parent(container) end --- @@ -327,9 +327,9 @@ function scroll.container_get_parent(container) end --- Only top level containers have children. Only bottom level containers --- have views. --- ---- @params container userdata +--- @params container integer --- ---- @return userdata[] +--- @return integer[] function scroll.container_get_children(container) end --- @@ -337,15 +337,15 @@ function scroll.container_get_children(container) end --- If a top level container, it will return the views of all its children, --- if a bottom level container, its only view. --- ---- @param container userdata +--- @param container integer --- ---- @return userdata[] +--- @return integer[] function scroll.container_get_views(container) end --- --- Returns an integer value with the unique container id, or nil if error. --- ---- @param container userdata +--- @param container integer --- --- @return integer|nil function scroll.container_get_id(container) end @@ -353,7 +353,7 @@ function scroll.container_get_id(container) end --- --- Sets the focus on the last active container of workspace. --- ---- @param workspace userdata +--- @param workspace integer --- --- @return integer function scroll.workspace_set_focus(workspace) end @@ -361,7 +361,7 @@ function scroll.workspace_set_focus(workspace) end --- --- Returns a string with the name of the workspace, or nil if error. --- ---- @param workspace userdata +--- @param workspace integer --- --- @return string|nil function scroll.workspace_get_name(workspace) end @@ -369,17 +369,17 @@ function scroll.workspace_get_name(workspace) end --- --- Returns an array with all the tiling containers inside the workspace. --- ---- @param workspace userdata +--- @param workspace integer --- ---- @return userdata[] +--- @return integer[] function scroll.workspace_get_tiling(workspace) end --- --- Returns an array with all the floating containers inside the workspace. --- ---- @param workspace userdata +--- @param workspace integer --- ---- @return userdata[] +--- @return integer[] function scroll.workspace_get_floating(workspace) end --- @@ -393,7 +393,7 @@ function scroll.workspace_get_floating(workspace) end --- center_horizontal: true|false --- center_vertical: true|false --- ---- @param workspace userdata +--- @param workspace integer --- --- @return table function scroll.workspace_get_mode(workspace) end @@ -410,7 +410,7 @@ function scroll.workspace_get_mode(workspace) end --- center_horizontal: true|false --- center_vertical: true|false --- ---- @param workspace userdata +--- @param workspace integer --- @param modifiers table --- --- @return integer @@ -419,7 +419,7 @@ function scroll.workspace_set_mode(workspace, modifiers) end --- --- Returns the layout type, `horizontal` or `vertical`. --- ---- @param workspace userdata +--- @param workspace integer --- --- @return string|nil function scroll.workspace_get_layout_type(workspace) end @@ -428,7 +428,7 @@ function scroll.workspace_get_layout_type(workspace) end --- Sets the workspace's layout type to layout_type, --- which can be `horizontal` or `vertical`. --- ---- @param workspace userdata +--- @param workspace integer --- @param layout_type string --- --- @return integer @@ -437,7 +437,7 @@ function scroll.workspace_set_layout_type(workspace, layout_type) end --- --- Returns an integer number with the workspace's width in pixels. --- ---- @param workspace userdata +--- @param workspace integer --- --- @return integer|nil function scroll.workspace_get_width(workspace) end @@ -445,17 +445,17 @@ function scroll.workspace_get_width(workspace) end --- --- Returns an integer number with the workspace's height in pixels. --- ---- @param workspace userdata +--- @param workspace integer --- --- @return integer|nil function scroll.workspace_get_height(workspace) end --- ---- Returns the workspace's pinned container pointer, or nil if none. +--- Returns the workspace's pinned container ID, or nil if none. --- ---- @param workspace userdata +--- @param workspace integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.workspace_get_pin(workspace) end --- @@ -465,25 +465,25 @@ function scroll.workspace_get_pin(workspace) end --- split: "none"|"top"|"bottom"|"left"|"right" --- fraction: number --- gap: integer ---- sibling: the sibling workspace's pointer +--- sibling: the sibling workspace's ID --- ---- @param workspace userdata +--- @param workspace integer --- --- @return table function scroll.workspace_get_split(workspace) end --- ---- Returns the workspace's output pointer, or nil if none. +--- Returns the workspace's output ID, or nil if none. --- ---- @param workspace userdata +--- @param workspace integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.workspace_get_output(workspace) end --- --- Returns true if the output (display) is enabled, or false otherwise. --- ---- @param output userdata +--- @param output integer --- --- @return boolean function scroll.output_get_enabled(output) end @@ -491,7 +491,7 @@ function scroll.output_get_enabled(output) end --- --- Returns the name of the output's interface. For example 'DP-3'. --- ---- @param output userdata +--- @param output integer --- --- @return string|nil function scroll.output_get_name(output) end @@ -499,35 +499,35 @@ function scroll.output_get_name(output) end --- --- Returns the workspace currently active on output. --- ---- @param output userdata +--- @param output integer --- ---- @return userdata|nil +--- @return integer|nil function scroll.output_get_active_workspace(output) end --- --- Returns an array with all the existing workspaces assigned to output. --- ---- @praam output userdata +--- @praam output integer --- ---- @return userdata[] +--- @return integer[] function scroll.output_get_workspaces(output) end --- --- Returns an array with all the outputs (displays). --- ---- @return userdata[] +--- @return integer[] function scroll.root_get_outputs() end --- --- Returns an array with all the containers in the scratchpad. --- ---- @return userdata[] +--- @return integer[] function scroll.scratchpad_get_containers() end --- --- Shows container if it is in the scratchpad. --- ---- @param container userdata +--- @param container integer --- --- @return integer function scroll.scratchpad_show(container) end @@ -535,7 +535,7 @@ function scroll.scratchpad_show(container) end --- --- Hide container if it is in the scratchpad. --- ---- @param container userdata +--- @param container integer --- --- @return integer function scroll.scratchpad_hide(container) end diff --git a/sway/lua.c b/sway/lua.c index e4be414d..4c3fcb3c 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -7,6 +7,7 @@ #include "sway/tree/view.h" #include "sway/tree/container.h" #include "sway/tree/workspace.h" +#include "sway/tree/node.h" #include "sway/output.h" #include "sway/ipc-server.h" @@ -201,15 +202,6 @@ static int scroll_ipc_send(lua_State *L) { return 0; } -static bool find_container(struct sway_container *container, void *data) { - struct sway_container *con = data; - return container == con; -} - -static bool find_workspace(struct sway_workspace *workspace, void *data) { - struct sway_workspace *ws = data; - return workspace == ws; -} static int scroll_command_error(lua_State *L, const char *error) { lua_createtable(L, 1, 0); @@ -218,6 +210,47 @@ static int scroll_command_error(lua_State *L, const char *error) { return 1; } +static struct sway_node *lua_to_node(lua_State *L, int index) { + if (lua_isnil(L, index)) { + return NULL; + } + if (!lua_isinteger(L, index)) { + return NULL; + } + size_t id = lua_tointeger(L, index); + return node_by_id(id); +} + +static struct sway_container *lua_to_container(lua_State *L, int index) { + struct sway_node *node = lua_to_node(L, index); + return (node && node->type == N_CONTAINER) ? node->sway_container : NULL; +} + +static struct sway_workspace *lua_to_workspace(lua_State *L, int index) { + struct sway_node *node = lua_to_node(L, index); + return (node && node->type == N_WORKSPACE) ? node->sway_workspace : NULL; +} + +static struct sway_output *lua_to_output(lua_State *L, int index) { + struct sway_node *node = lua_to_node(L, index); + return (node && node->type == N_OUTPUT) ? node->sway_output : NULL; +} + +static struct sway_view *lua_to_view(lua_State *L, int index) { + struct sway_container *con = lua_to_container(L, index); + return con ? con->view : NULL; +} + +static void lua_push_node(lua_State *L, struct sway_node *node) { + if (node) { + lua_pushinteger(L, node->id); + } else { + lua_pushnil(L); + } +} + + + void lua_command_data_create() { luaL_unref(config->lua.state, LUA_REGISTRYINDEX, config->lua.command_data); config->lua.command_data = luaL_ref(config->lua.state, LUA_REGISTRYINDEX); @@ -229,32 +262,29 @@ static int scroll_command(lua_State *L) { if (argc < 2) { return scroll_command_error(L, "Error: scroll_command() received a wrong number of parameters"); } - void *node = lua_isnil(L, 1) ? NULL : lua_touserdata(L, 1); + struct sway_container *container = NULL; + struct sway_workspace *workspace = NULL; struct sway_seat *seat = input_manager_current_seat(); - struct sway_container *container = node; - if (container && container->node.type == N_CONTAINER) { - struct sway_container *found = root_find_container(find_container, container); - if (!found) { - return scroll_command_error(L, "Error: scroll_command() received a container parameter that does not exist"); - } - if (!container->view) { - return scroll_command_error(L, "Error: scroll_command() received a container parameter that does not have a view"); + + if (!lua_isnil(L, 1)) { + struct sway_node *node = lua_to_node(L, 1); + if (!node) { + return scroll_command_error(L, "Error: scroll_command() received a parameter that does not exist or is invalid"); } - seat = NULL; - } else { - container = NULL; - struct sway_workspace *workspace = node; - if (workspace && workspace->node.type == N_WORKSPACE) { - struct sway_workspace *found = root_find_workspace(find_workspace, workspace); - if (!found) { - return scroll_command_error(L, "Error: scroll_command() received a workspace parameter that does not exist"); + if (node->type == N_CONTAINER) { + container = node->sway_container; + if (!container->view) { + return scroll_command_error(L, "Error: scroll_command() received a container parameter that does not have a view"); } - seat_set_raw_focus(seat, &workspace->node); - } else if (node == NULL) { seat = NULL; + } else if (node->type == N_WORKSPACE) { + workspace = node->sway_workspace; + seat_set_raw_focus(seat, &workspace->node); } else { return scroll_command_error(L, "Error: scroll_command() received a parameter that is neither a container nor a workspace"); } + } else { + seat = NULL; } // Remove command_data luaL_unref(config->lua.state, LUA_REGISTRYINDEX, config->lua.command_data); @@ -294,58 +324,31 @@ static struct sway_node *get_focused_node() { static int scroll_focused_view(lua_State *L) { struct sway_node *node = get_focused_node(); - if (!node) { - lua_pushnil(L); - return 1; - } - struct sway_container *container = node->type == N_CONTAINER ? + struct sway_container *container = (node && node->type == N_CONTAINER) ? node->sway_container : NULL; - - if (container && container->view) { - lua_pushlightuserdata(L, container->view); - } else { - lua_pushnil(L); - } + lua_push_node(L, (container && container->view) ? &container->node : NULL); return 1; } static int scroll_focused_container(lua_State *L) { struct sway_node *node = get_focused_node(); - if (!node) { - lua_pushnil(L); - return 1; - } - struct sway_container *container = node->type == N_CONTAINER ? + struct sway_container *container = (node && node->type == N_CONTAINER) ? node->sway_container : NULL; - - if (container) { - lua_pushlightuserdata(L, container); - } else { - lua_pushnil(L); - } + lua_push_node(L, (struct sway_node *)container); return 1; } static int scroll_focused_workspace(lua_State *L) { struct sway_node *node = get_focused_node(); - if (!node) { - lua_pushnil(L); - return 1; - } - struct sway_workspace *workspace; - if (node->type == N_WORKSPACE) { - workspace = node->sway_workspace; - } else if (node->type == N_CONTAINER) { - workspace = node->sway_container->pending.workspace; - } else { - workspace = NULL; - } - - if (workspace) { - lua_pushlightuserdata(L, workspace); - } else { - lua_pushnil(L); + struct sway_workspace *workspace = NULL; + if (node) { + if (node->type == N_WORKSPACE) { + workspace = node->sway_workspace; + } else if (node->type == N_CONTAINER) { + workspace = node->sway_container->pending.workspace; + } } + lua_push_node(L, (struct sway_node *)workspace); return 1; } @@ -355,11 +358,7 @@ static bool find_urgent(struct sway_container *container, void *data) { static int scroll_urgent_view(lua_State *L) { struct sway_container *container = root_find_container(find_urgent, NULL); - if (container && container->view) { - lua_pushlightuserdata(L, container->view); - } else { - lua_pushnil(L); - } + lua_push_node(L, (container && container->view) ? &container->node : NULL); return 1; } @@ -369,14 +368,8 @@ static int scroll_view_mapped(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_view *view = lua_touserdata(L, -1); - if (view) { - if (view->lua.mapped) { - lua_pushboolean(L, 1); - return 1; - } - } - lua_pushboolean(L, 0); + struct sway_view *view = lua_to_view(L, -1); + lua_pushboolean(L, (view && view->lua.mapped) ? 1 : 0); return 1; } @@ -386,12 +379,8 @@ static int scroll_view_get_container(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); - if (view && view->container) { - lua_pushlightuserdata(L,view->container); - } else { - lua_pushnil(L); - } + struct sway_container *con = lua_to_container(L, -1); + lua_push_node(L, (struct sway_node *)con); return 1; } @@ -401,7 +390,7 @@ static int scroll_view_get_app_id(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -417,7 +406,7 @@ static int scroll_view_get_class(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -433,7 +422,7 @@ static int scroll_view_get_title(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -449,7 +438,7 @@ static int scroll_view_get_pid(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -481,7 +470,7 @@ static int scroll_view_get_parent_view(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -499,11 +488,7 @@ static int scroll_view_get_parent_view(lua_State *L) { break; } }; - if (container && container->view) { - lua_pushlightuserdata(L, container->view); - } else { - lua_pushnil(L); - } + lua_push_node(L, (container && container->view) ? &container->node : NULL); return 1; } @@ -513,7 +498,7 @@ static int scroll_view_get_urgent(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -528,7 +513,7 @@ static int scroll_view_set_urgent(lua_State *L) { return 0; } bool urgent = lua_toboolean(L, 2); - struct sway_view *view = lua_touserdata(L, 1); + struct sway_view *view = lua_to_view(L, 1); if (view) { view_set_urgent(view, urgent); } @@ -541,7 +526,7 @@ static int scroll_view_get_shell(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -557,7 +542,7 @@ static int scroll_view_get_tag(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (!view) { lua_pushnil(L); return 1; @@ -576,7 +561,7 @@ static int scroll_view_close(lua_State *L) { if (argc == 0) { return 0; } - struct sway_view *view = lua_touserdata(L, -1); + struct sway_view *view = lua_to_view(L, -1); if (view) { view_close(view); } @@ -588,8 +573,8 @@ static int scroll_container_set_focus(lua_State *L) { if (argc == 0) { return 0; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { return 0; } struct sway_seat *seat = input_manager_current_seat(); @@ -603,16 +588,9 @@ static int scroll_container_get_workspace(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { - lua_pushnil(L); - return 1; - } - if (container->pending.workspace) { - lua_pushlightuserdata(L, container->pending.workspace); - } else { - lua_pushnil(L); - } + struct sway_container *container = lua_to_container(L, -1); + lua_push_node(L, (container && container->pending.workspace) ? + (struct sway_node *)container->pending.workspace : NULL); return 1; } @@ -622,8 +600,8 @@ static int scroll_container_get_marks(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_createtable(L, 0, 0); return 1; } @@ -643,8 +621,8 @@ static int scroll_container_get_floating(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushboolean(L, 0); return 1; } @@ -658,8 +636,8 @@ static int scroll_container_get_opacity(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushnil(L); return 1; } @@ -673,8 +651,8 @@ static int scroll_container_get_sticky(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushboolean(L, 0); return 1; } @@ -688,8 +666,8 @@ static int scroll_container_get_scratchpad(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushboolean(L, 0); return 1; } @@ -703,8 +681,8 @@ static int scroll_container_get_width_fraction(lua_State *L) { lua_pushnumber(L, 0.0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushnumber(L, 0.0); return 1; } @@ -718,8 +696,8 @@ static int scroll_container_get_height_fraction(lua_State *L) { lua_pushnumber(L, 0.0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushnumber(L, 0.0); return 1; } @@ -733,8 +711,8 @@ static int scroll_container_get_width(lua_State *L) { lua_pushnumber(L, 0.0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushnumber(L, 0.0); return 1; } @@ -748,8 +726,8 @@ static int scroll_container_get_height(lua_State *L) { lua_pushnumber(L, 0.0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushnumber(L, 0.0); return 1; } @@ -763,8 +741,8 @@ static int scroll_container_get_fullscreen_mode(lua_State *L) { lua_pushstring(L, "none"); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushstring(L, "none"); return 1; } @@ -788,8 +766,8 @@ static int scroll_container_get_fullscreen_app_mode(lua_State *L) { lua_pushstring(L, "disabled"); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushstring(L, "disabled"); return 1; } @@ -810,8 +788,8 @@ static int scroll_container_get_fullscreen_view_mode(lua_State *L) { lua_pushstring(L, "disabled"); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushstring(L, "disabled"); return 1; } @@ -832,8 +810,8 @@ static int scroll_container_get_fullscreen_layout_mode(lua_State *L) { lua_pushstring(L, "disabled"); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_pushstring(L, "disabled"); return 1; } @@ -854,8 +832,8 @@ static int scroll_container_get_pin_mode(lua_State *L) { lua_pushstring(L, "none"); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER || !container->pending.workspace) { + struct sway_container *container = lua_to_container(L, -1); + if (!container || !container->pending.workspace) { lua_pushstring(L, "none"); return 1; } @@ -881,13 +859,9 @@ static int scroll_container_get_parent(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER || - container->pending.parent == NULL) { - lua_pushnil(L); - return 1; - } - lua_pushlightuserdata(L, container->pending.parent); + struct sway_container *container = lua_to_container(L, -1); + lua_push_node(L, (container && container->pending.parent) ? + (struct sway_node *)container->pending.parent : NULL); return 1; } @@ -897,9 +871,8 @@ static int scroll_container_get_children(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER || - container->pending.children == NULL) { + struct sway_container *container = lua_to_container(L, -1); + if (!container || container->pending.children == NULL) { lua_createtable(L, 0, 0); return 1; } @@ -908,7 +881,7 @@ static int scroll_container_get_children(lua_State *L) { lua_createtable(L, len, 0); for (int i = 0; i < len; ++i) { struct sway_container *con = container->pending.children->items[i]; - lua_pushlightuserdata(L, con); + lua_push_node(L, (struct sway_node *)con); lua_rawseti(L, -2, i + 1); } return 1; @@ -920,14 +893,14 @@ static int scroll_container_get_views(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { + struct sway_container *container = lua_to_container(L, -1); + if (!container) { lua_createtable(L, 0, 0); return 1; } if (container->view) { lua_createtable(L, 1, 0); - lua_pushlightuserdata(L, container->view); + lua_push_node(L, (struct sway_node *)container); lua_rawseti(L, -2, 1); } else { int len = container->pending.children->length; @@ -935,7 +908,7 @@ static int scroll_container_get_views(lua_State *L) { lua_createtable(L, len, 0); for (int i = 0; i < len; ++i) { struct sway_container *con = container->pending.children->items[i]; - lua_pushlightuserdata(L, con->view); + lua_push_node(L, con->view ? (struct sway_node *)con : NULL); lua_rawseti(L, -2, i + 1); } } @@ -948,22 +921,19 @@ static int scroll_container_get_id(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER) { - lua_pushnil(L); - return 1; - } - lua_pushinteger(L, container->node.id); + struct sway_container *container = lua_to_container(L, -1); + lua_push_node(L, (struct sway_node *)container); return 1; } + static int scroll_workspace_set_focus(lua_State *L) { int argc = lua_gettop(L); if (argc == 0) { return 0; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { return 0; } struct sway_seat *seat = input_manager_current_seat(); @@ -981,8 +951,8 @@ static int scroll_workspace_get_name(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_pushnil(L); return 1; } @@ -996,9 +966,8 @@ static int scroll_workspace_get_tiling(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE || - workspace->tiling->length == 0) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace || workspace->tiling->length == 0) { lua_createtable(L, 0, 0); return 1; } @@ -1006,7 +975,7 @@ static int scroll_workspace_get_tiling(lua_State *L) { lua_createtable(L, workspace->tiling->length, 0); for (int i = 0; i < workspace->tiling->length; ++i) { struct sway_container *container = workspace->tiling->items[i]; - lua_pushlightuserdata(L, container); + lua_push_node(L, (struct sway_node *)container); lua_rawseti(L, -2, i + 1); } return 1; @@ -1018,9 +987,8 @@ static int scroll_workspace_get_floating(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE || - workspace->floating->length == 0) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace || workspace->floating->length == 0) { lua_createtable(L, 0, 0); return 1; } @@ -1028,7 +996,7 @@ static int scroll_workspace_get_floating(lua_State *L) { lua_createtable(L, workspace->floating->length, 0); for (int i = 0; i < workspace->floating->length; ++i) { struct sway_container *container = workspace->floating->items[i]; - lua_pushlightuserdata(L, container); + lua_push_node(L, (struct sway_node *)container); lua_rawseti(L, -2, i + 1); } return 1; @@ -1040,8 +1008,8 @@ static int scroll_workspace_get_mode(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_createtable(L, 0, 0); return 1; } @@ -1105,8 +1073,8 @@ static int scroll_workspace_set_mode(lua_State *L) { if (argc < 2) { return 0; } - struct sway_workspace *workspace = lua_touserdata(L, 1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, 1); + if (!workspace) { return 0; } if (lua_getfield(L, 2, "mode") == LUA_TSTRING) { @@ -1167,8 +1135,8 @@ static int scroll_workspace_get_layout_type(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_pushnil(L); return 1; } @@ -1192,8 +1160,8 @@ static int scroll_workspace_set_layout_type(lua_State *L) { if (argc < 2) { return 0; } - struct sway_workspace *workspace = lua_touserdata(L, 1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, 1); + if (!workspace) { return 0; } const char *layout = luaL_checkstring(L, 2); @@ -1211,8 +1179,8 @@ static int scroll_workspace_get_width(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_pushnil(L); return 1; } @@ -1226,8 +1194,8 @@ static int scroll_workspace_get_height(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_pushnil(L); return 1; } @@ -1241,16 +1209,8 @@ static int scroll_workspace_get_output(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { - lua_pushnil(L); - return 1; - } - if (workspace->output) { - lua_pushlightuserdata(L, workspace->output); - } else { - lua_pushnil(L); - } + struct sway_workspace *workspace = lua_to_workspace(L, -1); + lua_push_node(L, (workspace && workspace->output) ? (struct sway_node *)workspace->output : NULL); return 1; } @@ -1260,13 +1220,9 @@ static int scroll_workspace_get_pin(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE || - !workspace->layout.pin.container) { - lua_pushnil(L); - return 1; - } - lua_pushlightuserdata(L, workspace->layout.pin.container); + struct sway_workspace *workspace = lua_to_workspace(L, -1); + lua_push_node(L, (workspace && workspace->layout.pin.container) ? + (struct sway_node *)workspace->layout.pin.container : NULL); return 1; } @@ -1276,8 +1232,8 @@ static int scroll_workspace_get_split(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_workspace *workspace = lua_touserdata(L, -1); - if (!workspace || workspace->node.type != N_WORKSPACE) { + struct sway_workspace *workspace = lua_to_workspace(L, -1); + if (!workspace) { lua_createtable(L, 0, 0); return 1; } @@ -1310,7 +1266,7 @@ static int scroll_workspace_get_split(lua_State *L) { lua_pushinteger(L, workspace->split.gap); lua_setfield(L, -2, "gap"); - lua_pushlightuserdata(L, workspace->split.sibling); + lua_push_node(L, workspace->split.sibling ? (struct sway_node *)workspace->split.sibling : NULL); lua_setfield(L, -2, "sibling"); return 1; @@ -1321,7 +1277,7 @@ static int scroll_scratchpad_get_containers(lua_State *L) { lua_createtable(L, root->scratchpad->length, 0); for (int i = 0; i < root->scratchpad->length; ++i) { struct sway_container *container = root->scratchpad->items[i]; - lua_pushlightuserdata(L, container); + lua_push_node(L, (struct sway_node *)container); lua_rawseti(L, -2, i + 1); } return 1; @@ -1332,8 +1288,8 @@ static int scroll_scratchpad_show(lua_State *L) { if (argc == 0) { return 0; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER || !container->scratchpad) { + struct sway_container *container = lua_to_container(L, -1); + if (!container || !container->scratchpad) { return 0; } root_scratchpad_show(container); @@ -1345,8 +1301,8 @@ static int scroll_scratchpad_hide(lua_State *L) { if (argc == 0) { return 0; } - struct sway_container *container = lua_touserdata(L, -1); - if (!container || container->node.type != N_CONTAINER || !container->scratchpad) { + struct sway_container *container = lua_to_container(L, -1); + if (!container || !container->scratchpad) { return 0; } root_scratchpad_hide(container); @@ -1359,16 +1315,9 @@ static int scroll_output_get_active_workspace(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_output *output = lua_touserdata(L, -1); - if (!output || output->node.type != N_OUTPUT) { - lua_pushnil(L); - return 1; - } - if (output->current.active_workspace) { - lua_pushlightuserdata(L, output->current.active_workspace); - } else { - lua_pushnil(L); - } + struct sway_output *output = lua_to_output(L, -1); + lua_push_node(L, (output && output->current.active_workspace) ? + (struct sway_node *)output->current.active_workspace : NULL); return 1; } @@ -1378,8 +1327,8 @@ static int scroll_output_get_enabled(lua_State *L) { lua_pushboolean(L, 0); return 1; } - struct sway_output *output = lua_touserdata(L, -1); - if (!output || output->node.type != N_OUTPUT) { + struct sway_output *output = lua_to_output(L, -1); + if (!output) { lua_pushboolean(L, 0); return 1; } @@ -1393,8 +1342,8 @@ static int scroll_output_get_name(lua_State *L) { lua_pushnil(L); return 1; } - struct sway_output *output = lua_touserdata(L, -1); - if (!output || output->node.type != N_OUTPUT) { + struct sway_output *output = lua_to_output(L, -1); + if (!output) { lua_pushnil(L); return 1; } @@ -1408,8 +1357,8 @@ static int scroll_output_get_workspaces(lua_State *L) { lua_createtable(L, 0, 0); return 1; } - struct sway_output *output = lua_touserdata(L, -1); - if (!output || output->node.type != N_OUTPUT) { + struct sway_output *output = lua_to_output(L, -1); + if (!output) { lua_createtable(L, 0, 0); return 1; } @@ -1417,7 +1366,7 @@ static int scroll_output_get_workspaces(lua_State *L) { lua_createtable(L, output->workspaces->length, 0); for (int i = 0; i < output->workspaces->length; ++i) { struct sway_workspace *workspace = output->workspaces->items[i]; - lua_pushlightuserdata(L, workspace); + lua_push_node(L, (struct sway_node *)workspace); lua_rawseti(L, -2, i + 1); } return 1; @@ -1428,7 +1377,7 @@ static int scroll_root_get_outputs(lua_State *L) { lua_createtable(L, root->outputs->length, 0); for (int i = 0; i < root->outputs->length; ++i) { struct sway_output *output = root->outputs->items[i]; - lua_pushlightuserdata(L, output); + lua_push_node(L, (struct sway_node *)output); lua_rawseti(L, -2, i + 1); } return 1; @@ -1664,7 +1613,7 @@ void lua_execute_view_map_cbs(struct sway_view *view) { for (int i = 0; i < config->lua.cbs_view_map->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_view_map->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, (view && view->container) ? (struct sway_node *)view->container : NULL); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1674,7 +1623,7 @@ void lua_execute_view_unmap_cbs(struct sway_view *view) { for (int i = 0; i < config->lua.cbs_view_unmap->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_view_unmap->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, (view && view->container) ? (struct sway_node *)view->container : NULL); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1684,7 +1633,7 @@ void lua_execute_view_urgent_cbs(struct sway_view *view) { for (int i = 0; i < config->lua.cbs_view_urgent->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_view_urgent->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, (view && view->container) ? (struct sway_node *)view->container : NULL); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1695,7 +1644,7 @@ void lua_execute_view_focus_cbs(struct sway_view *view) { for (int i = 0; i < config->lua.cbs_view_focus->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_view_focus->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, (view && view->container) ? (struct sway_node *)view->container : NULL); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1705,7 +1654,7 @@ void lua_execute_view_float_cbs(struct sway_view *view) { for (int i = 0; i < config->lua.cbs_view_float->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_view_float->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, (view && view->container) ? (struct sway_node *)view->container : NULL); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1715,7 +1664,7 @@ void lua_execute_workspace_create_cbs(struct sway_workspace *workspace) { for (int i = 0; i < config->lua.cbs_workspace_create->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_workspace_create->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, workspace); + lua_push_node(config->lua.state, (struct sway_node *)workspace); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1725,7 +1674,7 @@ void lua_execute_workspace_focus_cbs(struct sway_workspace *workspace) { for (int i = 0; i < config->lua.cbs_workspace_focus->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_workspace_focus->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, workspace); + lua_push_node(config->lua.state, (struct sway_node *)workspace); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } @@ -1736,7 +1685,7 @@ void lua_execute_ipc_view_cbs(struct sway_view *view, const char *change) { for (int i = 0; i < config->lua.cbs_ipc_view->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_ipc_view->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, view); + lua_push_node(config->lua.state, view->container ? (struct sway_node *)view->container : NULL); lua_pushstring(config->lua.state, change); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 3); @@ -1749,8 +1698,8 @@ void lua_execute_ipc_workspace_cbs(struct sway_workspace *old_ws, for (int i = 0; i < config->lua.cbs_ipc_workspace->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_ipc_workspace->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - lua_pushlightuserdata(config->lua.state, old_ws); - lua_pushlightuserdata(config->lua.state, new_ws); + lua_push_node(config->lua.state, (struct sway_node *)old_ws); + lua_push_node(config->lua.state, (struct sway_node *)new_ws); lua_pushstring(config->lua.state, change); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 4); @@ -1761,11 +1710,7 @@ void lua_execute_jump_end_cbs(struct sway_container *container) { for (int i = 0; i < config->lua.cbs_jump_end->length; ++i) { struct sway_lua_closure *closure = config->lua.cbs_jump_end->items[i]; lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_function); - if (container) { - lua_pushlightuserdata(config->lua.state, container->view); - } else { - lua_pushnil(config->lua.state); - } + lua_push_node(config->lua.state, (struct sway_node *)container); lua_rawgeti(config->lua.state, LUA_REGISTRYINDEX, closure->cb_data); safe_pcall(config->lua.state, 2); } diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index 7537c695..5ed94a7a 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -2008,15 +2008,18 @@ print(val) Each time you run the script, it will increment and print the value for "test". -When writing Lua scripts, you can access pointers to scroll's workspaces, -containers or views. A view (window) has an immutable pointer throughout its +When writing Lua scripts, you can access IDs of scroll's workspaces, +containers or views. A view (window) has an immutable ID throughout its lifetime, from creation (mapping) to destruction (unmapping). A container, instead, can be destroyed or transformed when moving, changing workspace or in -several other ciscumstances. So if you want to store some references in the -state from one run of your script to another, store view pointers, never +several other circumstances. So if you want to store some references in the +state from one run of your script to another, store view IDs, never containers. You can always get the valid enclosing container for a view using the API when you need it. +Using an ID of a destroyed workspace or container will not crash the compositor; +the API functions will safely return nil or default values. + This is the current supported API: *log(message)* @@ -2043,22 +2046,22 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") ``` *focused_view()* - Returns the focused view pointer or nil if none + Returns the focused view ID or nil if none *focused_container()* - Returns the focused container pointer or nil if none + Returns the focused container ID or nil if none *focused_workspace()* - Returns the focused workspace pointer or nil if none + Returns the focused workspace ID or nil if none *urgent_view()* - Returns the current urgent view pointer or nil if none. + Returns the current urgent view ID or nil if none. *view_mapped(view)* Returns _true_ if the _view_ is mapped (exists), otherwise returns _false_. *view_get_container(view)* - Returns the container pointer associated to _view_. + Returns the container ID associated to _view_. *view_get_app_id(view)* Returns the _app_id_ string for _view_, or nil if any error happens. @@ -2099,7 +2102,7 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") Sets the focus on _container_. *container_get_workspace(container)* - Returns the _container's_ parent workspace pointer, or nil if none. + Returns the _container's_ parent workspace ID, or nil if none. *container_get_marks(container)* Returns an array with all the marks associated to _container_. @@ -2232,7 +2235,7 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") Returns an integer number with the _workspace_'s height in pixels. *workspace_get_pin(workspace)* - Returns the _workspace's_ pinned container pointer, or nil if none. + Returns the _workspace's_ pinned container ID, or nil if none. *workspace_get_split(workspace)* Returns a table with the split state for _workspace_. The @@ -2244,10 +2247,10 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") _gap_: integer number - _sibling_: the sibling workspace's pointer + _sibling_: the sibling workspace's ID *workspace_get_output(workspace)* - Returns the _workspace's_ output pointer, or nil if none. + Returns the _workspace's_ output ID, or nil if none. *output_get_enabled(output)* Returns _true_ if the output (display) is enabled, or _false_ otherwise. From 2f754aef20ff3d1ebdff71ab5f85cd5a131d5b35 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Wed, 17 Jun 2026 16:43:25 -0700 Subject: [PATCH 11/26] Add pytest-based integration test harness and test clients This introduces the pytest-based test harness infrastructure, including conftest.py and IPC helpers, along with the C-based Wayland and X11 test clients used to simulate windows in integration tests. --- .gitignore | 1 + meson.build | 1 + tests/clients/wayland-client.c | 148 +++++++++++++++++ tests/clients/x11-client.c | 62 ++++++++ tests/conftest.py | 71 +++++++++ tests/lsan.supp | 2 + tests/meson.build | 17 ++ tests/scrollipc.py | 63 ++++++++ tests/test_lua_error.py | 241 ++++++++++++++++++++++++++++ tests/test_utils.py | 281 +++++++++++++++++++++++++++++++++ 10 files changed, 887 insertions(+) create mode 100644 tests/clients/wayland-client.c create mode 100644 tests/clients/x11-client.c create mode 100644 tests/conftest.py create mode 100644 tests/lsan.supp create mode 100644 tests/meson.build create mode 100644 tests/scrollipc.py create mode 100644 tests/test_lua_error.py create mode 100644 tests/test_utils.py diff --git a/.gitignore b/.gitignore index 403d243e..1e757826 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build-*/ .lvimrc config-debug wayland-*-protocol.* +__pycache__ diff --git a/meson.build b/meson.build index 875847e0..cc0402b9 100644 --- a/meson.build +++ b/meson.build @@ -247,6 +247,7 @@ if get_option('default-wallpaper') endif subdir('completions') +subdir('tests') summary({ 'gdk-pixbuf': gdk_pixbuf.found(), diff --git a/tests/clients/wayland-client.c b/tests/clients/wayland-client.c new file mode 100644 index 00000000..9aad66e2 --- /dev/null +++ b/tests/clients/wayland-client.c @@ -0,0 +1,148 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client-protocol.h" + +struct client_state { + struct wl_display *display; + struct wl_registry *registry; + struct wl_compositor *compositor; + struct wl_shm *shm; + struct xdg_wm_base *xdg_wm_base; + struct wl_surface *surface; + struct xdg_surface *xdg_surface; + struct xdg_toplevel *xdg_toplevel; + struct wl_buffer *buffer; + int width, height; + void *shm_data; +}; + +static void noop() {} + +static void shm_format(void *data, struct wl_shm *wl_shm, uint32_t format) {} +static const struct wl_shm_listener shm_listener = { shm_format }; + +static void xdg_wm_base_ping(void *data, struct xdg_wm_base *xdg_wm_base, uint32_t serial) { + xdg_wm_base_pong(xdg_wm_base, serial); +} +static const struct xdg_wm_base_listener xdg_wm_base_listener = { xdg_wm_base_ping }; + +static void registry_global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) { + struct client_state *state = data; + if (strcmp(interface, wl_compositor_interface.name) == 0) { + state->compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); + } else if (strcmp(interface, wl_shm_interface.name) == 0) { + state->shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + wl_shm_add_listener(state->shm, &shm_listener, state); + } else if (strcmp(interface, xdg_wm_base_interface.name) == 0) { + state->xdg_wm_base = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); + xdg_wm_base_add_listener(state->xdg_wm_base, &xdg_wm_base_listener, state); + } +} +static void registry_global_remove(void *data, struct wl_registry *registry, uint32_t name) {} +static const struct wl_registry_listener registry_listener = { registry_global, registry_global_remove }; + +static void xdg_surface_configure(void *data, struct xdg_surface *xdg_surface, uint32_t serial) { + struct client_state *state = data; + xdg_surface_ack_configure(xdg_surface, serial); + + if (!state->buffer) { + int stride = state->width * 4; + int size = stride * state->height; + + int fd = memfd_create("scroll-test-shm", MFD_CLOEXEC); + if (fd < 0) { + perror("memfd_create"); + exit(1); + } + if (ftruncate(fd, size) < 0) { + perror("ftruncate"); + exit(1); + } + state->shm_data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (state->shm_data == MAP_FAILED) { + perror("mmap"); + exit(1); + } + struct wl_shm_pool *pool = wl_shm_create_pool(state->shm, fd, size); + state->buffer = wl_shm_pool_create_buffer(pool, 0, state->width, state->height, stride, WL_SHM_FORMAT_XRGB8888); + wl_shm_pool_destroy(pool); + close(fd); + + uint32_t *pixels = state->shm_data; + for (int i = 0; i < state->width * state->height; ++i) { + pixels[i] = 0xFF0000FF; // Blue + } + } + + wl_surface_attach(state->surface, state->buffer, 0, 0); + wl_surface_damage_buffer(state->surface, 0, 0, state->width, state->height); + wl_surface_commit(state->surface); +} +static const struct xdg_surface_listener xdg_surface_listener = { xdg_surface_configure }; + +static void xdg_toplevel_configure(void *data, struct xdg_toplevel *xdg_toplevel, int32_t width, int32_t height, struct wl_array *states) {} +static void xdg_toplevel_close(void *data, struct xdg_toplevel *xdg_toplevel) { + exit(0); +} +static const struct xdg_toplevel_listener xdg_toplevel_listener = { xdg_toplevel_configure, xdg_toplevel_close, noop, noop }; + +int main(int argc, char **argv) { + struct client_state state = {0}; + state.width = 100; + state.height = 100; + + const char *title = "Test Wayland Window"; + const char *app_id = "test_app_id"; + if (argc > 1) title = argv[1]; + if (argc > 2) app_id = argv[2]; + + state.display = wl_display_connect(NULL); + if (!state.display) { + fprintf(stderr, "Failed to connect to Wayland display\n"); + return 1; + } + + state.registry = wl_display_get_registry(state.display); + wl_registry_add_listener(state.registry, ®istry_listener, &state); + wl_display_roundtrip(state.display); + + if (!state.compositor || !state.shm || !state.xdg_wm_base) { + fprintf(stderr, "Missing globals\n"); + return 1; + } + + state.surface = wl_compositor_create_surface(state.compositor); + state.xdg_surface = xdg_wm_base_get_xdg_surface(state.xdg_wm_base, state.surface); + xdg_surface_add_listener(state.xdg_surface, &xdg_surface_listener, &state); + + state.xdg_toplevel = xdg_surface_get_toplevel(state.xdg_surface); + xdg_toplevel_add_listener(state.xdg_toplevel, &xdg_toplevel_listener, &state); + + xdg_toplevel_set_title(state.xdg_toplevel, title); + xdg_toplevel_set_app_id(state.xdg_toplevel, app_id); + + wl_surface_commit(state.surface); + + while (wl_display_dispatch(state.display) != -1) { + // Loop + } + + if (state.buffer) wl_buffer_destroy(state.buffer); + if (state.shm_data) munmap(state.shm_data, state.width * 4 * state.height); + if (state.xdg_toplevel) xdg_toplevel_destroy(state.xdg_toplevel); + if (state.xdg_surface) xdg_surface_destroy(state.xdg_surface); + if (state.surface) wl_surface_destroy(state.surface); + if (state.compositor) wl_compositor_destroy(state.compositor); + if (state.shm) wl_shm_destroy(state.shm); + if (state.xdg_wm_base) xdg_wm_base_destroy(state.xdg_wm_base); + if (state.registry) wl_registry_destroy(state.registry); + if (state.display) wl_display_disconnect(state.display); + + return 0; +} diff --git a/tests/clients/x11-client.c b/tests/clients/x11-client.c new file mode 100644 index 00000000..6357f3d8 --- /dev/null +++ b/tests/clients/x11-client.c @@ -0,0 +1,62 @@ +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + xcb_connection_t *conn = xcb_connect(NULL, NULL); + if (xcb_connection_has_error(conn)) { + fprintf(stderr, "Failed to connect to X11 display\n"); + return 1; + } + + xcb_screen_t *screen = xcb_setup_roots_iterator(xcb_get_setup(conn)).data; + xcb_window_t win = xcb_generate_id(conn); + + uint32_t mask = XCB_CW_BACK_PIXEL | XCB_CW_EVENT_MASK; + uint32_t values[2] = { + screen->white_pixel, + XCB_EVENT_MASK_EXPOSURE + }; + + xcb_create_window(conn, XCB_COPY_FROM_PARENT, win, screen->root, + 0, 0, 150, 150, 10, + XCB_WINDOW_CLASS_INPUT_OUTPUT, screen->root_visual, + mask, values); + + const char *title = "Test X11 Window"; + if (argc > 1) title = argv[1]; + xcb_change_property(conn, XCB_PROP_MODE_REPLACE, win, + XCB_ATOM_WM_NAME, XCB_ATOM_STRING, 8, + strlen(title), title); + + const char *instance = "test_instance"; + const char *class = "TestClass"; + if (argc > 2) instance = argv[2]; + if (argc > 3) class = argv[3]; + size_t class_len = strlen(instance) + 1 + strlen(class) + 1; + char *class_str = malloc(class_len); + if (!class_str) { + perror("malloc"); + return 1; + } + strcpy(class_str, instance); + strcpy(class_str + strlen(instance) + 1, class); + xcb_change_property(conn, XCB_PROP_MODE_REPLACE, win, + XCB_ATOM_WM_CLASS, XCB_ATOM_STRING, 8, + class_len, class_str); + free(class_str); + + xcb_map_window(conn, win); + xcb_flush(conn); + + while (1) { + xcb_generic_event_t *ev = xcb_wait_for_event(conn); + if (!ev) break; + free(ev); + } + + xcb_disconnect(conn); + return 0; +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4531b51c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,71 @@ +import subprocess +import os +import pytest +from typing import Generator +from pathlib import Path +from test_utils import ScrollInstance, run_compositor + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--scroll", help="the scroll binary to test", default=None) + + +@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}" + ) + 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}" + ) + + # 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}" + ) + + binary_path = os.path.join(build_dir, "sway", "scroll") + else: + binary_path = os.path.abspath(binary_path) + + assert os.path.exists(binary_path), f"Binary not found at {binary_path}" + return binary_path + + +@pytest.fixture(scope="session") +def scroll_compositor( + scroll_compositor_binary: str, tmp_path_factory: pytest.TempPathFactory +) -> Generator[ScrollInstance, None, None]: + temp_dir: Path = tmp_path_factory.mktemp("scroll") + with run_compositor(scroll_compositor_binary, temp_dir) as inst: + yield inst + + +@pytest.fixture(scope="function") +def fresh_compositor( + scroll_compositor_binary: str, tmp_path: Path +) -> Generator[ScrollInstance, None, None]: + with run_compositor(scroll_compositor_binary, tmp_path) as inst: + yield inst diff --git a/tests/lsan.supp b/tests/lsan.supp new file mode 100644 index 00000000..e01775a3 --- /dev/null +++ b/tests/lsan.supp @@ -0,0 +1,2 @@ +# Leaks in EGL/DRM initialization due to missing eglTerminate in wlroots (or system libraries) +leak:egl_init_display diff --git a/tests/meson.build b/tests/meson.build new file mode 100644 index 00000000..2b153d5d --- /dev/null +++ b/tests/meson.build @@ -0,0 +1,17 @@ +executable( + 'wayland-test-client', + files('clients/wayland-client.c'), + dependencies: [wayland_client], + sources: wl_protos_src, + install: false, +) + +xcb_dep = dependency('xcb', required: false) +if xcb_dep.found() + executable( + 'x11-test-client', + files('clients/x11-client.c'), + dependencies: [xcb_dep], + install: false, + ) +endif diff --git a/tests/scrollipc.py b/tests/scrollipc.py new file mode 100644 index 00000000..4d999f27 --- /dev/null +++ b/tests/scrollipc.py @@ -0,0 +1,63 @@ +import socket +import struct +import json + +MAGIC: bytes = b"i3-ipc" +HEADER_FORMAT: str = "<6sII" # 6 bytes magic, 4 bytes length, 4 bytes type +HEADER_SIZE: int = struct.calcsize(HEADER_FORMAT) + +# Command types from include/ipc.h +IPC_COMMAND: int = 0 +IPC_GET_WORKSPACES: int = 1 +IPC_SUBSCRIBE: int = 2 +IPC_GET_VERSION: int = 7 + + +class ScrollIPC: + socket_path: str + sock: socket.socket + + def __init__(self, socket_path: str): + self.socket_path = socket_path + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(socket_path) + + def close(self) -> None: + self.sock.close() + + def _send(self, msg_type: int, payload: str) -> None: + payload_bytes: bytes = payload.encode("utf-8") + length: int = len(payload_bytes) + header: bytes = struct.pack(HEADER_FORMAT, MAGIC, length, msg_type) + self.sock.sendall(header + payload_bytes) + + def _recv(self) -> tuple[int, str]: + header_data: bytes = self._recv_all(HEADER_SIZE) + magic: bytes + length: int + msg_type: int + magic, length, msg_type = struct.unpack(HEADER_FORMAT, header_data) + if magic != MAGIC: + raise ValueError(f"Invalid magic string: {magic}") + payload_data: bytes = self._recv_all(length) + return msg_type, payload_data.decode("utf-8") + + def _recv_all(self, size: int) -> bytes: + data: bytes = b"" + while len(data) < size: + chunk: bytes = self.sock.recv(size - len(data)) + if not chunk: + raise EOFError("Socket closed prematurely") + data += chunk + return data + + def command(self, cmd_string: str) -> list: + self._send(IPC_COMMAND, cmd_string) + reply_type: int + reply_payload: str + reply_type, reply_payload = self._recv() + if reply_type != IPC_COMMAND: + raise ValueError(f"Unexpected reply type: {reply_type}") + result = json.loads(reply_payload) + assert isinstance(result, list) + return result diff --git a/tests/test_lua_error.py b/tests/test_lua_error.py new file mode 100644 index 00000000..d29903ef --- /dev/null +++ b/tests/test_lua_error.py @@ -0,0 +1,241 @@ +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Generator +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +@contextmanager +def lua_callback( + scroll_compositor: ScrollInstance, event_name: str, callback_code: str +) -> Generator[str, None, None]: + cb_key: str = "cb_" + uuid.uuid4().hex + + register_code: str = f""" + if not _G.test_callbacks then _G.test_callbacks = {{}} end + _G.test_callbacks["{cb_key}"] = scroll.add_callback("{event_name}", {callback_code}, nil) + return "{cb_key}" + """ + res = scroll_compositor.execute_lua(register_code) + assert res == cb_key + + try: + yield cb_key + finally: + unregister_code: str = f""" + if _G.test_callbacks and _G.test_callbacks["{cb_key}"] then + scroll.remove_callback(_G.test_callbacks["{cb_key}"]) + _G.test_callbacks["{cb_key}"] = nil + end + """ + scroll_compositor.execute_lua(unregister_code) + + +def test_workspace_focus_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_WORKSPACE_FOCUS" + with lua_callback( + scroll_compositor, + "workspace_focus", + f"function(ws, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.cmd("workspace 2") + scroll_compositor.cmd("workspace 1") + + assert scroll_compositor.proc.poll() is None + + +def test_workspace_create_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_WORKSPACE_CREATE" + import random + + ws_num: int = random.randint(10, 100) + with lua_callback( + scroll_compositor, + "workspace_create", + f"function(ws, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.cmd(f"workspace {ws_num}") + scroll_compositor.cmd("workspace 1") + + assert scroll_compositor.proc.poll() is None + + +def test_view_map_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_VIEW_MAP" + with lua_callback( + scroll_compositor, + "view_map", + f"function(con, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + with wayland_client(scroll_compositor, "Test Window"): + wait_for_client_map(scroll_compositor, "Test Window") + + assert scroll_compositor.proc.poll() is None + + +def test_view_unmap_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_VIEW_UNMAP" + with wayland_client(scroll_compositor, "Test Window") as proc: + wait_for_client_map(scroll_compositor, "Test Window") + with lua_callback( + scroll_compositor, + "view_unmap", + f"function(con, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + proc.terminate() + proc.wait(timeout=5) + + assert scroll_compositor.proc.poll() is None + + +def test_view_focus_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_VIEW_FOCUS" + with wayland_client(scroll_compositor, "Test Window"): + wait_for_client_map(scroll_compositor, "Test Window") + scroll_compositor.cmd("workspace 2") + + with lua_callback( + scroll_compositor, + "view_focus", + f"function(con, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.cmd("workspace 1") + + assert scroll_compositor.proc.poll() is None + + +def test_view_float_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_VIEW_FLOAT" + with wayland_client(scroll_compositor, "Test Window"): + wait_for_client_map(scroll_compositor, "Test Window") + with lua_callback( + scroll_compositor, + "view_float", + f"function(con, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.cmd("floating toggle") + + assert scroll_compositor.proc.poll() is None + + +def test_view_urgent_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_VIEW_URGENT" + with wayland_client(scroll_compositor, "Test Window"): + view_id = wait_for_client_map(scroll_compositor, "Test Window") + # Switch focus away so the view is not focused (urgent cannot be set on focused view) + scroll_compositor.cmd("workspace 2") + try: + with lua_callback( + scroll_compositor, + "view_urgent", + f"function(con, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.execute_lua( + f"scroll.view_set_urgent({view_id}, true)" + ) + finally: + scroll_compositor.cmd("workspace 1") + + assert scroll_compositor.proc.poll() is None + + +def test_ipc_workspace_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_IPC_WORKSPACE" + with lua_callback( + scroll_compositor, + "ipc_workspace", + f"function(old, new, change, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.cmd("workspace 2") + scroll_compositor.cmd("workspace 1") + + assert scroll_compositor.proc.poll() is None + + +def test_ipc_view_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_IPC_VIEW" + with lua_callback( + scroll_compositor, + "ipc_view", + f"function(con, change, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + with wayland_client(scroll_compositor, "Test Window"): + wait_for_client_map(scroll_compositor, "Test Window") + + assert scroll_compositor.proc.poll() is None + + +def test_command_end_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_COMMAND_END" + with lua_callback( + scroll_compositor, + "command_end", + f"function(cmd, data) error('{marker}') end", + ): + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + scroll_compositor.execute_lua('scroll.command(nil, "nop")') + + assert scroll_compositor.proc.poll() is None + + +def test_lua_load_nonexistent_script(scroll_compositor: ScrollInstance) -> None: + res = scroll_compositor.cmd("lua /nonexistent/script.lua") + assert res and not res[0]["success"] + assert "Error" in res[0]["error"] + assert scroll_compositor.proc.poll() is None + + +def test_lua_load_syntax_error( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> None: + script: Path = tmp_path / "syntax_error.lua" + script.write_text("this is not valid lua code") + res = scroll_compositor.cmd(f"lua {script}") + assert res and not res[0]["success"] + assert "Error" in res[0]["error"] + assert scroll_compositor.proc.poll() is None + + +def test_lua_runtime_error_top_level( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> None: + script: Path = tmp_path / "runtime_error.lua" + script.write_text("error('top level error')") + res = scroll_compositor.cmd(f"lua {script}") + assert res and not res[0]["success"] + assert "Error" in res[0]["error"] + assert "top level error" in res[0]["error"] + assert scroll_compositor.proc.poll() is None + + +def test_jump_end_error(scroll_compositor: ScrollInstance) -> None: + marker: str = "ERR_JUMP_END" + with wayland_client(scroll_compositor, "Test Window"): + wait_for_client_map(scroll_compositor, "Test Window") + with lua_callback( + scroll_compositor, + "jump_end", + f"function(con, data) error('{marker}') end", + ): + # Enter jump mode + res = scroll_compositor.cmd("jump") + assert res and res[0]["success"] + + # Trigger end by clicking + with scroll_compositor.assert_logs_match(rf"Lua error:.*{marker}"): + r1 = scroll_compositor.cmd("seat * cursor press button1") + assert r1 and r1[0]["success"], f"cursor press failed: {r1}" + r2 = scroll_compositor.cmd("seat * cursor release button1") + assert r2 and r2[0]["success"], f"cursor release failed: {r2}" + + assert scroll_compositor.proc.poll() is None diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..3515d980 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,281 @@ +import time +import os +import subprocess +import re +import pytest +from typing import Generator, Any +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 + ipc: ScrollIPC + log_path: Path + temp_dir: Path + + def __init__( + self, proc: subprocess.Popen, ipc: ScrollIPC, log_path: Path, temp_dir: Path + ): + self.proc = proc + self.ipc = ipc + self.log_path = log_path + self.temp_dir = temp_dir + + def cmd(self, command: str) -> list: + return self.ipc.command(command) + + 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 + + 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}" + + assert output_path.exists(), ( + f"Output file not created: {output_path}. Compositor log:\\n{self.read_log()}" + ) + output_content = output_path.read_text() + + lines = output_content.splitlines() + if not lines: + raise RuntimeError( + f"Lua runner output is empty. Compositor log:\\n{self.read_log()}" + ) + status = lines[0] + result_str = "\\n".join(lines[1:]) + + if status == "SUCCESS": + if not result_str: + return None + return json.loads(result_str) + else: + raise RuntimeError(f"Lua execution failed: {result_str}") + + def getenv(self, var: str) -> str | None: + return self.execute_lua(f'return os.getenv("{var}")') + + @contextmanager + def assert_logs_match( + self, pattern: str, timeout: float = 5.0 + ) -> Generator[None, None, None]: + initial_log_len: int = len(self.read_log()) + yield + start_time: float = time.time() + compiled_pattern = re.compile(pattern) + while True: + current_log: str = self.read_log() + new_log: str = current_log[initial_log_len:] + if compiled_pattern.search(new_log): + return + if time.time() - start_time > timeout: + raise AssertionError( + f"Pattern '{pattern}' not found in new log output within {timeout}s.\nNew log was:\n{new_log}" + ) + time.sleep(0.1) + + +@contextmanager +def run_compositor( + binary_path: str, temp_dir: Path, config_content: str | None = None +) -> Generator[ScrollInstance, None, None]: + log_path: Path = temp_dir / "scroll.log" + log_file = open(log_path, "w") + + config_path: Path = temp_dir / "config" + if config_content is None: + config_content = "workspace 1\nxwayland force\n" + config_path.write_text(config_content) + + env = os.environ.copy() + env["HOME"] = str(temp_dir) + env["WLR_BACKENDS"] = "headless" + + 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']}" + else: + env["LSAN_OPTIONS"] = f"suppressions={supp_path}" + if "DISPLAY" in env: + del env["DISPLAY"] + if "WAYLAND_DISPLAY" in env: + del env["WAYLAND_DISPLAY"] + + proc = subprocess.Popen( + [binary_path, "-c", str(config_path), "-d"], + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + + xdg_runtime_dir: str = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + uid: int = os.getuid() + socket_path: str = os.path.join( + xdg_runtime_dir, f"scroll-ipc.{uid}.{proc.pid}.sock" + ) + + ipc = None + tries = 0 + while tries < 100: + if os.path.exists(socket_path): + try: + ipc = ScrollIPC(socket_path) + break + except Exception: + pass + time.sleep(0.05) + tries += 1 + + if not ipc: + proc.terminate() + log_file.close() + print(f"Scroll log:\n{log_path.read_text()}") + pytest.exit("Failed to connect to scroll IPC") + + try: + yield ScrollInstance(proc, ipc, log_path, temp_dir) + finally: + # Teardown + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + log_file.close() + + +@contextmanager +def wayland_client( + compositor: ScrollInstance, + title: str, +) -> Generator[subprocess.Popen, None, None]: + wayland_display: str | None = compositor.getenv("WAYLAND_DISPLAY") + assert wayland_display is not None + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + env: dict = os.environ.copy() + env["WAYLAND_DISPLAY"] = wayland_display + proc: subprocess.Popen = subprocess.Popen( + [str(client_path), title, "test_app"], env=env + ) + try: + yield proc + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + +def wait_for_client_map(compositor: ScrollInstance, title: str) -> int: + tries: int = 0 + while tries < 50: + view_id = compositor.execute_lua(f""" + local view = scroll.focused_view() + if view and scroll.view_get_title(view) == "{title}" then + return view + end + """) + if view_id is not None: + assert isinstance(view_id, int) + return view_id + time.sleep(0.05) + tries += 1 + raise RuntimeError(f"Client '{title}' did not map") From 1b63cf2cb5273491c7d5610566f588ae07ab28e9 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 12:31:49 -0700 Subject: [PATCH 12/26] Add comprehensive integration tests for Lua APIs and clients Adds Python integration tests to verify Lua APIs (geometry, outputs, workspaces), safe pointer lookup (use-after-free prevention), and client management (mapping/unmapping clients). --- tests/test_clients.py | 135 ++++++++++++++++++++++++++++++++++++++ tests/test_crash_move.py | 23 +++++++ tests/test_lua_api.py | 80 ++++++++++++++++++++++ tests/test_lua_safety.py | 27 ++++++++ tests/test_normal_exit.py | 34 ++++++++++ tests/test_swap.py | 13 ++++ 6 files changed, 312 insertions(+) create mode 100644 tests/test_clients.py create mode 100644 tests/test_crash_move.py create mode 100644 tests/test_lua_api.py create mode 100644 tests/test_lua_safety.py create mode 100644 tests/test_normal_exit.py create mode 100644 tests/test_swap.py diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 00000000..b29a7c69 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,135 @@ +import os +import subprocess +import time +from pathlib import Path +import pytest +from conftest import ScrollInstance + + +def test_wayland_client(scroll_compositor: ScrollInstance) -> None: + wayland_display: str | None = scroll_compositor.getenv("WAYLAND_DISPLAY") + assert wayland_display is not None + + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + title: str = "My Wayland Test Window" + app_id: str = "my_wayland_app_id" + + env: dict = os.environ.copy() + env["WAYLAND_DISPLAY"] = wayland_display + + proc: subprocess.Popen = subprocess.Popen( + [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 + + scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") + + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + pytest.fail("Client did not exit after view_close") + + assert proc.returncode == 0 + + +def test_x11_client(scroll_compositor: ScrollInstance) -> None: + display: str | None = scroll_compositor.getenv("DISPLAY") + if not display: + pytest.skip("Xwayland is not enabled (no DISPLAY env var in compositor)") + + 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" + + client_path: Path = Path("./build/tests/x11-test-client").resolve() + if not client_path.exists(): + pytest.skip("X11 test client not built") + + title: str = "My X11 Test Window" + instance: str = "my_x11_instance" + class_name: str = "MyX11Class" + + env: dict = os.environ.copy() + env["DISPLAY"] = display + if xauthority: + env["XAUTHORITY"] = xauthority + + proc: subprocess.Popen = subprocess.Popen( + [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 + + scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") + + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + pytest.fail("X11 client did not exit after view_close") + + assert proc.returncode == 0 diff --git a/tests/test_crash_move.py b/tests/test_crash_move.py new file mode 100644 index 00000000..5250a77a --- /dev/null +++ b/tests/test_crash_move.py @@ -0,0 +1,23 @@ +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_move_left_nomode_no_crash(scroll_compositor: ScrollInstance) -> None: + # Open first window + with wayland_client(scroll_compositor, "Window 1"): + wait_for_client_map(scroll_compositor, "Window 1") + + # Open second window + with wayland_client(scroll_compositor, "Window 2"): + 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 + + # Check if compositor process is still alive + assert scroll_compositor.proc.poll() is None, "Compositor crashed" diff --git a/tests/test_lua_api.py b/tests/test_lua_api.py new file mode 100644 index 00000000..87fc0d18 --- /dev/null +++ b/tests/test_lua_api.py @@ -0,0 +1,80 @@ +from conftest import ScrollInstance + + +def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: + # Get focused workspace + ws: int = scroll_compositor.execute_lua("return scroll.focused_workspace()") + assert isinstance(ws, int) + + # Get workspace name + ws_name: str = scroll_compositor.execute_lua( + f"return scroll.workspace_get_name({ws})" + ) + assert ws_name == "1" + + # Get workspace output + output: int = scroll_compositor.execute_lua( + f"return scroll.workspace_get_output({ws})" + ) + assert isinstance(output, int) + + # Get output name + output_name: str = scroll_compositor.execute_lua( + f"return scroll.output_get_name({output})" + ) + assert isinstance(output_name, str) + + # Get output enabled + output_enabled: bool = scroll_compositor.execute_lua( + f"return scroll.output_get_enabled({output})" + ) + assert output_enabled is True + + # Get root outputs + outputs: list = scroll_compositor.execute_lua("return scroll.root_get_outputs()") + assert isinstance(outputs, list) + assert len(outputs) > 0 + assert outputs[0] == output + + # Get output workspaces + ws_list: list = scroll_compositor.execute_lua( + f"return scroll.output_get_workspaces({output})" + ) + assert isinstance(ws_list, list) + assert len(ws_list) > 0 + assert ws_list[0] == ws + + # Test invalid IDs + invalid_ws: int = 999999 + assert ( + scroll_compositor.execute_lua(f"return scroll.workspace_get_name({invalid_ws})") + is None + ) + assert ( + scroll_compositor.execute_lua( + f"return scroll.workspace_get_output({invalid_ws})" + ) + is None + ) + + invalid_output: int = 999998 + assert ( + scroll_compositor.execute_lua( + f"return scroll.output_get_name({invalid_output})" + ) + is None + ) + assert ( + scroll_compositor.execute_lua( + f"return scroll.output_get_enabled({invalid_output})" + ) + is False + ) + + invalid_output_ws: list = scroll_compositor.execute_lua( + f"return scroll.output_get_workspaces({invalid_output})" + ) + assert isinstance(invalid_output_ws, list) + assert len(invalid_output_ws) == 0 + + assert scroll_compositor.proc.poll() is None diff --git a/tests/test_lua_safety.py b/tests/test_lua_safety.py new file mode 100644 index 00000000..7911868a --- /dev/null +++ b/tests/test_lua_safety.py @@ -0,0 +1,27 @@ +from conftest import ScrollInstance + + +def test_lua_use_after_free_prevented(scroll_compositor: ScrollInstance) -> None: + # 1. Get current workspace (workspace 1) + ws1: int = scroll_compositor.execute_lua("return scroll.focused_workspace()") + + # 2. Create and switch to workspace 2 + scroll_compositor.execute_lua('scroll.command(nil, "workspace 2")') + ws2: int = scroll_compositor.execute_lua("return scroll.focused_workspace()") + assert ws1 != ws2 + + # 3. Switch back to workspace 1 (workspace 2 should be destroyed if empty) + scroll_compositor.execute_lua('scroll.command(nil, "workspace 1")') + + # 4. Now try to get name of ws2. It should return nil (None in Python), but NOT crash. + ws2_name = scroll_compositor.execute_lua(f"return scroll.workspace_get_name({ws2})") + assert ws2_name is None + + # 5. Also try with invalid container ID + invalid_con_title = scroll_compositor.execute_lua( + "return scroll.view_get_title(999999)" + ) + assert invalid_con_title is None + + # Verify it didn't crash + assert scroll_compositor.proc.poll() is None diff --git a/tests/test_normal_exit.py b/tests/test_normal_exit.py new file mode 100644 index 00000000..0a51e8e7 --- /dev/null +++ b/tests/test_normal_exit.py @@ -0,0 +1,34 @@ +import time +from conftest import ScrollInstance + + +def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: + # Send exit command + try: + res = fresh_compositor.cmd("exit") + assert res and res[0]["success"], f"Exit command failed: {res}" + 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 + + 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 diff --git a/tests/test_swap.py b/tests/test_swap.py new file mode 100644 index 00000000..d409024f --- /dev/null +++ b/tests/test_swap.py @@ -0,0 +1,13 @@ +from conftest import ScrollInstance + + +def test_swap_invalid_con_id(scroll_compositor: ScrollInstance) -> None: + # Try swap with invalid con_id format + res: list = scroll_compositor.cmd("swap container with con_id invalid123") + assert not res[0]["success"] + assert "Invalid container ID" in res[0].get("error", "") + + # Try swap with valid con_id format but non-existent + res = scroll_compositor.cmd("swap container with con_id 999999") + assert not res[0]["success"] + assert "Failed to find con_id '999999'" in res[0].get("error", "") From 55912c71105840c4e65ac98c6f8c7d98ef773d74 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 13:55:54 -0700 Subject: [PATCH 13/26] lua: expand path using wordexp and support relative paths - Use wordexp to expand the script path in the lua command, allowing tilde and environment variable expansion. - Enforce that the path must expand to exactly one file, returning specific errors otherwise. - Resolve relative paths against the directory of the loaded configuration file during config loading (and use initial working directory otherwise). - Update documentation in scroll.5.scd to mention wordexp and relative path support. - Mock HOME in tests to test tilde expansion safely. --- sway/commands/lua.c | 88 ++++++++++++++++++++++++++++++++++++++++--- sway/scroll.5.scd | 10 +++-- tests/test_lua_api.py | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 9 deletions(-) diff --git a/sway/commands/lua.c b/sway/commands/lua.c index a39a7204..fe31b8e3 100644 --- a/sway/commands/lua.c +++ b/sway/commands/lua.c @@ -1,4 +1,8 @@ +#include +#include +#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) { @@ -15,16 +19,83 @@ struct cmd_results *cmd_lua(int argc, char **argv) { if ((error = checkarg(argc, "lua", EXPECTED_AT_LEAST, 1))) { return error; } - int err = luaL_loadfile(config->lua.state, argv[0]); + + char *wd = NULL; + char *config_dir = NULL; + if (config->reading && config->current_config_path) { + wd = getcwd(NULL, 0); + char *conf = strdup(config->current_config_path); + if (conf) { + config_dir = strdup(dirname(conf)); + free(conf); + } + if (config_dir && chdir(config_dir) < 0) { + sway_log(SWAY_ERROR, "failed to change working directory to config dir"); + free(config_dir); + free(wd); + return cmd_results_new(CMD_FAILURE, "Failed to change working directory to config dir"); + } + } + + char *expanded_path = NULL; + wordexp_t p; + int err_we = wordexp(argv[0], &p, 0); + + if (wd && chdir(wd) < 0) { + sway_log(SWAY_ERROR, "failed to restore working directory"); + } + free(wd); + + if (err_we != 0) { + free(config_dir); + return cmd_results_new(CMD_FAILURE, "Error expanding path %s (code %d)", argv[0], err_we); + } + + struct cmd_results *res = NULL; + + if (p.we_wordc == 0) { + res = cmd_results_new(CMD_FAILURE, "Path expanded to nothing: %s", argv[0]); + goto cleanup; + } else if (p.we_wordc > 1) { + res = cmd_results_new(CMD_FAILURE, "Path expanded to multiple files: %s", argv[0]); + goto cleanup; + } + + expanded_path = strdup(p.we_wordv[0]); + if (!expanded_path) { + res = cmd_results_new(CMD_FAILURE, "Failed to allocate memory"); + goto cleanup; + } + + if (config->reading && expanded_path[0] != '/' && config_dir) { + char *real_path = malloc(strlen(config_dir) + strlen(expanded_path) + 2); + if (real_path) { + sprintf(real_path, "%s/%s", config_dir, expanded_path); + free(expanded_path); + expanded_path = real_path; + } + } + +cleanup: + wordfree(&p); + free(config_dir); + + if (res) { + return res; + } + + int err = luaL_loadfile(config->lua.state, expanded_path); if (err != LUA_OK) { - return cmd_results_new(CMD_FAILURE, "Error %d loading lua script %s", err, argv[0]); + struct cmd_results *res = cmd_results_new(CMD_FAILURE, "Error %d loading lua script %s", err, expanded_path); + free(expanded_path); + return res; } // Search if there is already a state for this script - struct sway_lua_script *script = find_script(config->lua.scripts, argv[0]); + struct sway_lua_script *script = find_script(config->lua.scripts, expanded_path); if (!script) { script = malloc(sizeof(struct sway_lua_script)); - script->name = strdup(argv[0]); + 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); @@ -41,11 +112,16 @@ 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) { - return cmd_results_new(CMD_FAILURE, "Error %s executing lua script %s", str, argv[0]); + 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); } - return cmd_results_new(CMD_FAILURE, "Error %d executing lua script %s", err, argv[0]); + free(expanded_path); + return res; } + free(expanded_path); return cmd_results_new(CMD_SUCCESS, NULL); } diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index 5ed94a7a..ee83f695 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -1802,9 +1802,13 @@ lua $lua_scripts/workspace_exec.lua 1 exec kitty windows. See *focus_on_window_activation* to set the state of the activated window to _urgent_ or _focused_. -*lua* script - Loads and executes the lua script. See *LUA* for details about the API and - some examples. +*lua* [] + Loads and executes the lua script at _path_ with optional _args_. _path_ + expands shell syntax (see *wordexp*(3) for details) and must resolve to + exactly one file. When invoked during configuration file loading, relative + paths are resolved against the directory of the configuration file. + Otherwise, they are resolved against the initial working directory of the + compositor. See *LUA* for details about the API and some examples. # CRITERIA diff --git a/tests/test_lua_api.py b/tests/test_lua_api.py index 87fc0d18..c4613ac4 100644 --- a/tests/test_lua_api.py +++ b/tests/test_lua_api.py @@ -1,4 +1,7 @@ +from pathlib import Path +import time from conftest import ScrollInstance +from test_utils import run_compositor def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: @@ -78,3 +81,84 @@ 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_tilde_expansion(scroll_compositor: ScrollInstance) -> None: + home_dir = scroll_compositor.temp_dir + script_path = home_dir / "test_tilde.lua" + script_path.write_text('scroll.log("TILDE_TEST_SUCCESS")') + + with scroll_compositor.assert_logs_match("TILDE_TEST_SUCCESS"): + res = scroll_compositor.cmd("lua ~/test_tilde.lua") + assert res[0]["success"] + + # Test non-existent file + res = scroll_compositor.cmd("lua ~/nonexistent.lua") + assert not res[0]["success"] + assert "Error" in res[0].get("error", "") + + # Test multiple matches (should fail) + script_path2 = home_dir / "test_tilde2.lua" + script_path2.write_text('scroll.log("TILDE_TEST_SUCCESS2")') + + res = scroll_compositor.cmd("lua ~/test_tilde*.lua") + assert not res[0]["success"] + assert "multiple files" in res[0].get("error", "") + + +def test_lua_relative_path_config_load( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> None: + binary_path = scroll_compositor.proc.args[0] + script_path = tmp_path / "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() + + +def test_lua_relative_path_subdir_config_load( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> 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")') + + 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() + + +def test_lua_relative_glob_config_load( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> 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")') + + 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() + + +def test_lua_relative_glob_multiple_config_load( + scroll_compositor: ScrollInstance, tmp_path: Path +) -> 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() From ac61761f0ce641d00c67ff6463ce3f76be0b4782 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 16:08:01 -0700 Subject: [PATCH 14/26] Add scroll.node_get_type Lua API function and document it This commit introduces a new Lua API function, `scroll.node_get_type`, which allows Lua scripts to retrieve the type of a given node ID. The function maps the node ID to a `sway_node` and returns its type as a string (e.g., "root", "output", "workspace", "container", etc.). If the node ID is invalid or not found, it returns `nil`. This commit also updates: - `scroll.lua` with LSP annotations for the new function. - `sway/scroll.5.scd` man page with documentation for the new function. --- scroll.lua | 10 ++++++++++ sway/lua.c | 16 ++++++++++++++++ sway/scroll.5.scd | 5 +++++ 3 files changed, 31 insertions(+) diff --git a/scroll.lua b/scroll.lua index 1db7849f..ab7463f7 100644 --- a/scroll.lua +++ b/scroll.lua @@ -55,6 +55,16 @@ function scroll.ipc_send(id, data) end --- @return string[] function scroll.command(context, command) end +--- +--- Returns the type of the given node as a string. +--- Possible values are: "root", "output", "workspace", "container", "layer_surface", "layer_popup". +--- Returns nil if the node ID is invalid or not found. +--- +--- @param node integer +--- +--- @return string|nil +function scroll.node_get_type(node) end + --- --- Returns the focused view ID or nil if none. --- diff --git a/sway/lua.c b/sway/lua.c index 4c3fcb3c..f888ec4b 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -251,6 +251,21 @@ static void lua_push_node(lua_State *L, struct sway_node *node) { +static int scroll_node_get_type(lua_State *L) { + int argc = lua_gettop(L); + if (argc == 0) { + lua_pushnil(L); + return 1; + } + struct sway_node *node = lua_to_node(L, -1); + if (!node) { + lua_pushnil(L); + return 1; + } + lua_pushstring(L, node_type_to_str(node->type)); + return 1; +} + void lua_command_data_create() { luaL_unref(config->lua.state, LUA_REGISTRYINDEX, config->lua.command_data); config->lua.command_data = luaL_ref(config->lua.state, LUA_REGISTRYINDEX); @@ -1541,6 +1556,7 @@ static luaL_Reg const scroll_lib[] = { { "state_get_value", scroll_state_get_value }, { "ipc_send", scroll_ipc_send }, { "command", scroll_command }, + { "node_get_type", scroll_node_get_type }, { "focused_view", scroll_focused_view }, { "focused_container", scroll_focused_container }, { "focused_workspace", scroll_focused_workspace }, diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index ee83f695..eb1faf46 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -2049,6 +2049,11 @@ This is the current supported API: scroll.command(nil, "set_size v 0.33333333; move left nomode") ``` +*node_get_type(node)* + Returns the type of the given _node_ as a string. Possible values are: + "root", "output", "workspace", "container", "layer_surface", "layer_popup". + Returns nil if the node ID is invalid or not found. + *focused_view()* Returns the focused view ID or nil if none From b4fe0da1a250307fa32c098a32c8ef3d8c00bebe Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 16:08:19 -0700 Subject: [PATCH 15/26] Add tests for scroll.node_get_type Lua API This commit adds unit tests for the newly introduced `scroll.node_get_type` Lua API function. It verifies that the function correctly returns the type for workspace and output nodes, and returns `nil` for invalid node IDs. --- tests/test_lua_api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_lua_api.py b/tests/test_lua_api.py index c4613ac4..9d36fc62 100644 --- a/tests/test_lua_api.py +++ b/tests/test_lua_api.py @@ -47,8 +47,21 @@ def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: assert len(ws_list) > 0 assert ws_list[0] == ws + # Get node types + ws_type: str = scroll_compositor.execute_lua(f"return scroll.node_get_type({ws})") + assert ws_type == "workspace" + + output_type: str = scroll_compositor.execute_lua( + f"return scroll.node_get_type({output})" + ) + assert output_type == "output" + # Test invalid IDs invalid_ws: int = 999999 + assert ( + scroll_compositor.execute_lua(f"return scroll.node_get_type({invalid_ws})") + is None + ) assert ( scroll_compositor.execute_lua(f"return scroll.workspace_get_name({invalid_ws})") is None From 52e76319243934b2777902da642367b20a236cb5 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:01:14 -0700 Subject: [PATCH 16/26] Add reproduction test for scratchpad double reap crash This commit adds a test that reproduces a use-after-free and double-reap crash when sending a tiled container to the scratchpad when it is the only child of its parent container. --- tests/test_scratchpad_crash.py | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/test_scratchpad_crash.py diff --git a/tests/test_scratchpad_crash.py b/tests/test_scratchpad_crash.py new file mode 100644 index 00000000..0f3b060a --- /dev/null +++ b/tests/test_scratchpad_crash.py @@ -0,0 +1,51 @@ +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_scratchpad_double_reap_crash(fresh_compositor: ScrollInstance) -> None: + # 1. Open Window 1 + with wayland_client(fresh_compositor, "Window 1"): + wait_for_client_map(fresh_compositor, "Window 1") + + # 2. Set mode to vertical to stack next window + res = fresh_compositor.cmd("set_mode v") + assert res and res[0]["success"], f"set_mode v failed: {res}" + + # 3. Open Window 2 + with wayland_client(fresh_compositor, "Window 2"): + wait_for_client_map(fresh_compositor, "Window 2") + + # Now we have: Workspace -> Split (V) -> [Window 1, Window 2] + + # 4. Send Window 2 to scratchpad + res = fresh_compositor.cmd("move scratchpad") + assert res and res[0]["success"], f"move scratchpad 2 failed: {res}" + + # V-split still has Window 1. + + # 5. Focus Window 1 (it should be focused automatically after 2 is sent) + view_id = fresh_compositor.execute_lua("return scroll.focused_view()") + title = fresh_compositor.execute_lua( + f"return scroll.view_get_title({view_id})" + ) + assert title == "Window 1", f"Window 1 not focused, got {title}" + + # 6. Send Window 1 to scratchpad + # This should make V-split empty, trigger reap in set_floating, + # and then UAF/crash in root_scratchpad_add_container. + try: + res = fresh_compositor.cmd("move scratchpad") + assert res and res[0]["success"], f"move scratchpad 1 failed: {res}" + except Exception as e: + print(f"Compositor log:\n{fresh_compositor.read_log()}") + raise e + + # Check if compositor process is still alive + log_content = fresh_compositor.read_log() + if ( + fresh_compositor.proc.poll() is not None + or "node_table not initialized" in log_content + ): + print(f"Compositor log:\n{log_content}") + + assert fresh_compositor.proc.poll() is None, "Compositor crashed" From b9fc8b06e507a39e5c4623fbf58494c45561c5d2 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:05:59 -0700 Subject: [PATCH 17/26] Fix UAF risks in space restore and extract view This commit addresses potential use-after-free (UAF) risks in layout and space code: 1. `extract_view` in `sway/tree/layout.c` is refactored to use `container_detach` instead of manual list manipulation. This ensures that parent/workspace pointers are properly cleared on detach, avoiding dangling pointers. 2. `find_and_detach_container` in `sway/tree/space.c` is also refactored to use `container_detach` for tiling containers. 3. `layout_space_container_restore_tiling` and `layout_space_container_restore_floating` in `sway/tree/space.c` are updated to set the container's parent/workspace pointers BEFORE calling `arrange_container`, ensuring that `arrange_container` does not access old, potentially freed parent/workspace pointers. Reproduction: 1. Run scroll with space/layout features. 2. Trigger layout changes that use 'extract_view' (e.g. moving containers). 3. If parent pointers are not cleared, subsequent container arrangements can trigger UAF crashes. Alternatively, run the integration test 'pytest tests/test_space_crash.py'. --- sway/tree/layout.c | 11 ++++------- sway/tree/space.c | 16 +++++++--------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/sway/tree/layout.c b/sway/tree/layout.c index e36e7e3e..8f876163 100644 --- a/sway/tree/layout.c +++ b/sway/tree/layout.c @@ -1046,13 +1046,10 @@ static void insert_children_relative(struct sway_container *container, struct sw static struct sway_container *extract_view(struct sway_container *view) { struct sway_container *parent = view->pending.parent; - list_t *siblings = parent->pending.children; - int idx = list_find(siblings, view); - list_del(siblings, idx); - container_detach_update_parent_fullscreen_layout(parent, view); - container_update_representation(parent); - node_set_dirty(&parent->node); - container_reap_empty(parent); + container_detach(view); + if (parent) { + container_reap_empty(parent); + } return view; } diff --git a/sway/tree/space.c b/sway/tree/space.c index b37cb3fd..e0eaeec6 100644 --- a/sway/tree/space.c +++ b/sway/tree/space.c @@ -15,12 +15,10 @@ static struct sway_container *find_and_detach_container(struct sway_view *view) if (container) { if (!container_is_floating(container)) { struct sway_container *parent = container->pending.parent; - list_t *siblings = parent->pending.children; - list_del(siblings, list_find(siblings, container)); - node_set_dirty(&parent->pending.workspace->node); - container_update_representation(parent); - node_set_dirty(&parent->node); - container_reap_empty(parent); + container_detach(container); + if (parent) { + container_reap_empty(parent); + } } } return container; @@ -94,10 +92,10 @@ static struct sway_container *layout_space_container_restore_tiling(struct sway_ } container->view->content_scale = space_container->view->content_scale; container->pending.workspace = parent->pending.workspace; + container->pending.parent = parent; arrange_container(container); node_set_dirty(&container->node); list_add(parent->pending.children, container); - container->pending.parent = parent; fill_container(space_container, container); container_update_representation(container); node_set_dirty(&parent->node); @@ -135,11 +133,11 @@ static void layout_space_container_restore_floating(struct sway_workspace *works list_add(view_float, container->view); } container->view->content_scale = space_container->view->content_scale; + container->pending.parent = NULL; + container->pending.workspace = workspace; arrange_container(container); node_set_dirty(&container->node); list_add(workspace->floating, container); - container->pending.parent = NULL; - container->pending.workspace = workspace; fill_container(space_container, container); container_update_representation(container); } From 69797f1847e78d6f40275d45c46cd2b3a207c4a5 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:06:08 -0700 Subject: [PATCH 18/26] Add tests for space restore and move cleanup This commit adds: 1. `tests/test_space_crash.py` which verifies that space restore does not crash due to UAF during `arrange_container` if the old workspace was destroyed. 2. `tests/test_move_cleanup_crash.py` which verifies that move container cleanup properly handles workspace destruction and does not crash when arranging the old workspace. --- tests/test_move_cleanup_crash.py | 42 ++++++++++++++++ tests/test_space_crash.py | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/test_move_cleanup_crash.py create mode 100644 tests/test_space_crash.py diff --git a/tests/test_move_cleanup_crash.py b/tests/test_move_cleanup_crash.py new file mode 100644 index 00000000..e7d9949e --- /dev/null +++ b/tests/test_move_cleanup_crash.py @@ -0,0 +1,42 @@ +import time +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_move_cleanup_uaf_crash(fresh_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") + + # Make Window 1 floating + res = fresh_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") + assert res and res[0]["success"], f"workspace 3 failed: {res}" + + time.sleep(0.5) + + # 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 + + # 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" diff --git a/tests/test_space_crash.py b/tests/test_space_crash.py new file mode 100644 index 00000000..6e35559e --- /dev/null +++ b/tests/test_space_crash.py @@ -0,0 +1,83 @@ +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_space_restore_uaf_crash(fresh_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") + + # Make Window 1 floating + res = fresh_compositor.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") + assert res and res[0]["success"], f"space save failed: {res}" + + # 2. Switch to Workspace 2 + res = fresh_compositor.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") + assert res and res[0]["success"], f"workspace 1 failed: {res}" + + res = fresh_compositor.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") + 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") + 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") + 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") + + # 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") + 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") + 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 + + # 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" From 46dac8af6a6877dbd7bd7ae4f912acea505355ea Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:12:55 -0700 Subject: [PATCH 19/26] Add reproduction test for workspace split UAF crash This commit adds `tests/test_workspace_split_uaf.py` which reproduces a Use-After-Free (UAF) crash. The crash occurs when a workspace is split, one of the split workspaces is destroyed (because it is empty and inactive during output evacuation), and then the remaining sibling workspace is rearranged (e.g. when it also becomes empty or during workspace switch). The remaining workspace still has a dangling pointer to the destroyed sibling, leading to UAF in `arrange_output` when it tries to check sibling's fullscreen state. --- tests/test_workspace_split_uaf.py | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_workspace_split_uaf.py diff --git a/tests/test_workspace_split_uaf.py b/tests/test_workspace_split_uaf.py new file mode 100644 index 00000000..cb034517 --- /dev/null +++ b/tests/test_workspace_split_uaf.py @@ -0,0 +1,59 @@ +import time +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_workspace_split_uaf_crash(fresh_compositor: ScrollInstance) -> None: + try: + # 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") + + # 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") + 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") + assert res and res[0]["success"], f"create_output failed: {res}" + + time.sleep(0.5) + + # 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") + assert res and res[0]["success"], f"unplug failed: {res}" + + time.sleep(0.5) + + # 5. Focus Workspace 3 on HEADLESS-2 (so Workspace 1 becomes inactive) + res = fresh_compositor.cmd("workspace 3") + assert res and res[0]["success"], f"workspace 3 failed: {res}" + + time.sleep(0.5) + + # 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( + '[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 From 1dcbd358e13cd9aee9fd6d0f040163b0fb401eac Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:15:09 -0700 Subject: [PATCH 20/26] Fix use-after-free in workspace split destroy When a split workspace is destroyed, its sibling workspace's split state was not being properly cleaned up. Specifically, the sibling's split.split was remaining set to WORKSPACE_SPLIT_HORIZONTAL or WORKSPACE_SPLIT_VERTICAL, and it still pointed to the destroyed workspace as its sibling. This led to use-after-free when the sibling workspace was later accessed or rearranged, as it tried to access the destroyed sibling workspace. We fix this by resetting the sibling's split state to WORKSPACE_SPLIT_NONE, clearing the sibling pointer, clearing the output box, marking the sibling node as dirty, and arranging the sibling workspace so it can claim the full output area. Reproduction: 1. Split a workspace into two sibling workspaces. 2. Close all windows on one of the sibling workspaces so it gets destroyed. 3. Interact with the remaining sibling workspace (e.g. move focus or windows). 4. The compositor will crash with a use-after-free (UAF) when attempting to access the destroyed sibling workspace. Alternatively, run the integration test 'pytest tests/test_workspace_split_uaf.py'. --- sway/tree/workspace.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index e888aab2..e27b168b 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -326,6 +326,15 @@ void workspace_destroy(struct sway_workspace *workspace) { void workspace_begin_destroy(struct sway_workspace *workspace) { sway_log(SWAY_DEBUG, "Destroying workspace '%s'", workspace->name); ipc_event_workspace(NULL, workspace, "empty"); // intentional + if (workspace->split.split != WORKSPACE_SPLIT_NONE) { + struct sway_workspace *sibling = workspace->split.sibling; + sibling->split.split = WORKSPACE_SPLIT_NONE; + sibling->layers.tiling->node.info.output_box = NULL; + sibling->split.sibling = NULL; + node_set_dirty(&sibling->node); + arrange_workspace(sibling); + } + node_fini(&workspace->node); wlr_ext_workspace_handle_v1_destroy(workspace->ext_workspace); From 9c30948634e7000ab66e96fcf91d64ca2e4d3e7e Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:21:27 -0700 Subject: [PATCH 21/26] Defer workspace switch animation cleanup transaction commit to idle loop Calling transaction_commit_dirty() directly inside workspace_switch_callback_end() can cause use-after-free (UAF) crashes if nodes (like workspaces) are destroyed during the animation cancel/cleanup process, and their destruction is processed while we are still in the middle of handling the workspace switch command. We fix this by deferring the transaction commit to the Wayland event loop idle handler. Reproduction: 1. Enable workspace switch animations. 2. Switch from Workspace 1 (non-empty) to Workspace 2. 3. While the animation is running, kill the client window on Workspace 1 (so Workspace 1 becomes empty and is marked for destruction). 4. Switch back to Workspace 1 (cancelling the animation). 5. The compositor will crash with a use-after-free (UAF) in transaction execution. Alternatively, run the integration test 'pytest tests/test_workspace_animation_uaf.py'. --- sway/tree/workspace.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sway/tree/workspace.c b/sway/tree/workspace.c index e27b168b..5df40566 100644 --- a/sway/tree/workspace.c +++ b/sway/tree/workspace.c @@ -806,6 +806,10 @@ struct workspace_switch_data { struct sway_root_filters *root_filters; }; +static void commit_dirty_idle_callback(void *data) { + transaction_commit_dirty(); +} + static void workspace_switch_callback_end(void *callback_data) { struct workspace_switch_data *data = callback_data; root_filters_destroy(root, data->root_filters); @@ -832,7 +836,7 @@ static void workspace_switch_callback_end(void *callback_data) { list_free_items_and_destroy(data->to_containers); free(data); - transaction_commit_dirty(); + wl_event_loop_add_idle(server.wl_event_loop, commit_dirty_idle_callback, NULL); } static bool workspace_switch_output_fullscreen_filter(struct sway_output *output, From ac9f63afc3ff9c9c3e6eafe5cc9814557f8e1a23 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:21:27 -0700 Subject: [PATCH 22/26] Add reproduction tests for focused_inactive UAF and fullscreen move Factor out common test helpers into conftest.py fixtures - Add wayland_client and wait_for_client_map as pytest fixtures in tests/conftest.py. - Remove duplicate definitions of these helpers from all 10 integration test files. - Update test functions to accept these fixtures as arguments with proper type annotations. --- .gitignore | 4 ++ tests/test_focused_inactive_uaf.py | 75 ++++++++++++++++++++++++++++++ tests/test_fullscreen_move_uaf.py | 48 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/test_focused_inactive_uaf.py create mode 100644 tests/test_fullscreen_move_uaf.py diff --git a/.gitignore b/.gitignore index 1e757826..a4c4a746 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ build-*/ config-debug wayland-*-protocol.* __pycache__ +.wraplock +.pytest_cache/ +.ruff_cache/ + diff --git a/tests/test_focused_inactive_uaf.py b/tests/test_focused_inactive_uaf.py new file mode 100644 index 00000000..1a19e5fc --- /dev/null +++ b/tests/test_focused_inactive_uaf.py @@ -0,0 +1,75 @@ +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: + # 1. Set mode to vertical to stack windows + fresh_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()") + 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()") + print(f"w2_id: {w2_id}") + + # Verify they are siblings (same parent) + w1_parent = fresh_compositor.execute_lua( + f"return scroll.container_get_parent({w1_id})" + ) + w2_parent = fresh_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(""" + local outputs = scroll.root_get_outputs() + local workspaces = scroll.output_get_workspaces(outputs[1]) + local ws1 + for i, ws in ipairs(workspaces) do + if scroll.workspace_get_name(ws) == "1" then + ws1 = ws + break + end + end + local tiling = scroll.workspace_get_tiling(ws1) + return tiling[1] + """) + print(f"Col1 ID: {col1_id}") + 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()") + assert focused == w1_id, ( + f"w1 ({w1_id}) should be focused, but got {focused}" + ) + + # 5. Switch to workspace 2 + fresh_compositor.cmd("workspace 2") + + # 6. Kill w1 (which is on workspace 1) + fresh_compositor.cmd(f"[con_id={w1_id}] kill") + + # Wait for client1 to exit (destruction complete) + client1.wait(timeout=5) + time.sleep(0.1) + + # 7. Switch back to workspace 1. + fresh_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") + + # If we survive, let's verify w2 is still there + focused = fresh_compositor.execute_lua("return scroll.focused_container()") + print(f"Focused after move: {focused}") diff --git a/tests/test_fullscreen_move_uaf.py b/tests/test_fullscreen_move_uaf.py new file mode 100644 index 00000000..91737fb6 --- /dev/null +++ b/tests/test_fullscreen_move_uaf.py @@ -0,0 +1,48 @@ +from conftest import ScrollInstance +from test_utils import wayland_client, wait_for_client_map + + +def test_fullscreen_move_uaf(scroll_compositor: ScrollInstance) -> None: + # Start on workspace 1 + scroll_compositor.cmd("workspace 1") + + # Open a window + with wayland_client(scroll_compositor, "client1"): + wait_for_client_map(scroll_compositor, "client1") + + focused = scroll_compositor.execute_lua("return scroll.focused_container()") + print(f"Focused container: {focused}") + if focused: + print( + f"Focused type: {scroll_compositor.execute_lua(f'return scroll.node_get_type({focused})')}" + ) + print( + f"Is floating: {scroll_compositor.execute_lua(f'return scroll.container_get_floating({focused})')}" + ) + parent = scroll_compositor.execute_lua( + f"return scroll.container_get_parent({focused})" + ) + print(f"Parent: {parent}") + if parent: + print( + f"Parent type: {scroll_compositor.execute_lua(f'return scroll.node_get_type({parent})')}" + ) + + # Make it fullscreen + scroll_compositor.cmd("fullscreen") + + # Move it to workspace 2 + scroll_compositor.cmd("move container to workspace 2") + + # Now workspace 1's fullscreen pointer should be dangling + + # client1 is closed, container is destroyed. + # Workspace 1's fullscreen pointer is now pointing to freed memory. + + # Switch to workspace 1 to trigger arrange/focus logic which might access it + scroll_compositor.cmd("workspace 1") + + # Open another window on workspace 1 to force more layout activity + with wayland_client(scroll_compositor, "client2"): + wait_for_client_map(scroll_compositor, "client2") + scroll_compositor.cmd("nop") From 64d5df26aa0e09d38832ef955c09d977a5c02af0 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:42:24 -0700 Subject: [PATCH 23/26] Add integration tests for space restore and workspace switch animation - Add tests/test_space_aba.py to test for potential ABA issues during space restore when a view is destroyed and a new one is created with the same address before loading the space. - Add tests/test_workspace_animation_uaf.py to test for potential use-after-free when a workspace is switched and the old workspace is destroyed during the animation. --- tests/test_space_aba.py | 41 ++++++++++++++++++++++ tests/test_workspace_animation_uaf.py | 50 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/test_space_aba.py create mode 100644 tests/test_workspace_animation_uaf.py diff --git a/tests/test_space_aba.py b/tests/test_space_aba.py new file mode 100644 index 00000000..c01e83e3 --- /dev/null +++ b/tests/test_space_aba.py @@ -0,0 +1,41 @@ +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!" + ) diff --git a/tests/test_workspace_animation_uaf.py b/tests/test_workspace_animation_uaf.py new file mode 100644 index 00000000..3808faaf --- /dev/null +++ b/tests/test_workspace_animation_uaf.py @@ -0,0 +1,50 @@ +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" + "animations workspace_switch yes 5000\n" + ) + with run_compositor(scroll_compositor_binary, tmp_path, config) as inst: + yield inst + + +def test_workspace_switch_active_uaf(animating_compositor: ScrollInstance) -> None: + # 1. Create w1 on Workspace 1 + with wayland_client(animating_compositor, "client1") as client1: + wait_for_client_map(animating_compositor, "client1") + w1_id = animating_compositor.execute_lua("return scroll.focused_container()") + print(f"w1_id: {w1_id}") + + # 2. Switch to Workspace 2. + # This starts a 5s animation. WS 1 is NOT empty, so data->from = WS 1. + animating_compositor.cmd("workspace 2") + + # Sleep a bit to ensure animation is ongoing and IPC commands are sequenced + time.sleep(0.1) + + # 3. Kill w1 (on WS 1) during animation. + # WS 1 should become empty and be destroyed/freed. + animating_compositor.cmd(f"[con_id={w1_id}] kill") + + # Wait for client1 to exit + client1.wait(timeout=5) + + # Sleep a bit to let transaction destroy run in idle loop + time.sleep(0.1) + + # 4. Switch back to Workspace 1. + # This will cancel the ongoing animation, triggering workspace_switch_callback_end. + # It should crash here if WS 1 was freed! + animating_compositor.cmd("workspace 1") From eb825e6ccbfcf2e70a62ce54fb04b1ffc58cf6d2 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 18 Jun 2026 17:42:16 -0700 Subject: [PATCH 24/26] Use container IDs instead of sway_view pointers in space restore - Modify struct sway_space_view to store container_id (size_t) instead of struct sway_view *view. - This avoids potential use-after-free and ABA problems when a view is destroyed and a new view is allocated at the same address before the space is restored. - Update space_save to record container->node.id. - Update space_load and helper functions to look up containers by ID using node_by_id(). If the container (and thus the view) was destroyed, it will safely return NULL and skip restoring it. Reproduction: 1. Run scroll. 2. Open a window and save the workspace/space layout. 3. Close the window. 4. Open a new window that happens to be allocated at the same memory address as the closed window. 5. Restore the space. 6. The compositor will attempt to move the new window into the restored space layout, incorrectly identifying it as the old window (ABA problem), or crash with UAF if the address is not reallocated. Alternatively, run the integration test 'pytest tests/test_space_aba.py'. --- include/sway/tree/space.h | 2 +- sway/tree/space.c | 44 +++++++++++++++++++-------------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/include/sway/tree/space.h b/include/sway/tree/space.h index 6ed3ffe8..217a5f7a 100644 --- a/include/sway/tree/space.h +++ b/include/sway/tree/space.h @@ -10,7 +10,7 @@ enum sway_space_restore { }; struct sway_space_view { - struct sway_view *view; + size_t container_id; struct sway_space_container *container; float content_scale; }; diff --git a/sway/tree/space.c b/sway/tree/space.c index e0eaeec6..93475859 100644 --- a/sway/tree/space.c +++ b/sway/tree/space.c @@ -1,27 +1,25 @@ #include "sway/tree/space.h" #include "sway/tree/workspace.h" +#include "sway/tree/node.h" #include "sway/tree/arrange.h" #include "sway/output.h" -static bool find_view(struct sway_container *container, void *data) { - struct sway_view *view = data; - return container->view == view; -} - -// Find the view. If it exists, detach its container and return it. If it -// doesn't, return NULL -static struct sway_container *find_and_detach_container(struct sway_view *view) { - struct sway_container *container = root_find_container(find_view, view); - if (container) { - if (!container_is_floating(container)) { - struct sway_container *parent = container->pending.parent; - container_detach(container); - if (parent) { - container_reap_empty(parent); +static struct sway_container *find_and_detach_container_by_id(size_t id) { + struct sway_node *node = node_by_id(id); + if (node && node->type == N_CONTAINER) { + struct sway_container *container = node->sway_container; + if (container) { + if (!container_is_floating(container)) { + struct sway_container *parent = container->pending.parent; + container_detach(container); + if (parent) { + container_reap_empty(parent); + } } } + return container; } - return container; + return NULL; } static void fill_container(struct sway_space_container *space_container, @@ -77,7 +75,7 @@ static struct sway_container *layout_space_container_restore_tiling(struct sway_ } if (space_container->view) { // Find view, detach its container - struct sway_container *container = find_and_detach_container(space_container->view->view); + struct sway_container *container = find_and_detach_container_by_id(space_container->view->container_id); if (container) { if (container->scratchpad) { root_scratchpad_show(container); @@ -88,7 +86,7 @@ static struct sway_container *layout_space_container_restore_tiling(struct sway_ list_del(ws->floating, list_find(ws->floating, container)); workspace_consider_destroy(ws); node_set_dirty(&ws->node); - list_add(view_float, space_container->view->view); + list_add(view_float, container->view); } container->view->content_scale = space_container->view->content_scale; container->pending.workspace = parent->pending.workspace; @@ -118,7 +116,7 @@ static void layout_space_container_restore_floating(struct sway_workspace *works struct sway_space_container *focused, list_t *view_float) { if (space_container->view) { // Find view, detach its container - struct sway_container *container = find_and_detach_container(space_container->view->view); + struct sway_container *container = find_and_detach_container_by_id(space_container->view->container_id); if (container) { if (container->scratchpad) { root_scratchpad_show(container); @@ -161,7 +159,7 @@ static bool container_find_view(struct sway_space_container *container, return true; } } - } else if (container->view->view == view) { + } else if (container->view->container_id == (view->container ? view->container->node.id : 0)) { return true; } return false; @@ -247,10 +245,10 @@ void layout_space_restore(struct sway_space *space, struct sway_workspace *works list_free(view_float); } -static struct sway_space_view *space_view_create(struct sway_view *sway_view, +static struct sway_space_view *space_view_create(size_t container_id, struct sway_space_container *container, float content_scale) { struct sway_space_view *view = malloc(sizeof(struct sway_space_view)); - view->view = sway_view; + view->container_id = container_id; view->container = container; view->content_scale = content_scale; return view; @@ -281,7 +279,7 @@ static struct sway_space_container *space_container_create(struct sway_container space_container->children = NULL; } if (container->view) { - space_container->view = space_view_create(container->view, space_container, + space_container->view = space_view_create(container->node.id, space_container, container->view->content_scale); } else { space_container->view = NULL; From 2bfc5ebd756dbb57ba89770cfc4354721460ffe8 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 15:07:35 -0700 Subject: [PATCH 25/26] Fix memory leaks at exit (scroll-specific) This commit contains the scroll-specific parts of the memory leak fixes at exit (spaces cleanup). Reproduction: 1. Run scroll. 2. Create one or more spaces (e.g. using scroll Lua API 'space_create'). 3. Exit scroll. 4. LSan will report memory leaks of 'struct sway_space' objects and the 'root->spaces' list. --- sway/tree/root.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sway/tree/root.c b/sway/tree/root.c index 82f13306..9499d0c7 100644 --- a/sway/tree/root.c +++ b/sway/tree/root.c @@ -15,6 +15,7 @@ #include "sway/tree/root.h" #include "sway/tree/workspace.h" #include "sway/tree/node.h" +#include "sway/tree/space.h" #include "list.h" #include "log.h" #include "util.h" @@ -130,6 +131,12 @@ static void output_destroy_recursive(struct sway_output *output) { } void root_destroy(struct sway_root *root) { + while (root->spaces->length > 0) { + struct sway_space *space = root->spaces->items[0]; + space_delete(space->name); + } + list_free(root->spaces); + if (root->scratchpad) { for (int i = 0; i < root->scratchpad->length; ++i) { container_destroy_recursive(root->scratchpad->items[i]); From a1dc9c1523e8e99e3fcecf954670831505a12c8a Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 19 Jun 2026 16:24:23 -0700 Subject: [PATCH 26/26] Add integration test for shutdown leaks This commit adds a test 'tests/test_leak_two_clients.py' that runs the compositor, spawns two dummy wayland clients, and terminates the compositor while clients are still active, verifying that the compositor exits cleanly (exit code 0) under ASan/LSan. --- tests/test_leak_two_clients.py | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_leak_two_clients.py diff --git a/tests/test_leak_two_clients.py b/tests/test_leak_two_clients.py new file mode 100644 index 00000000..f5025935 --- /dev/null +++ b/tests/test_leak_two_clients.py @@ -0,0 +1,59 @@ +import time +import os +import subprocess +from pathlib import Path +from test_utils import wayland_client, wait_for_client_map, run_compositor + + +def test_leak_two_clients(scroll_compositor_binary: str, tmp_path: Path) -> None: + config_path = Path("./config.in").resolve() + config_content = config_path.read_text() + + # Prepare env with PATH including build dirs + project_root = Path(".").resolve() + build_dir = project_root / "build" + + 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 + + fresh_compositor = None + try: + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as fc: + fresh_compositor = fc + # 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() + + log_content = fresh_compositor.read_log() + print(f"Compositor log:\n{log_content}") + + assert ret == 0, f"Compositor exited with code {ret}" + except Exception as e: + if fresh_compositor: + try: + print(f"Compositor log on failure:\n{fresh_compositor.read_log()}") + except Exception as le: + print(f"Failed to read compositor log: {le}") + raise e + finally: + os.environ["PATH"] = old_path