diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5dffea4..1056520 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -19,6 +19,7 @@ Known-flaky tests are marked @pytest.mark.flaky; pytest.ini's default """ import ctypes +import fcntl import os import re import shutil @@ -1214,7 +1215,7 @@ def _spawn_standalone(extra_env, *apps, log=None): return Session(proc, log, dump_dir) -def _spawn_nested_in(outer, *apps, log=None): +def _spawn_nested_in(outer, *apps, extra_args=(), log=None): """A nested kick whose host compositor is another running kick (e.g. a standalone outer): the inner bridge binds the outer's data-control backend, so data-control/primary bridge paths are testable without a @@ -1225,6 +1226,7 @@ def _spawn_nested_in(outer, *apps, log=None): env["KICK_LOG"] = "2" env["KICK_SYNTH_INPUT"] = "1" env["KICK_DUMP_DIR"] = dump_dir + env["KICK_REPEAT_DELAY_MS"] = KICK_REPEAT_TEST_DELAY env["WAYLAND_DISPLAY"] = outer.socket # The outer kick advertises no server decorations; take the direct # xdg-shell path instead of libdecor (which also needs KICK_LIBDECOR @@ -1233,7 +1235,7 @@ def _spawn_nested_in(outer, *apps, log=None): env.pop("KICK_MODE", None) env.pop("WLR_BACKENDS", None) proc = subprocess.Popen( - [BIN, *apps], + [BIN, *extra_args] + list(apps), env=env, stdin=subprocess.PIPE, stdout=open(log, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid())) @@ -2226,34 +2228,38 @@ def test_clipboard_host_steal_cancels_nested_source(session): the bridge's host-side source is cancelled (the offering wl-copy exits) and the session survives.""" token = f"kick-cancel-{os.getpid()}-{time.monotonic_ns()}" - copy = _dc_setter(session.socket, token) + lock = _host_clipboard_locked() try: - session.wait_any_active() - # The mirror is live once the host pastes what the tab offered. - _poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token) - steal = subprocess.Popen( - ["wl-copy", "-f", "stolen"], - env=dict(os.environ), - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - preexec_fn=_preexec_death(os.getpid())) + copy = _dc_setter(session.socket, token) try: - # The data-control setter exits once its source is cancelled - # (like an offering wl-copy): the wait proves the bridge let - # go of it. - copy.wait(timeout=10.0) - assert copy.returncode == 0, \ - f"cancelled setter should exit 0, rc={copy.returncode}" + session.wait_any_active() + # The mirror is live once the host pastes what the tab offered. + _poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token) + steal = subprocess.Popen( + ["wl-copy", "-f", "stolen"], + env=dict(os.environ), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=_preexec_death(os.getpid())) + try: + # The data-control setter exits once its source is cancelled + # (like an offering wl-copy): the wait proves the bridge let + # go of it. + copy.wait(timeout=10.0) + assert copy.returncode == 0, \ + f"cancelled setter should exit 0, rc={copy.returncode}" + finally: + steal.terminate() + steal.wait(timeout=5) + assert session.proc.poll() is None, \ + f"compositor died on source cancel; log tail:\n{session.read_log()[-400:]}" finally: - steal.terminate() - steal.wait(timeout=5) - assert session.proc.poll() is None, \ - f"compositor died on source cancel; log tail:\n{session.read_log()[-400:]}" + try: + copy.kill() + except Exception: + pass finally: - try: - copy.kill() - except Exception: - pass + lock.close() def test_standalone_headless_cursor_theme_none_boots(tmp_path): @@ -2327,27 +2333,38 @@ def test_standalone_cursor_shape_resets_on_tab_switch(): s.close() -@pytest.mark.flaky -def test_nested_cursor_shape_forwarded(): +def test_nested_cursor_shape_forwarded(tmp_path): """In nested mode a client's cursor-shape request is forwarded to the host - pointer (the host draws the cursor). Deterministic when the host honours - the forwarded set_shape; on a desktop host the outcome depends on the - host's cursor-shape-v1 support (and on whether the real pointer has ever - entered the window, which provides the serial the request is tagged - with).""" - if not os.environ.get("WAYLAND_DISPLAY"): - pytest.skip("no host compositor for nested mode") - headless_host = os.environ["WAYLAND_DISPLAY"].startswith("kick-") - s = _nested_session("build/wltest '#0000ff'", - "build/wltest --cursor-shape text '#ff0000'") + pointer. The host is a private outer kick (not the live desktop), so no + real pointer or host cursor-shape support is needed: warps enter + deterministically and the forward line proves the request went out.""" + outer_log = tmp_path / "outer.log" + inner_log = tmp_path / "inner.log" + outer = _spawn_standalone( + {}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log)) try: - s.wait_app_active(1) - if headless_host: - s.pointer_enter_until(r"\[cursor\] host shape text") - else: - s.pointer_enter_until(r"\[cursor\] host shape (text|n/a)") + outer.wait_any_active() + inner = _spawn_nested_in( + outer, "build/wltest '#0000ff'", + "build/wltest --cursor-shape text '#ff0000'", + log=str(inner_log)) + try: + inner.wait_app_active(1) + inner.wait_surface_mapped(1) + # Deterministic enter (replaces pointer_enter_until's re-warp + # loop, which exists for races with surface readiness): the + # surface is mapped, so one nested warp enters it and the + # client requests its shape. (Nested warps log no [cursor] + # line -- no wlr_cursor there -- so wait for the forward line + # itself.) + inner.warp(400, 400) + inner.wait_for(r"\[cursor\] host shape text", timeout=30.0) + assert inner.proc.poll() is None + assert outer.proc.poll() is None + finally: + inner.close() finally: - s.close() + outer.close() def test_standalone_headless_last_tab_exits_cleanly(standalone_session): @@ -2473,9 +2490,30 @@ def _run_display(display, args, timeout=6, **kw): preexec_fn=_preexec_death(os.getpid()), **kw) +def _host_clipboard_locked(): + """Exclusive cross-worker lock for the shared live-host clipboard. + + Autopsied failure: parallel clipboard tests steal each other's host + selections (wl-paste returns another test's token), so the loser's + poll fails after 45s no matter the timeout. Serializing just these + sections across xdist workers removes the cross-talk; genuine host + slowness still fails honestly inside the (generous) polls, which is + why these tests stay marked flaky. Returns the lock file; close it + to release (use try/finally).""" + f = open("/tmp/kick-host-clipboard.lock", "w") + fcntl.flock(f, fcntl.LOCK_EX) + return f + + def _poll_paste(display, want, deadline=45.0, extra=()): """Wait until `wl-paste` on `display` returns exactly `want` - (extra args, e.g. ("--primary",), select the selection).""" + (extra args, e.g. ("--primary",), select the selection). + + Callers driving the shared live-host clipboard must hold + _host_clipboard_locked() for the whole set-then-paste sequence: + parallel workers otherwise steal each other's selections and the + loser's token never appears no matter how long the poll runs.""" + r = None since = time.monotonic() while time.monotonic() - since < deadline: @@ -2567,30 +2605,38 @@ def _poll_dc_demand(sock, out_file, token, primary=False, deadline=45.0): @pytest.mark.flaky def test_clipboard_nested_to_host(session): token = f"kick-n2h-{os.getpid()}-{time.monotonic_ns()}" - copy = _dc_setter(session.socket, token) + lock = _host_clipboard_locked() try: - _poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token) + copy = _dc_setter(session.socket, token) + try: + _poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token) + finally: + copy.terminate() + copy.wait(timeout=5) finally: - copy.terminate() - copy.wait(timeout=5) + lock.close() @pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed") @pytest.mark.flaky def test_clipboard_host_to_nested(session, tmp_path): token = f"kick-h2n-{os.getpid()}-{time.monotonic_ns()}" - copy = subprocess.Popen( - ["wl-copy", "-f", token], - env=dict(os.environ), - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - preexec_fn=_preexec_death(os.getpid())) + lock = _host_clipboard_locked() try: - out = tmp_path / "h2n.out" - _poll_dc_demand(session.socket, out, token) + copy = subprocess.Popen( + ["wl-copy", "-f", token], + env=dict(os.environ), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=_preexec_death(os.getpid())) + try: + out = tmp_path / "h2n.out" + _poll_dc_demand(session.socket, out, token) + finally: + copy.terminate() + copy.wait(timeout=5) finally: - copy.terminate() - copy.wait(timeout=5) + lock.close() def test_tabs_map(session): @@ -2625,69 +2671,105 @@ def test_function_keys_pass_through(session): assert session.proc.poll() is None -@pytest.mark.flaky -def test_client_fullscreen_round_trip(session): +def test_client_fullscreen_round_trip(tmp_path): """A client's fullscreen request is honored and mirrored onto the host window; the host configure then propagates fullscreen to every tab. wltest toggles xdg_toplevel fullscreen on the 'F' key, so tap it and - watch both directions of the mirror.""" - idx = session.wait_any_active() - session.wait_surface_mapped(idx) - # Tapping F before seat keyboard focus reaches the surface drops the - # key silently; map alone does not guarantee enter under load. - session.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) - session.tap_ack(F) - session.wait_for("host fullscreen=1", timeout=30.0) - session.wait_for("clients fullscreen=1") - session.tap_ack(F) - session.wait_for("host fullscreen=0", timeout=30.0) - session.wait_for("clients fullscreen=0") - assert session.proc.poll() is None + watch both directions of the mirror. The host is a private outer kick + (not the live desktop), so configure roundtrips are deterministic.""" + outer_log = tmp_path / "outer.log" + inner_log = tmp_path / "inner.log" + outer = _spawn_standalone( + {}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log)) + try: + outer.wait_any_active() + inner = _spawn_nested_in( + outer, "%s '#ff0000'" % WL, "%s '#00ff00'" % WL, + log=str(inner_log)) + try: + idx = inner.wait_any_active() + inner.wait_surface_mapped(idx) + # Tapping F before seat keyboard focus reaches the surface drops + # the key silently; map alone does not guarantee enter under load. + inner.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) + inner.tap_ack(F) + inner.wait_for("host fullscreen=1", timeout=30.0) + inner.wait_for("clients fullscreen=1") + inner.tap_ack(F) + inner.wait_for("host fullscreen=0", timeout=30.0) + inner.wait_for("clients fullscreen=0") + assert inner.proc.poll() is None + assert outer.proc.poll() is None + finally: + inner.close() + finally: + outer.close() -@pytest.mark.flaky -def test_alt_f11_toggles_host_fullscreen(session): +def test_alt_f11_toggles_host_fullscreen(tmp_path): """prefix+F11 toggles the kick window's fullscreen state in the host; the resulting configure propagates to every tab exactly like a host-compositor-initiated fullscreen. Tapping plain F11 (no prefix) - must still pass through to the client untouched.""" - idx = session.wait_any_active() - session.wait_surface_mapped(idx) - session.chord_ack(ALT, F11) - session.wait_for("host fullscreen=1", timeout=30.0) - session.wait_for("clients fullscreen=1") - session.chord_ack(ALT, F11) - session.wait_for("host fullscreen=0", timeout=30.0) - session.wait_for("clients fullscreen=0") - assert session.proc.poll() is None + must still pass through to the client untouched. The host is a private + outer kick, so configure roundtrips are deterministic.""" + outer_log = tmp_path / "outer.log" + inner_log = tmp_path / "inner.log" + outer = _spawn_standalone( + {}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log)) + try: + outer.wait_any_active() + inner = _spawn_nested_in( + outer, "%s '#ff0000'" % WL, "%s '#00ff00'" % WL, + log=str(inner_log)) + try: + idx = inner.wait_any_active() + inner.wait_surface_mapped(idx) + inner.chord_ack(ALT, F11) + inner.wait_for("host fullscreen=1", timeout=30.0) + inner.wait_for("clients fullscreen=1") + inner.chord_ack(ALT, F11) + inner.wait_for("host fullscreen=0", timeout=30.0) + inner.wait_for("clients fullscreen=0") + assert inner.proc.poll() is None + assert outer.proc.poll() is None + finally: + inner.close() + finally: + outer.close() -@pytest.mark.flaky -def test_alt_f11_propagates_to_stack_members(): +def test_alt_f11_propagates_to_stack_members(tmp_path): """prefix+F11's host fullscreen reaches not just each tab's main window but every stacked tab-group member too, and leaving fullscreen clears - both again.""" - if not os.environ.get("WAYLAND_DISPLAY"): - pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") - s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'", - extra_args=("--tab-groups", "on")) + both again. The host is a private outer kick, so configure roundtrips + are deterministic.""" + outer_log = tmp_path / "outer.log" + inner_log = tmp_path / "inner.log" + outer = _spawn_standalone( + {}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log)) try: - idx = s.wait_any_active() - s.wait_surface_mapped(idx) - s.wait_for(r"\[sub\] stack app=0\b") - s.chord_ack(ALT, F11) - s.wait_for("host fullscreen=1", timeout=30.0) - s.wait_for("clients fullscreen=1") - s.wait_for("members fullscreen=1") - s.chord_ack(ALT, F11) - s.wait_for("host fullscreen=0", timeout=30.0) - s.wait_for("clients fullscreen=0") - s.wait_for("members fullscreen=0") - assert s.proc.poll() is None + outer.wait_any_active() + inner = _spawn_nested_in( + outer, f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'", + extra_args=("--tab-groups", "on"), log=str(inner_log)) + try: + idx = inner.wait_any_active() + inner.wait_surface_mapped(idx) + inner.wait_for(r"\[sub\] stack app=0\b") + inner.chord_ack(ALT, F11) + inner.wait_for("host fullscreen=1", timeout=30.0) + inner.wait_for("clients fullscreen=1") + inner.wait_for("members fullscreen=1") + inner.chord_ack(ALT, F11) + inner.wait_for("host fullscreen=0", timeout=30.0) + inner.wait_for("clients fullscreen=0") + inner.wait_for("members fullscreen=0") + assert inner.proc.poll() is None + assert outer.proc.poll() is None + finally: + inner.close() finally: - s.close() - if s.proc.poll() is None: - s.proc.terminate() + outer.close() def test_sigterm_removes_socket(session):