diff --git a/.github/workflows/workspace-checks.yml b/.github/workflows/workspace-checks.yml index c79902b..034af8e 100644 --- a/.github/workflows/workspace-checks.yml +++ b/.github/workflows/workspace-checks.yml @@ -1,4 +1,4 @@ -name: Workspace tests and capture checks +name: Workspace and native stability checks on: pull_request: paths: @@ -56,14 +56,34 @@ jobs: sudo apt-get update sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \ libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \ - libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \ + python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \ clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules - name: Create private test runtime directory run: install -d -m 0700 "$XDG_RUNTIME_DIR" - name: Install Rust - run: rustup toolchain install 1.95.0 --profile minimal + run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy + - name: Check formatting and all workspace lints + run: | + cargo +1.95.0 fmt --all --check + cargo +1.95.0 clippy --locked --workspace --all-targets -- -D warnings + - name: Build desktop binaries + run: cargo +1.95.0 build --locked --workspace - name: Test every workspace package run: cargo +1.95.0 test --locked --workspace --no-fail-fast -- --nocapture + - name: Validate every Settings control against the compositor + env: + FERESE_TEST_BINARY: ${{ github.workspace }}/target/debug/ferese + run: cargo +1.95.0 test --locked -p ferese-settings all_gui_controls_and_presets_pass_compositor_validation -- --ignored --nocapture + - name: Fetch pinned upstream portal contracts + uses: actions/checkout@v6 + with: + repository: flatpak/xdg-desktop-portal + ref: 23a76c392170dbbd26230f85ef56c3a57e52b857 # 1.20.3 + path: build/portal-contracts + - name: Test installer, session recovery and private portal contracts + env: + FERESE_TEST_PORTAL_INTERFACES: ${{ github.workspace }}/build/portal-contracts/data + run: python3 -m unittest discover -s scripts/tests -v - name: Test capture privacy, resize dependencies and cursor composition env: LIBGL_ALWAYS_SOFTWARE: "1" @@ -72,3 +92,64 @@ jobs: cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture + - name: Share desktop binaries with native checks + uses: actions/upload-artifact@v7 + with: + name: native-check-binaries + path: | + target/debug/ferese + target/debug/ferese-shell + target/debug/feresectl + if-no-files-found: error + retention-days: 1 + native: + name: Native panel and XWayland checks + needs: workspace + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CARGO_BUILD_JOBS: "2" + CARGO_PROFILE_DEV_DEBUG: "0" + LIBGL_ALWAYS_SOFTWARE: "1" + steps: + - uses: actions/checkout@v6 + - name: Install native test dependencies + run: | + sudo apt-get update + sudo apt-get install -y weston xwayland x11-utils xauth dbus-daemon \ + build-essential pkg-config clang libclang-dev libxcb1-dev libxcb-cursor-dev libxkbcommon-dev \ + libwayland-dev libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \ + libegl1 libegl-mesa0 libgl1-mesa-dri fonts-dejavu-core + - name: Create the shared X11 socket directory + run: sudo install -d -m 1777 -o root -g root /tmp/.X11-unix + - uses: actions/download-artifact@v8 + with: + name: native-check-binaries + path: target/debug + - name: Restore executable permissions + run: chmod +x target/debug/ferese target/debug/ferese-shell target/debug/feresectl + - name: Install pinned Rust + run: rustup toolchain install 1.95.0 --profile minimal + - name: Build pinned XWayland Satellite with readiness support + uses: actions/checkout@v6 + with: + repository: Supreeeme/xwayland-satellite + ref: b83eab900644e4c7c77982ce3d44cb490f0c5e1d # v0.8.3 + path: build/xwayland-satellite + - name: Build XWayland Satellite + run: cargo +1.95.0 build --manifest-path build/xwayland-satellite/Cargo.toml --locked --features systemd + - name: Run isolated native checks on a headless Wayland host + env: + FERESE_TEST_BINARY: target/debug/ferese + FERESE_TEST_SHELL: target/debug/ferese-shell + FERESE_TEST_CTL: target/debug/feresectl + FERESE_TEST_SATELLITE: build/xwayland-satellite/target/debug/xwayland-satellite + run: python3 scripts/ci/run-native-checks.py --log-dir build/native-check-logs + - name: Save native test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-check-logs + path: build/native-check-logs + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/ci/run-native-checks.py b/scripts/ci/run-native-checks.py new file mode 100644 index 0000000..1ccb46f --- /dev/null +++ b/scripts/ci/run-native-checks.py @@ -0,0 +1,66 @@ +"""Run native regressions against a private, headless Weston instance.""" +import argparse +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--log-dir", type=Path, required=True) + args = parser.parse_args() + logs = args.log_dir.resolve() + logs.mkdir(parents=True, exist_ok=True) + repo = Path(__file__).resolve().parents[2] + + # No inherited session endpoints or activation settings reach the test host. + env = {key: value for key, value in os.environ.items() + if not key.startswith("FERESE_") or key.startswith("FERESE_TEST_")} + for key in ("DISPLAY", "WAYLAND_DISPLAY", "WAYLAND_SOCKET", "XAUTHORITY", + "DBUS_SESSION_BUS_ADDRESS", "DBUS_SESSION_BUS_PID", "EGL_PLATFORM"): + env.pop(key, None) + env.update(LIBGL_ALWAYS_SOFTWARE="1", FERESE_TEST_LOG_DIR=str(logs)) + + with tempfile.TemporaryDirectory(prefix="ferese-native-host-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY="ferese-test-host", + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache")) + with (logs / "weston.log").open("w") as log: + host = subprocess.Popen([ + env.get("FERESE_TEST_WESTON", "weston"), "--backend=headless-backend.so", + "--renderer=pixman", "--width=1600", "--height=1000", + "--socket=ferese-test-host", "--idle-time=0", "--no-config", + ], env=env, stdout=log, stderr=log, start_new_session=True) + try: + deadline = time.monotonic() + 15 + while not (runtime / env["WAYLAND_DISPLAY"]).is_socket(): + if host.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError((logs / "weston.log").read_text()) + time.sleep(.05) + for layout in ("continuous", "islands"): + subprocess.run([ + sys.executable, "scripts/tests/test_shell_motion_isolated.py", "-v", + ], cwd=repo, env=dict(env, FERESE_TEST_SHELL_MOTION="1", + FERESE_TEST_BAR_LAYOUT=layout), check=True) + subprocess.run([ + sys.executable, "scripts/tests/test_xwayland_isolated.py", "-v", + ], cwd=repo, env=dict(env, FERESE_TEST_XWAYLAND="1"), check=True) + finally: + if host.poll() is None: + os.killpg(host.pid, signal.SIGTERM) + try: + host.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(host.pid, signal.SIGKILL) + host.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index 2fbb377..8a72185 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -14,6 +14,7 @@ from fixtures.theme_owner import ThemeOwner REPO = Path(__file__).resolve().parents[2] BINARY = REPO / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese") +INTERFACES = Path(os.environ.get("FERESE_TEST_PORTAL_INTERFACES", "/usr/share/dbus-1/interfaces")) NAME = "org.freedesktop.impl.portal.desktop.ferese" PATH = "/org/freedesktop/portal/desktop" @@ -59,6 +60,7 @@ def private_bus_checks(standalone=False): env = dict(os.environ, XDG_CONFIG_HOME=directory, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY="wayland-unavailable") env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SOCKET", None) process = subprocess.Popen([str(BINARY)], env=env, stdout=log, stderr=log, start_new_session=True) try: deadline = time.monotonic() + 15 @@ -74,7 +76,7 @@ def private_bus_checks(standalone=False): for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit", "InputCapture"): interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") assert interface is not None, name - expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") + expected = ET.parse(INTERFACES / f"org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") for member in expected: if member.tag not in ("method", "signal", "property"): continue diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py index 97de966..911e8ef 100644 --- a/scripts/tests/test_shell_motion_isolated.py +++ b/scripts/tests/test_shell_motion_isolated.py @@ -10,6 +10,7 @@ from pathlib import Path from session_socket import ipc_environment, ipc_socket import re import signal +import shutil import subprocess import tempfile import time @@ -194,11 +195,11 @@ class ShellMotionTest(unittest.TestCase): "notification materials did not follow their fade") print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True) - subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) modal = wait_for(lambda: layer_surface("ferese-system-modal")) wait_for(lambda: frames(modal) >= 4) time.sleep(.3) - subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) wait_for(lambda: destroyed(modal), timeout=5) self.assertGreater(frames(modal), 10, "modal close did not follow frames") self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) @@ -225,10 +226,10 @@ class ShellMotionTest(unittest.TestCase): for _ in range(40): subprocess.run([str(ctl), "toggle-keybinding-guide"], - env=ipc_environment(env), check=True, capture_output=True) + env=ipc_environment(env), check=True, capture_output=True, timeout=5) time.sleep(.03) subprocess.run([str(ctl), "toggle-keybinding-guide"], - env=ipc_environment(env), check=True, capture_output=True) + env=ipc_environment(env), check=True, capture_output=True, timeout=5) time.sleep(.06) # A final toggle may leave the owned modal closing. Let # that motion finish before deciding whether a live modal @@ -243,14 +244,14 @@ class ShellMotionTest(unittest.TestCase): self.assertLessEqual(len(remaining), 1, "cancelled modals retained native surfaces") if remaining: subprocess.run([str(ctl), "toggle-keybinding-guide"], - env=ipc_environment(env), check=True, capture_output=True) + env=ipc_environment(env), check=True, capture_output=True, timeout=5) wait_for(lambda: destroyed(remaining[0])) print("Modal: rapid toggles released all native surfaces", flush=True) if bar_layout == "islands": for layout_name, expected_count in [("continuous", 1), ("islands", None)]: config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name)) - subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) wait_for(lambda: bar_region_count(bar) == (expected_count or 2)) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) @@ -261,12 +262,33 @@ class ShellMotionTest(unittest.TestCase): source = config_file.read_text() config_file.write_text(re.sub( r'island-padding \d+', f'island-padding {padding}', source)) - subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5) expected_width = original_input[0][2] - 2 * (12 - padding) wait_for(lambda: len(bar_input_rectangles(bar)) == 2 and bar_input_rectangles(bar)[0][2] == expected_width) print("Bar: live padding reductions updated the input regions", flush=True) + saved = config_file.read_text() + config_file.write_text('panel main { surface "invalid-surface"; }\n') + rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), + capture_output=True, text=True, timeout=5) + self.assertNotEqual(rejected.returncode, 0, "invalid reload was accepted") + self.assertIn("invalid_config", rejected.stderr) + self.assertFalse(destroyed(bar), "invalid reload destroyed the current panel") + self.assertEqual(dbus("GetServerInformation", check=False).returncode, 0) + config_file.write_text(saved) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), + check=True, capture_output=True, timeout=5) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), + check=True, capture_output=True, timeout=5) + recovered = wait_for(lambda: (surface := layer_surface("ferese-system-modal")) + and not destroyed(surface) and surface) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), + check=True, capture_output=True, timeout=5) + wait_for(lambda: destroyed(recovered)) + self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) + self.assertNotIn("panicked at", log_path.read_text()) + print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True) finally: for child in reversed(children): if child.poll() is None: @@ -278,6 +300,10 @@ class ShellMotionTest(unittest.TestCase): child.wait(timeout=5) if child.stdout is not None: child.stdout.close() + if directory := os.environ.get("FERESE_TEST_LOG_DIR"): + logs = Path(directory) + logs.mkdir(parents=True, exist_ok=True) + shutil.copyfile(log_path, logs / f"shell-{bar_layout}.log") if __name__ == "__main__": diff --git a/scripts/tests/test_xwayland_isolated.py b/scripts/tests/test_xwayland_isolated.py new file mode 100644 index 0000000..2a4fc72 --- /dev/null +++ b/scripts/tests/test_xwayland_isolated.py @@ -0,0 +1,151 @@ +"""Verify real on-demand/eager XWayland startup and teardown in a nested session. + +FERESE_TEST_XWAYLAND=1 python3 scripts/tests/test_xwayland_isolated.py +Requires a Wayland host, Xwayland, xdpyinfo and Satellite built with systemd support. +""" +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +from session_socket import ipc_environment, ipc_socket + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_XWAYLAND") == "1", "opt-in nested XWayland test") +class XwaylandTest(unittest.TestCase): + def test_startup_authorization_and_owned_process_cleanup(self): + repo = Path(__file__).resolve().parents[2] + binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese") + ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/release/feresectl") + satellite = os.environ.get("FERESE_TEST_SATELLITE") or shutil.which("xwayland-satellite") + self.assertIsNotNone(satellite, "Satellite must be installed; build it with --features systemd") + satellite = str(Path(satellite).resolve()) + self.assertTrue(Path(satellite).is_file(), f"Satellite does not exist: {satellite}") + for mode in ("on-demand", "eager"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory(prefix="ferese-xwayland-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text( + f'xwayland {{ startup "{mode}"; path {json.dumps(satellite)}; }}\n') + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = {key: value for key, value in os.environ.items() + if not key.startswith("FERESE_") or key.startswith("FERESE_TEST_")} + for key in ("WAYLAND_SOCKET", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"): + env.pop(key, None) + env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache")) + # This managed child receives Ferese's reserved display and cookie. + # Waiting for a gate lets us prove on-demand stays idle until an X client connects. + client = root / "client.py" + client.write_text('''import json, os +from pathlib import Path +import subprocess, time +root = Path(__file__).parent +def publish(name, value): + temporary = root / (name + ".tmp") + temporary.write_text(json.dumps(value)) + temporary.replace(root / name) +publish("endpoint.json", { + "display": os.environ["DISPLAY"], "authority": os.environ["XAUTHORITY"]}) +deadline = time.monotonic() + 30 +while not (root / "connect").exists(): + if time.monotonic() >= deadline: + raise SystemExit("X client was never released") + time.sleep(.025) +result = subprocess.run(["xdpyinfo"], capture_output=True, text=True, timeout=15) +publish("client-result.json", { + "code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}) +''') + log_path = root / "compositor.log" + with log_path.open("w") as log: + compositor = subprocess.Popen([ + str(binary), "--backend", "nested", "--", sys.executable, str(client), + ], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) + + def wait_for(predicate, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + if compositor.poll() is not None: + break + time.sleep(.025) + self.fail(log_path.read_text()[-12000:]) + + def command(*args): + return subprocess.run([str(ctl), "-j", *args], env=ipc_environment(env), + check=True, capture_output=True, text=True, timeout=5) + + def status_is(state): + status = json.loads(command("xwayland", "status").stdout) + self.assertNotEqual(status["state"], "failed", status["last_error"]) + return status if status["state"] == state else None + + try: + wait_for(lambda: ipc_socket(runtime).is_socket()) + initial = wait_for(lambda: status_is("idle" if mode == "on-demand" else "running")) + self.assertEqual(initial["effective_startup"], mode) + wait_for(lambda: (root / "endpoint.json").exists()) + endpoint = json.loads((root / "endpoint.json").read_text()) + self.assertEqual(endpoint["display"], initial["display"]) + self.assertTrue(Path(endpoint["authority"]).is_relative_to(runtime)) + (root / "connect").touch() + running = wait_for(lambda: status_is("running"), timeout=10) + self.assertEqual(running["readiness"], "verified") + self.assertEqual(running["recent_failures"], 0) + wait_for(lambda: (root / "client-result.json").exists()) + result = json.loads((root / "client-result.json").read_text()) + self.assertEqual(result["code"], 0, result["stderr"]) + self.assertIn("X.Org", result["stdout"]) + # Record the complete owned service group before requesting a clean exit. + leader = running["satellite_pid"] + members = [] + for proc in Path("/proc").iterdir(): + if not proc.name.isdigit(): + continue + try: + stat = (proc / "stat").read_text() + if int(stat[stat.rfind(")") + 1:].split()[2]) == leader: + members.append(proc) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + self.assertGreaterEqual(len(members), 2, "Satellite and Xwayland must both be running") + command("exit") + compositor.wait(timeout=10) + self.assertEqual(compositor.returncode, 0, log_path.read_text()) + self.assertFalse(any(proc.exists() for proc in members), "owned X11 processes survived shutdown") + self.assertFalse(Path(endpoint["authority"]).exists(), "Xauthority survived shutdown") + slot = endpoint["display"].removeprefix(":") + self.assertFalse(Path(f"/tmp/.X11-unix/X{slot}").exists(), "X11 socket survived shutdown") + self.assertFalse(Path(f"/tmp/.X{slot}-lock").exists(), "X11 display lock survived shutdown") + self.assertNotIn("panicked at", log_path.read_text()) + print(f"XWayland: {mode} startup, authorized client and complete teardown passed", flush=True) + finally: + if compositor.poll() is None: + os.killpg(compositor.pid, signal.SIGTERM) + try: + compositor.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(compositor.pid, signal.SIGKILL) + compositor.wait(timeout=5) + if directory := os.environ.get("FERESE_TEST_LOG_DIR"): + logs = Path(directory) + logs.mkdir(parents=True, exist_ok=True) + shutil.copyfile(log_path, logs / f"xwayland-{mode}.log") + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-shell/src/media/subscription.rs b/shell/ferese-shell/src/media/subscription.rs index 0e01244..6c1ddfe 100644 --- a/shell/ferese-shell/src/media/subscription.rs +++ b/shell/ferese-shell/src/media/subscription.rs @@ -81,6 +81,7 @@ mod tests { ]) .env(CHILD, "1") .env("XDG_RUNTIME_DIR", root.path()) + .env_remove("FERESE_SOCKET") .output() .unwrap(); assert!(