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