mirror of
https://github.com/X11Libre/xserver.git
synced 2026-11-15 05:23:40 +00:00
Each test starts its own X server via -displayfd, and pytest-xdist runs
several workers (meson several suites) in parallel. Servers scanning for a
free display number at the same time can both end up claiming the same one:
the losing test's client then connects to the winner's server, and when the
winner is torn down at the end of its own test the loser's connection dies
mid-test. It surfaces as an opaque
BrokenPipeError: [Errno 32] Broken pipe
test/pyxtest/xclient.py:270: in send_request / self.sock.sendall(data)
in an unrelated test, which looks like a flake and hides the cause.
Hold a cross-process lock for the short display-allocation window (Popen
plus reading the display number). Once a server has announced its display
it owns the socket and the lock file, so later servers skip that number and
that window is the only place the race can happen.
As a safety net, record the server PID that owns the socket (SO_PEERCRED)
and fail the xclient fixture with a clear message when it is not the server
the fixture started, instead of leaving a confusing BrokenPipeError.
Measured with a --buildtype=debug build and 4 pytest-xdist workers, 40
parallel pytest sessions of test_xi.py: before, 7 sessions failed with
"connected to PID X instead of the test server" and 5 with
BrokenPipeError; after, 0 of 40, plus 0 errors over 8 full test/pyxtest
runs.
Signed-off-by: Enrico Weigelt, metux IT consult <info@metux.net>
633 lines
21 KiB
Python
633 lines
21 KiB
Python
# SPDX-License-Identifier: MIT
|
|
#
|
|
# X server lifecycle manager
|
|
#
|
|
# Handles starting and stopping Xvfb, Xwayland, and Xorg servers,
|
|
# optionally under valgrind or with AddressSanitizer (ASAN) support,
|
|
# with automatic display number allocation via the -displayfd mechanism.
|
|
|
|
import fcntl
|
|
import os
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import warnings
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from asan import AsanError
|
|
from valgrind import ValgrindError
|
|
|
|
# Serializes the display-allocation window of concurrently started servers.
|
|
#
|
|
# Several tests (and pytest-xdist workers) start their own X servers at the
|
|
# same time. Each of them scans for a free display number via -displayfd, and
|
|
# two servers racing through the same number can both end up believing they own
|
|
# it: the loser's client then connects to the winner's server. When the winner
|
|
# is torn down at the end of its own test, the loser's client sees its
|
|
# connection die mid-test (BrokenPipeError), which looks like a flaky test
|
|
# rather than a display-socket takeover.
|
|
#
|
|
# The lock is held only for the short startup window (Popen plus reading the
|
|
# display number), not for the lifetime of the server: once a server has
|
|
# announced its display it holds the socket and the lock file, so later servers
|
|
# skip that number.
|
|
_DISPLAY_LOCK_PATH = os.path.join(
|
|
tempfile.gettempdir(), "pyxtest-xserver-display.lock"
|
|
)
|
|
|
|
|
|
def _acquire_display_lock() -> int:
|
|
fd = os.open(_DISPLAY_LOCK_PATH, os.O_CREAT | os.O_RDWR, 0o666)
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
return fd
|
|
|
|
|
|
def _release_display_lock(fd: int) -> None:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
class XServerProcess:
|
|
"""
|
|
Manages an X server subprocess for testing.
|
|
|
|
Supports Xvfb, Xwayland, and Xorg. Uses the -displayfd pipe mechanism
|
|
for automatic display number allocation. Optionally wraps the server
|
|
in valgrind for memory error detection, or detects AddressSanitizer
|
|
(ASAN) errors when running an ASAN-instrumented binary.
|
|
"""
|
|
|
|
VALGRIND_ERROR_EXIT = 99
|
|
|
|
def __init__(
|
|
self,
|
|
server_type="xvfb",
|
|
valgrind=False,
|
|
valgrind_suppressions=None,
|
|
asan=False,
|
|
server_path=None,
|
|
extra_args=None,
|
|
log_file=None,
|
|
):
|
|
self.server_type = server_type
|
|
self.asan = asan
|
|
if asan and valgrind:
|
|
warnings.warn(
|
|
"ASAN and valgrind are incompatible; disabling valgrind "
|
|
"in favour of ASAN.",
|
|
stacklevel=2,
|
|
)
|
|
valgrind = False
|
|
self.valgrind = valgrind
|
|
if valgrind_suppressions is None:
|
|
default_supp = Path(__file__).resolve().parent / "valgrind.suppressions"
|
|
self.valgrind_suppressions = (
|
|
default_supp if default_supp.is_file() else None
|
|
)
|
|
else:
|
|
self.valgrind_suppressions = valgrind_suppressions
|
|
self._builddir = None
|
|
self.server_path = server_path or self._find_server()
|
|
self.extra_args = extra_args or []
|
|
self.log_file = log_file
|
|
|
|
self._process = None
|
|
self._display_num = None
|
|
self._valgrind_xml_file = None
|
|
self._asan_log_path = None
|
|
self._stderr_file = None
|
|
self._stderr_path = None
|
|
self._weston_process = None
|
|
|
|
def _find_server(self):
|
|
"""Discover the server binary from environment or build directory."""
|
|
env_map = {
|
|
"xvfb": "XVFB_PATH",
|
|
"xwayland": "XWAYLAND_PATH",
|
|
"xorg": "XORG_PATH",
|
|
}
|
|
env_var = env_map.get(self.server_type)
|
|
if env_var and os.environ.get(env_var):
|
|
path = Path(os.environ[env_var])
|
|
if path.is_file() and os.access(path, os.X_OK):
|
|
return path
|
|
|
|
def find_meson_builddir(source_root: Path) -> Iterator[Path]:
|
|
for d in source_root.iterdir():
|
|
if d.is_dir() and (d / "meson-private").exists():
|
|
yield d
|
|
|
|
# Try XSERVER_BUILDDIR env var or fall back to the first
|
|
# build directory relative to this file's location
|
|
# test/pyxtest/xserver.py -> ../../build/hw/...
|
|
builddir = os.environ.get("XSERVER_BUILDDIR")
|
|
if builddir is None:
|
|
try:
|
|
builddir = next(
|
|
find_meson_builddir(Path(__file__).resolve().parent.parent.parent)
|
|
)
|
|
except StopIteration:
|
|
pass
|
|
|
|
if builddir:
|
|
build_paths = {
|
|
"xvfb": Path(builddir, "hw", "vfb", "Xvfb"),
|
|
"xwayland": Path(builddir, "hw", "xwayland", "Xwayland"),
|
|
"xorg": Path(builddir, "hw", "xfree86", "Xorg"),
|
|
}
|
|
path = build_paths.get(self.server_type)
|
|
if path and path.is_file() and os.access(path, os.X_OK):
|
|
self._builddir = Path(builddir)
|
|
return path
|
|
|
|
# Fall back to system PATH
|
|
binary_names = {
|
|
"xvfb": "Xvfb",
|
|
"xwayland": "Xwayland",
|
|
"xorg": "Xorg",
|
|
}
|
|
name = binary_names.get(self.server_type)
|
|
server_in_path = shutil.which(name)
|
|
if server_in_path:
|
|
if builddir:
|
|
msg = (
|
|
f"Using system {self.server_type} server from PATH: {server_in_path}. "
|
|
f"{name} was not found in XSERVER_BUILDDIR ({builddir})."
|
|
)
|
|
else:
|
|
msg = (
|
|
f"Using system {self.server_type} server from PATH: {server_in_path}. "
|
|
f"Set {env_var} or XSERVER_BUILDDIR to use a specific build."
|
|
)
|
|
warnings.warn(msg, stacklevel=2)
|
|
return server_in_path
|
|
|
|
raise FileNotFoundError(
|
|
f"Failed to find {self.server_type} server binary. "
|
|
f"Set {env_var} environment variable or build the server first."
|
|
)
|
|
|
|
@property
|
|
def display_num(self):
|
|
return self._display_num
|
|
|
|
@property
|
|
def display(self):
|
|
if self._display_num is not None:
|
|
return f":{self._display_num}"
|
|
return None
|
|
|
|
def start(self, timeout=10):
|
|
"""Start the X server and wait for it to be ready."""
|
|
# Hold the display-allocation lock for the whole startup, so that
|
|
# concurrently starting servers cannot pick the same display number.
|
|
lock_fd = _acquire_display_lock()
|
|
try:
|
|
return self._start_locked(timeout)
|
|
finally:
|
|
_release_display_lock(lock_fd)
|
|
|
|
def _start_locked(self, timeout=10):
|
|
read_fd, write_fd = os.pipe()
|
|
|
|
cmd = self._build_command(write_fd)
|
|
|
|
# Kept open for the server subprocess lifetime; closed in stop().
|
|
stderr_fd, stderr_path = tempfile.mkstemp(
|
|
prefix=f"xserver-{self.server_type}-stderr-",
|
|
suffix=".log",
|
|
)
|
|
self._stderr_file = open(stderr_fd, "w") # noqa: SIM115
|
|
self._stderr_path = Path(stderr_path)
|
|
|
|
if self.server_type == "xwayland":
|
|
self._start_wayland_compositor()
|
|
|
|
env = os.environ.copy()
|
|
if self._weston_process:
|
|
env.setdefault("WAYLAND_DISPLAY", "wayland-security-test")
|
|
|
|
if self.asan:
|
|
self._setup_asan_env(env)
|
|
|
|
try:
|
|
self._process = subprocess.Popen(
|
|
cmd,
|
|
stderr=self._stderr_file,
|
|
pass_fds=(write_fd,),
|
|
env=env,
|
|
)
|
|
except Exception:
|
|
os.close(write_fd)
|
|
os.close(read_fd)
|
|
raise
|
|
|
|
os.close(write_fd)
|
|
|
|
try:
|
|
display_bytes = b""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if self._process.poll() is not None:
|
|
display_bytes = None
|
|
break
|
|
|
|
ready, _, _ = select.select([read_fd], [], [], 1.0)
|
|
if ready:
|
|
chunk = os.read(read_fd, 64)
|
|
if not chunk:
|
|
break
|
|
display_bytes += chunk
|
|
if b"\n" in display_bytes:
|
|
break
|
|
|
|
if not display_bytes:
|
|
if self._process.poll() is not None:
|
|
stderr_content = self._read_stderr()
|
|
raise RuntimeError(
|
|
f"X server exited with code {self._process.returncode} "
|
|
f"before sending display number (waited {timeout}s).\n"
|
|
f"Command: {' '.join(str(s) for s in cmd)}\n"
|
|
f"Stderr:\n{stderr_content}"
|
|
)
|
|
self.stop()
|
|
raise RuntimeError(
|
|
f"Timed out waiting for X server display number (waited {timeout}s)"
|
|
)
|
|
|
|
self._display_num = int(display_bytes.strip())
|
|
|
|
finally:
|
|
os.close(read_fd)
|
|
|
|
return self._display_num
|
|
|
|
def _setup_xorg_modules(self):
|
|
"""Create a module directory layout for Xorg from a meson build tree.
|
|
|
|
Xorg's module loader expects drivers in a ``drivers/``
|
|
subdirectory, extensions in ``extensions/``, etc. The meson
|
|
build tree puts them in separate build directories, so we
|
|
create a temporary directory with the right structure using
|
|
symlinks.
|
|
|
|
Returns the path to the module directory, or None if the build
|
|
directory doesn't contain the expected modules.
|
|
"""
|
|
xfree86 = self._builddir / "hw" / "xfree86"
|
|
if not xfree86.is_dir():
|
|
return None
|
|
|
|
moddir = Path(tempfile.mkdtemp(prefix="xorg-modules-"))
|
|
|
|
# Map of build subdirectories to module layout subdirectories.
|
|
# Entries are (build_subdir, module_subdir, glob_pattern).
|
|
module_dirs = [
|
|
("drivers/modesetting", "drivers", "*_drv.so"),
|
|
("drivers/inputtest", "input", "*_drv.so"),
|
|
("dixmods", "extensions", "libglx.so"),
|
|
]
|
|
for build_sub, mod_sub, pattern in module_dirs:
|
|
src = xfree86 / build_sub
|
|
if not src.is_dir():
|
|
continue
|
|
dst = moddir / mod_sub
|
|
dst.mkdir(exist_ok=True)
|
|
for f in src.glob(pattern):
|
|
(dst / f.name).symlink_to(f.resolve())
|
|
|
|
# Other modules go at the top level
|
|
top_level_modules = [
|
|
"dixmods/libwfb.so",
|
|
"dixmods/libshadow.so",
|
|
"glamor_egl/libglamoregl.so",
|
|
"exa/libexa.so",
|
|
"fbdevhw/libfbdevhw.so",
|
|
"int10/libint10.so",
|
|
"shadowfb/libshadowfb.so",
|
|
"vgahw/libvgahw.so",
|
|
]
|
|
for rel in top_level_modules:
|
|
src = xfree86 / rel
|
|
if src.is_file():
|
|
(moddir / src.name).symlink_to(src.resolve())
|
|
|
|
return moddir
|
|
|
|
def _build_command(self, displayfd):
|
|
"""Build the full command line including optional valgrind wrapper."""
|
|
server_args = [self.server_path]
|
|
|
|
if self.server_type == "xvfb":
|
|
server_args.extend(
|
|
[
|
|
"-screen",
|
|
"scrn",
|
|
"1280x1024x24",
|
|
]
|
|
)
|
|
elif self.server_type == "xwayland":
|
|
server_args.extend(["-nokeymap"])
|
|
elif self.server_type == "xorg":
|
|
# -logfile is only permitted if we're running as root
|
|
if self.log_file and os.geteuid() == 0:
|
|
server_args.extend(["-logfile", str(self.log_file)])
|
|
|
|
# When running Xorg from a build directory, set up the
|
|
# module path and use an empty config so it auto-detects
|
|
# the GPU via modesetting.
|
|
if self._builddir:
|
|
modpath = self._setup_xorg_modules()
|
|
if modpath:
|
|
server_args.extend(["-modulepath", str(modpath)])
|
|
server_args.extend(["-config", "/dev/null", "-configdir", "/dev/null"])
|
|
|
|
server_args.extend(["-noreset", "+byteswappedclients"])
|
|
|
|
# Auto-detect xkb directory if the compiled-in default doesn't exist
|
|
xkb_dir = os.environ.get("XKB_CONFIG_ROOT")
|
|
if not xkb_dir:
|
|
for candidate in ["/usr/share/X11/xkb", "/usr/local/share/X11/xkb"]:
|
|
if os.path.isdir(candidate):
|
|
xkb_dir = candidate
|
|
break
|
|
if xkb_dir:
|
|
server_args.extend(["-xkbdir", xkb_dir])
|
|
|
|
server_args.extend(["-displayfd", str(displayfd)])
|
|
server_args.extend(self.extra_args)
|
|
|
|
if not self.valgrind:
|
|
return server_args
|
|
|
|
xml_fd, xml_path = tempfile.mkstemp(
|
|
prefix=f"xserver-{self.server_type}-valgrind-", suffix=".xml"
|
|
)
|
|
os.close(xml_fd)
|
|
self._valgrind_xml_file = Path(xml_path)
|
|
|
|
valgrind_cmd = [
|
|
"valgrind",
|
|
"--tool=memcheck",
|
|
"--leak-check=full",
|
|
"--track-origins=yes",
|
|
"--show-reachable=no",
|
|
"--gen-suppressions=all",
|
|
f"--error-exitcode={self.VALGRIND_ERROR_EXIT}",
|
|
"--xml=yes",
|
|
f"--xml-file={self._valgrind_xml_file}",
|
|
]
|
|
|
|
if self.valgrind_suppressions:
|
|
valgrind_cmd.append(f"--suppressions={self.valgrind_suppressions}")
|
|
|
|
return valgrind_cmd + server_args
|
|
|
|
def _setup_asan_env(self, env):
|
|
"""Configure ASAN_OPTIONS for the server subprocess.
|
|
|
|
Sets up a log file for ASAN output and configures ASAN to not
|
|
detect leaks (too noisy for these tests). Any existing
|
|
ASAN_OPTIONS from the environment are preserved.
|
|
"""
|
|
asan_fd, asan_path = tempfile.mkstemp(
|
|
prefix=f"xserver-{self.server_type}-asan-",
|
|
suffix=".log",
|
|
)
|
|
os.close(asan_fd)
|
|
self._asan_log_path = Path(asan_path)
|
|
|
|
asan_opts = {
|
|
"log_path": str(self._asan_log_path),
|
|
"detect_leaks": "0",
|
|
}
|
|
|
|
# Merge with any existing ASAN_OPTIONS from the environment
|
|
existing = env.get("ASAN_OPTIONS", "")
|
|
for part in existing.split(":"):
|
|
part = part.strip()
|
|
if "=" in part:
|
|
key, val = part.split("=", 1)
|
|
# Don't override our log_path
|
|
if key not in asan_opts:
|
|
asan_opts[key] = val
|
|
|
|
env["ASAN_OPTIONS"] = ":".join(f"{k}={v}" for k, v in asan_opts.items())
|
|
|
|
def _start_wayland_compositor(self):
|
|
"""Start weston as a headless compositor for Xwayland testing."""
|
|
if not shutil.which("weston"):
|
|
raise FileNotFoundError(
|
|
"weston is required for Xwayland testing but was not found"
|
|
)
|
|
|
|
self._weston_process = subprocess.Popen(
|
|
["weston", "--no-config", "--backend=headless-backend.so"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
time.sleep(1)
|
|
if self._weston_process.poll() is not None:
|
|
raise RuntimeError(
|
|
f"weston exited with code {self._weston_process.returncode}"
|
|
)
|
|
|
|
@property
|
|
def is_alive(self) -> bool:
|
|
"""Check if the server process is still running."""
|
|
if self._process is None:
|
|
return False
|
|
return self._process.poll() is None
|
|
|
|
@property
|
|
def crash_signal(self) -> int | None:
|
|
"""If the server crashed, return the signal number. Otherwise None."""
|
|
if self._process is None:
|
|
return None
|
|
ret = self._process.poll()
|
|
if ret is not None and ret < 0:
|
|
return -ret
|
|
return None
|
|
|
|
def stop(self):
|
|
"""Stop the server and return any valgrind/ASAN errors."""
|
|
errors = []
|
|
|
|
if self._process and self._process.poll() is None:
|
|
self._process.terminate()
|
|
try:
|
|
self._process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self._process.kill()
|
|
self._process.wait(timeout=5)
|
|
|
|
if self._weston_process and self._weston_process.poll() is None:
|
|
self._weston_process.terminate()
|
|
try:
|
|
self._weston_process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self._weston_process.kill()
|
|
self._weston_process.wait()
|
|
|
|
if self.valgrind and self._valgrind_xml_file:
|
|
errors = ValgrindError.from_xml(self._valgrind_xml_file)
|
|
|
|
if self._stderr_file:
|
|
self._stderr_file.close()
|
|
|
|
return errors
|
|
|
|
def _read_stderr(self):
|
|
"""Read the captured stderr content."""
|
|
if self._stderr_file:
|
|
self._stderr_file.flush()
|
|
try:
|
|
return self._stderr_path.read_text()
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
@property
|
|
def valgrind_xml_path(self) -> Path | None:
|
|
"""Path to this server's valgrind XML output, if valgrind was used.
|
|
|
|
Stays valid after :meth:`stop`, so callers can persist it
|
|
elsewhere (the file itself is not deleted automatically).
|
|
"""
|
|
if self._valgrind_xml_file is None:
|
|
return None
|
|
return self._valgrind_xml_file
|
|
|
|
def check_valgrind_errors(self):
|
|
"""Parse and assert no valgrind errors occurred."""
|
|
if self._valgrind_xml_file is None:
|
|
return []
|
|
errors = ValgrindError.from_xml(self._valgrind_xml_file)
|
|
if errors:
|
|
msg = f"Valgrind found {len(errors)} error(s):\n\n"
|
|
msg += "\n\n".join(str(e) for e in errors)
|
|
raise AssertionError(msg)
|
|
return errors
|
|
|
|
def get_asan_errors(self) -> list[AsanError]:
|
|
"""Parse ASAN errors from log file and/or stderr."""
|
|
errors: list[AsanError] = []
|
|
|
|
# Check the ASAN log file(s) first (ASAN appends .<pid> to log_path)
|
|
if self._asan_log_path:
|
|
errors.extend(AsanError.from_log(self._asan_log_path))
|
|
|
|
# Also check stderr in case ASAN wrote there
|
|
stderr_text = self._read_stderr()
|
|
if stderr_text and "AddressSanitizer" in stderr_text:
|
|
stderr_errors = AsanError.from_text(stderr_text)
|
|
# Avoid duplicates: only add stderr errors if log file had none
|
|
if not errors:
|
|
errors.extend(stderr_errors)
|
|
|
|
return errors
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.stop()
|
|
return False
|
|
|
|
def __repr__(self):
|
|
state = "running" if self.is_alive else "stopped"
|
|
display = self.display or "N/A"
|
|
flags = ""
|
|
if self.valgrind:
|
|
flags += " +valgrind"
|
|
if self.asan:
|
|
flags += " +asan"
|
|
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)
|
|
if sys.platform.startswith("sunos"):
|
|
import ucred as _ucred
|
|
|
|
ucred = _ucred.getpeer(sock.fileno())
|
|
pid = ucred.getpid()
|
|
else:
|
|
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}>"
|