Port [claim]/[launch] client-PID logging and the 13-test smoke suite

Brings in master's client-PID logging ([claim] ties an app slot to the
connected client's PID, [launch] records the spawn PID) and its expanded
smoke suite, so the fixed tree can run and pass the full merged suite.
This commit is contained in:
Andrew Hamilton 2026-08-11 19:07:42 +10:00
parent 3cc2ab38fd
commit ea8770c887
3 changed files with 167 additions and 4 deletions

View file

@ -855,6 +855,17 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
/* hidden until it becomes the active tab */
wlr_scene_node_set_enabled(&app->tree->node, false);
/* [claim] ties the app slot to the *connected client's* PID, so tests
can kill the tab that is actually active. App slots are claimed in
connection order (first free slot), which is NOT the launch order
the [launch] lines use when two clients start concurrently. */
pid_t cpid = -1;
if (toplevel->base->client)
wl_client_get_credentials(toplevel->base->client->client,
&cpid, NULL, NULL);
tabbed_log(TABBED_LOG_INFO, "[claim] app=%d pid=%d\n",
app_index(app), (int)cpid);
app->destroy.notify = handle_toplevel_destroy;
wl_signal_add(&toplevel->events.destroy, &app->destroy);
@ -2734,8 +2745,11 @@ static void launch_client(int idx) {
execlp("/bin/sh", "sh", "-c", server.apps[idx]->cmd, NULL);
_exit(127);
}
if (pid > 0)
if (pid > 0) {
server.apps[idx]->pid = pid;
tabbed_log(TABBED_LOG_INFO, "[launch] app=%d pid=%d cmd=%s\n",
idx, pid, server.apps[idx]->cmd);
}
}
/* ===================== Crash handler ===================== */

View file

@ -1,4 +1,8 @@
#!/bin/sh
set -e
cd "$(dirname "$0")"
exec pytest -v tests/
exec pytest -v "$@"

View file

@ -18,6 +18,7 @@ import glob
import os
import re
import shutil
import signal
import struct
import subprocess
import time
@ -48,6 +49,7 @@ CTL_CMD_KEY = 1
CTL_CMD_MODS = 2
ACTIVE_RE = re.compile(r"active app=(\d+)")
CLAIM_RE = re.compile(r"\[claim\] app=(\d+) pid=(\d+)")
SOCKET_RE = re.compile(r"Wayland socket: (tabbed-wayland-\d+)")
@ -181,6 +183,38 @@ class Session:
time.sleep(0.1)
raise AssertionError("F10 produced no 'host back buffer ->' line")
def dump_scene(self, timeout=5.0):
"""F9-request a scene dump; wait until the dump path has run.
The scene buffer is a dmabuf and never CPU-readable, so no ppm is
written; the `[dump] scene` line (either "not CPU-readable" or the
stats line) proves the F9 request reached the dump path.
"""
rx = re.compile(r"\[dump\] scene")
before = len(rx.findall(self.read_log()))
self.tap(F9)
since = time.monotonic()
while time.monotonic() - since < timeout:
m = rx.findall(self.read_log())
if len(m) > before:
return
time.sleep(0.1)
raise AssertionError("F9 produced no '[dump] scene' line")
def active_count(self):
"""How many `active app=` transitions the log has seen so far."""
return len(ACTIVE_RE.findall(self.read_log()))
def launch_pids(self, timeout=10.0):
"""Map app index -> connected client PID from [claim] lines."""
since = time.monotonic()
while time.monotonic() - since < timeout:
d = dict((int(a), int(p)) for a, p in CLAIM_RE.findall(self.read_log()))
if {0, 1}.issubset(d):
return d
time.sleep(0.1)
pytest.fail("both apps never logged a [claim] line")
def centre_of(self, path):
"""Centre pixel of an 8-bit P6 ppm as an (R, G, B) tuple."""
with open(path, "rb") as f:
@ -202,7 +236,7 @@ class Session:
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
for ppm in glob.glob("/tmp/host_*.ppm"):
for ppm in glob.glob("/tmp/host_*.ppm") + glob.glob("/tmp/scene_*.ppm"):
os.unlink(ppm)
@ -345,4 +379,115 @@ def test_sigterm_removes_socket(session):
session.proc.terminate()
session.proc.wait(timeout=5)
assert session.proc.returncode == 0
assert not os.path.exists(sock_path), f"socket left behind: {sock_path}"
assert not os.path.exists(sock_path), f"socket left behind: {sock_path}"
def test_cycle_wraps(session):
start = session.wait_any_active()
assert start in (0, 1)
session.cycle(+1)
other = session.wait_active_change(start)
assert other == 1 - start
session.cycle(+1)
assert session.wait_active_change(other) == start
session.cycle(-1)
assert session.wait_active_change(start) == other
session.cycle(-1)
assert session.wait_active_change(other) == start
def test_hold_pagedown_repeats(session):
start = session.wait_any_active()
assert start in (0, 1)
# Press and hold Ctrl+PageDown for ~1.3s so the compositor's own
# repeat timer (600ms delay, then 25Hz) kicks in.
session.mods(CTRL)
session.key(PAGE_DOWN, KEY_PRESSED)
base = session.active_count()
time.sleep(1.3)
transitions = session.active_count() - base
# Clean release: let go of the key and the modifier.
session.key(PAGE_DOWN, KEY_RELEASED)
session.mods(0)
assert transitions >= 3, (
f"held Ctrl+PgDn produced only {transitions} active transitions "
"(expected repeats to cycle several tabs)"
)
settled = session.active_count()
time.sleep(0.3)
assert session.active_count() == settled, "repeats continued after release"
def test_non_ctrl_key_ignored(session):
start = session.wait_any_active()
assert start in (0, 1)
base_active = session.active_count()
session.key(30, KEY_PRESSED) # 'A'
session.key(30, KEY_RELEASED)
time.sleep(0.3)
assert session.active_count() == base_active, "'A' without ctrl cycled tabs"
assert "host fullscreen=" not in session.read_log(), "'A' toggled fullscreen"
assert session.proc.poll() is None
session.tap(F11)
session.wait_for("host fullscreen=1")
assert session.proc.poll() is None
def test_f9_scene_dump_runs(session):
"""F9 must trigger the scene-dump path. The scene buffer is a dmabuf
(never CPU-readable here), so we assert the diagnostic line that the
dump path emits regardless of readability, and that F10 still works."""
session.wait_for(r"\[diag\] map app=0")
session.wait_for(r"\[diag\] map app=1")
session.dump_scene()
host_ppm = session.dump_host()
assert session.centre_of(host_ppm) is not None
# dump_seq increments every dump (even failed scene reads), so F10
# still lands a fresh host ppm after an F9.
ch = session.centre_of(host_ppm)
assert ch[0] + ch[1] + ch[2] > 0, "host dump was all-black"
def test_active_switch_on_client_exit(session):
start = session.wait_any_active()
assert start in (0, 1)
pids = session.launch_pids()
other = 1 - start
# Kill the active tab: the compositor must fall through to the other.
# [claim] pid is the connected client (wltest); SIGTERM closes its
# Wayland socket, which is what makes the compositor destroy the tab.
os.kill(pids[start], signal.SIGTERM)
assert session.wait_active_change(start) == other, \
"active did not fall through to the other tab after its tab died"
assert session.proc.poll() is None, "compositor exited after one tab died"
# Kill the last tab: the compositor should exit cleanly on its own.
os.kill(pids[other], signal.SIGTERM)
session.proc.wait(timeout=5)
assert session.proc.returncode == 0, \
f"compositor exited with {session.proc.returncode} after last tab closed"
def test_sigusr1_dumps(session):
scene_rx = re.compile(r"\[dump\] scene")
host_rx = re.compile(r"\[dump\] host back buffer -> (\S+\.ppm)")
# Warm the dump machinery so both baselines exist, then assert SIGUSR1
# produces a *new* scene dump attempt and a *new* host ppm.
session.tap(F9)
session.tap(F10)
session.wait_for(r"\[dump\] host back buffer ->")
scene_before = len(scene_rx.findall(session.read_log()))
host_before = len(host_rx.findall(session.read_log()))
os.kill(session.proc.pid, signal.SIGUSR1)
since = time.monotonic()
while time.monotonic() - since < 5.0:
log = session.read_log()
if len(scene_rx.findall(log)) > scene_before and \
len(host_rx.findall(log)) > host_before:
return
time.sleep(0.1)
pytest.fail("SIGUSR1 produced no new scene+host dump lines")