test: migrate host-timed tests to private kick-in-kick hosts; flock clipboard

The 7 host-timed tests failed not (only) on timeouts: autopsy showed
parallel workers stealing each other's live-host clipboard (wl-paste
returns another test's token -- no timeout survives that).

Clipboard x3: the failure is cross-talk on ONE shared host clipboard.
Serialize just those sections with an flock file across xdist workers
(the 45s polls stay as backstops for genuine host slowness -- marks
kept; focus-based backend still needs the real host).

Fullscreen x3 + cursor-shape: migrate to per-test outer-kick hosts
(same xdg/fullscreen/shape protocols, deterministic compositor instead
of GNOME).  No coverage lost (single code path; timing was the only
host variable).  cursor-shape uses deterministic warp-enter; fullscreen
uses tap/chord acks.  Added KICK_REPEAT_DELAY_MS + extra_args to
_spawn_nested_in.  Unmarked after -n15 validation.

hold_alt stays (wall-clock timer).

Evidence: -n15 gate 3x clean (152 passed), serial gate clean (152),
migrated tests 6/6 at -n15.  12 -> 7 flaky.
This commit is contained in:
Andrew Hamilton 2026-09-09 13:03:10 +10:00
parent 0babe8832e
commit e398f90b12

View file

@ -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):