pyxtest: add --display for running a test against a manually started server

This makes it much easier to debug an individual test since we can now
start an X server via valgrind/gdb/whatever and have the test client
connect to that server.

Assisted-by: Claude:claude-claude-opus-4-6
Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2187>
This commit is contained in:
Peter Hutterer 2026-05-06 11:54:13 +10:00 committed by Marge Bot
parent b9ed4bd4c0
commit 4d79ddd0b4
3 changed files with 142 additions and 3 deletions

View file

@ -42,6 +42,14 @@ pytest test/pyxtest/ -v
The normal pytest options work as expected (`-k` for test selection, etc.) The normal pytest options work as expected (`-k` for test selection, etc.)
Tests can be run against a manually-started server using the `--display`
option:
```sh
./build/hw/vfb/Xvfb :2
pytest test/pyxtest --display :2
```
### Running with AddressSanitizer (ASAN) ### Running with AddressSanitizer (ASAN)
ASAN is a compile-time instrumentation that detects memory errors such as ASAN is a compile-time instrumentation that detects memory errors such as

View file

@ -7,7 +7,7 @@ import shutil
import pytest import pytest
from pathlib import Path from pathlib import Path
from xserver import XServerProcess from xserver import ExternalXServer, XServerProcess
from xclient import RawX11Connection, X11ConnectionError, XlibConnection from xclient import RawX11Connection, X11ConnectionError, XlibConnection
@ -33,6 +33,14 @@ def pytest_addoption(parser):
parser.addoption( parser.addoption(
"--server-path", default=None, help="Explicit path to the X server binary" "--server-path", default=None, help="Explicit path to the X server binary"
) )
parser.addoption(
"--display",
default=None,
help="Connect to an existing X server instead of starting one. "
"Value is a display number or :N string (e.g. '42' or ':42'). "
"Optionally combine with --server-type to declare the server type "
"for marker-based test filtering.",
)
def pytest_configure(config): def pytest_configure(config):
@ -53,10 +61,36 @@ def pytest_configure(config):
"markers", "swapped_client: mark test as requiring a byte-swapped client" "markers", "swapped_client: mark test as requiring a byte-swapped client"
) )
# Validate --display against conflicting options
display = config.getoption("--display", default=None)
if display is not None:
if config.getoption("--valgrind"):
raise pytest.UsageError("--display and --valgrind are mutually exclusive")
if config.getoption("--server-path"):
raise pytest.UsageError(
"--display and --server-path are mutually exclusive"
)
def _parse_display(value):
"""Parse a display string like ':42' or '42' into an integer."""
value = value.strip().lstrip(":")
try:
return int(value)
except ValueError:
raise pytest.UsageError(f"Invalid display value: {value!r}")
def get_server_types(config) -> list[str]: def get_server_types(config) -> list[str]:
"""Get the list of server types to test, default to xvfb.""" """Get the list of server types to test.
return config.getoption("--server-type") or ["xvfb"]
With ``--display``, defaults to ``["external"]`` if no explicit
``--server-type`` is given. Otherwise defaults to ``["xvfb"]``.
"""
types = config.getoption("--server-type") or []
if config.getoption("--display", default=None) is not None:
return types or ["external"]
return types or ["xvfb"]
def get_valgrind_suppressions(config) -> Path | None: def get_valgrind_suppressions(config) -> Path | None:
@ -209,6 +243,9 @@ def xserver(request, tmp_path):
A fresh server per test, killed afterward. With --valgrind, A fresh server per test, killed afterward. With --valgrind,
valgrind memory errors cause test failure during teardown. valgrind memory errors cause test failure during teardown.
When ``--display`` is given, no server is started; an
:class:`ExternalXServer` proxy is yielded instead.
For a fixture that targets a specific server type use the xvfb, For a fixture that targets a specific server type use the xvfb,
xwayland, or xorg fixtures instead. xwayland, or xorg fixtures instead.
""" """
@ -220,6 +257,13 @@ def xserver(request, tmp_path):
if request.node.get_closest_marker("xorg_only") and server_type != "xorg": if request.node.get_closest_marker("xorg_only") and server_type != "xorg":
pytest.skip("Test only applies to Xorg") pytest.skip("Test only applies to Xorg")
# External server mode: no server lifecycle management
display = request.config.getoption("--display")
if display is not None:
display_num = _parse_display(display)
yield ExternalXServer(display_num, server_type=server_type)
return
kwargs = {} kwargs = {}
if server_type == "xorg": if server_type == "xorg":
kwargs["log_file"] = tmp_path / f"{server_type}.log" kwargs["log_file"] = tmp_path / f"{server_type}.log"
@ -230,6 +274,10 @@ def xserver(request, tmp_path):
@pytest.fixture @pytest.fixture
def xvfb(request, tmp_path): def xvfb(request, tmp_path):
"""Start an Xvfb server for this test.""" """Start an Xvfb server for this test."""
display = request.config.getoption("--display")
if display is not None:
yield ExternalXServer(_parse_display(display), server_type="xvfb")
return
if "xvfb" not in get_server_types(request.config): if "xvfb" not in get_server_types(request.config):
pytest.skip("Xvfb not in --server-type list") pytest.skip("Xvfb not in --server-type list")
yield from _start_server(request, "xvfb") yield from _start_server(request, "xvfb")
@ -238,6 +286,10 @@ def xvfb(request, tmp_path):
@pytest.fixture @pytest.fixture
def xwayland(request, tmp_path): def xwayland(request, tmp_path):
"""Start an Xwayland server for this test.""" """Start an Xwayland server for this test."""
display = request.config.getoption("--display")
if display is not None:
yield ExternalXServer(_parse_display(display), server_type="xwayland")
return
if "xwayland" not in get_server_types(request.config): if "xwayland" not in get_server_types(request.config):
pytest.skip("Xwayland not in --server-type list") pytest.skip("Xwayland not in --server-type list")
yield from _start_server(request, "xwayland") yield from _start_server(request, "xwayland")
@ -246,6 +298,10 @@ def xwayland(request, tmp_path):
@pytest.fixture @pytest.fixture
def xorg(request, tmp_path): def xorg(request, tmp_path):
"""Start an Xorg server for this test.""" """Start an Xorg server for this test."""
display = request.config.getoption("--display")
if display is not None:
yield ExternalXServer(_parse_display(display), server_type="xorg")
return
if "xorg" not in get_server_types(request.config): if "xorg" not in get_server_types(request.config):
pytest.skip("Xorg not in --server-type list") pytest.skip("Xorg not in --server-type list")
yield from _start_server(request, "xorg", log_file=tmp_path / "xorg.log") yield from _start_server(request, "xorg", log_file=tmp_path / "xorg.log")

View file

@ -427,3 +427,78 @@ class XServerProcess:
if self.asan: if self.asan:
flags += " +asan" flags += " +asan"
return f"<XServerProcess {self.server_type} {display} [{state}]{flags}>" return f"<XServerProcess {self.server_type} {display} [{state}]{flags}>"
class ExternalXServer:
"""Proxy for an externally-managed X server (``--display`` mode).
Used when the user passes ``--display`` to connect to an already-running
server instead of launching one per test. The server's PID is discovered
via ``SO_PEERCRED`` on the Unix socket so that :attr:`is_alive` can
report whether the process is still running.
"""
def __init__(self, display_num, server_type="external"):
self._display_num = display_num
self.server_type = server_type
self._pid = self._get_server_pid()
def _get_server_pid(self):
"""Get the PID of the X server via SO_PEERCRED on the Unix socket."""
import socket as _socket
import struct as _struct
path = f"/tmp/.X11-unix/X{self._display_num}"
try:
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
sock.connect(path)
cred = sock.getsockopt(
_socket.SOL_SOCKET, _socket.SO_PEERCRED, _struct.calcsize("iii")
)
pid, _, _ = _struct.unpack("iii", cred)
sock.close()
return pid
except (OSError, _struct.error):
return None
@property
def display_num(self):
return self._display_num
@property
def display(self):
return f":{self._display_num}"
@property
def is_alive(self) -> bool:
"""Check if the external server process is still running."""
if self._pid is None:
return True # can't check, assume alive
try:
os.kill(self._pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True # process exists but we can't signal it
@property
def crash_signal(self) -> int | None:
"""Always None -- we cannot determine this for external servers."""
return None
def stop(self):
"""No-op -- never stop an external server."""
return []
def get_asan_errors(self):
"""No ASAN log access for external servers."""
return []
def check_valgrind_errors(self):
"""No valgrind access for external servers."""
return []
def __repr__(self):
state = "running" if self.is_alive else "stopped"
return f"<ExternalXServer :{self._display_num} [{state}] pid={self._pid}>"