Add XWayland support (#16)

* save dev state

* fix xwayland-satellite supervisor lifecycle, readiness and cleanup

* remove wayland scanner protocol generated files

* default xwayland enabled with path

* remove jargons

* address x11 review: park unconfirmed cleanup, never signal a reaped group, fix storm test and notify dir mode

* fix resize tests: never build Size values from negative dimensions

* add min-width/min-height window rules that replace client minimum sizes

* rename effective_size_constraints to effective_minimum_size and minor cleanups
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-06 19:40:22 +01:00
1 parent ed23949a95
commit a6f83689f8
42 files changed
+7162 -1333

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Fake Satellite for child-launch and notification tests; it does not serve X11."""
import ctypes
import errno
import json
import os
import signal
import socket
import sys
import time
SO_ACCEPTCONN = 30
_LIBC = ctypes.CDLL(None, use_errno=True)
_LIBC.getsockopt.argtypes = [
ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint),
]
RECORD = os.environ.get('FAKE_SATELLITE_RECORD', '')
READY = os.environ.get('FAKE_SATELLITE_READY', '1')
READY_DELAY = float(os.environ.get('FAKE_SATELLITE_READY_DELAY', '0'))
EXIT_BEFORE_READY = os.environ.get('FAKE_SATELLITE_EXIT_BEFORE_READY', '0') == '1'
EXIT_AFTER_READY = os.environ.get('FAKE_SATELLITE_EXIT_AFTER_READY', '0') == '1'
FORK_NOTIFY = os.environ.get('FAKE_SATELLITE_FORK_NOTIFY', '0') == '1'
IGNORE_SIGTERM = os.environ.get('FAKE_SATELLITE_IGNORE_SIGTERM', '0') == '1'
HOLD_FDS = os.environ.get('FAKE_SATELLITE_HOLD_FDS', '0') == '1'
LINGER = float(os.environ.get('FAKE_SATELLITE_LINGER', '30'))
def parse_args(argv):
display = None
authority = None
listenfds = []
index = 0
while index < len(argv):
argument = argv[index]
if display is None and not argument.startswith('-'):
display = argument
index += 1
continue
if argument == '-auth':
index += 1
authority = argv[index]
elif argument == '-nolisten':
index += 1
if argv[index] != 'tcp':
sys.exit('-nolisten must be followed by tcp')
elif argument == '-listenfd':
index += 1
listenfds.append(int(argv[index]))
else:
sys.exit(f'unexpected argument: {argument}')
index += 1
if display is None or authority is None:
sys.exit('missing display or -auth')
if not listenfds:
sys.exit('expected at least one -listenfd')
if len(set(listenfds)) != len(listenfds):
sys.exit('duplicate -listenfd')
return display, authority, listenfds
# The scan's own descriptor and entries that vanish mid-scan are not held descriptors.
def inherited_fds():
inherited = []
with os.scandir('/proc/self/fd') as entries:
for entry in entries:
try:
fd = int(entry.name)
except ValueError:
continue
try:
target = os.readlink(entry.path)
except OSError:
continue
if target == '/proc/self/fd':
continue
inherited.append(fd)
return sorted(inherited)
# Compare the link target: an fd number alone can be reused by an unrelated descriptor.
def sentinel_is_leaked(inherited):
sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD')
if not sentinel:
return False
try:
fd = int(sentinel)
except ValueError:
return False
target = os.environ.get('FAKE_SATELLITE_SENTINEL_TARGET')
if target:
try:
return os.readlink(f'/proc/self/fd/{fd}') == target
except OSError:
return False
return fd in inherited
def describe_fds(listenfds):
inherited = inherited_fds()
sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD')
return {
'inherited_fds': inherited,
'listenfds': listenfds,
'sentinel_fd': int(sentinel) if sentinel else None,
'sentinel_leaked': sentinel_is_leaked(inherited),
'listening': {
str(fd): is_listening(fd) for fd in listenfds
},
'all_listening': all(is_listening(fd) for fd in listenfds),
}
def is_listening(fd):
value = ctypes.c_int(0)
length = ctypes.c_uint(ctypes.sizeof(value))
if _LIBC.getsockopt(
fd,
socket.SOL_SOCKET,
SO_ACCEPTCONN,
ctypes.byref(value),
ctypes.byref(length),
) != 0:
return False
return value.value == 1
def notify(payload):
notify_socket = os.environ.get('NOTIFY_SOCKET')
if not notify_socket:
return False
address = notify_socket[1:] if notify_socket.startswith('@') else notify_socket
sender = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
sender.connect(address)
sender.sendall(payload)
return True
except OSError as error:
print(f'fake-satellite: notify failed: {error}', file=sys.stderr)
return False
finally:
sender.close()
# The descendant never exits on its own: a live holder after the leader is gone means group cleanup failed.
def hold_descriptors(listenfds):
child = os.fork()
if child == 0:
for fd in listenfds:
try:
os.dup2(fd, fd)
except OSError:
pass
while True:
time.sleep(3600)
return child
def main():
display, authority, listenfds = parse_args(sys.argv[1:])
if IGNORE_SIGTERM:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
holder_pid = None
if HOLD_FDS:
holder_pid = hold_descriptors(listenfds)
if RECORD:
record = {
'pid': os.getpid(),
'display': display,
'authority': authority,
'argv': sys.argv[1:],
'env': {
key: os.environ.get(key)
for key in ('DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY',
'WAYLAND_SOCKET', 'FERESE_PUBLIC_WAYLAND_DISPLAY',
'FERESE_SHELL_CONTROL_SOCKET')
},
}
record.update(describe_fds(listenfds))
record['holder_pid'] = holder_pid
with open(RECORD, 'a', encoding='utf-8') as handle:
handle.write(json.dumps(record) + '\n')
if EXIT_BEFORE_READY:
sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '17')))
if READY_DELAY:
time.sleep(READY_DELAY)
if READY == '1':
payload = b'READY=1'
elif READY == 'child':
payload = b'READY=1'
elif READY == 'garbage':
payload = b'READY'
elif READY == 'truncated':
# Oversize the buffer so the kernel really reports MSG_TRUNC; a short
# payload would only be a malformed line.
payload = b'READY=1' + b'X' * 4096
else:
payload = b''
if payload:
if FORK_NOTIFY or READY == 'child':
pid = os.fork()
if pid == 0:
notify(payload)
os._exit(0)
os.waitpid(pid, 0)
else:
notify(payload)
if EXIT_AFTER_READY:
sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '19')))
deadline = time.monotonic() + LINGER
while time.monotonic() < deadline:
time.sleep(0.05)
sys.exit(0)
if __name__ == '__main__':
try:
main()
except OSError as error:
if error.errno != errno.EINTR:
raise
+54 -6
View File
@@ -12,13 +12,16 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
class SessionStartupTest(unittest.TestCase):
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False):
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False,
display=None, xauthority=None, extra_env=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
helper = root / 'ferese-session-shell'
shutil.copyfile(SCRIPT, helper)
programs = {
'dbus-update-activation-environment': 'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n',
'dbus-update-activation-environment': (
'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n'
'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'),
'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n',
'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n',
'ferese-polkit-agent': 'exec sleep 30\n',
@@ -32,6 +35,13 @@ class SessionStartupTest(unittest.TestCase):
FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display',
FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77',
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate)))
env.pop('DISPLAY', None)
env.pop('XAUTHORITY', None)
if display is not None:
env['DISPLAY'] = display
if xauthority is not None:
env['XAUTHORITY'] = xauthority
env.update(extra_env or {})
child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if terminate:
deadline = time.monotonic() + 3
@@ -44,13 +54,51 @@ class SessionStartupTest(unittest.TestCase):
child.communicate(timeout=4)
return child.returncode, (root / 'events').read_text().splitlines()
@staticmethod
def imported_vars(events):
for line in events:
if line.startswith('vars:'):
return line[len('vars:'):].split(' ')
raise AssertionError('no activation-environment import recorded')
def test_direct_session_imports_public_display_before_activation_and_cleans_up(self):
status, events = self.run_session(shell_status=7)
self.assertEqual(status, 7)
self.assertEqual(events, ['import:public-display', 'reload-bus', '--user daemon-reload',
'--user start ferese-session.target',
'--user try-restart xdg-desktop-portal-ferese.service', 'shell:77',
'--user stop ferese-session.target'])
self.assertEqual([e for e in events if e.startswith('import:')], ['import:public-display'])
self.assertEqual([e for e in events if not e.startswith(('import:', 'vars:'))],
['reload-bus', '--user daemon-reload',
'--user start ferese-session.target',
'--user try-restart xdg-desktop-portal-ferese.service', 'shell:77',
'--user stop ferese-session.target'])
def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self):
status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd')
self.assertEqual(status, 0)
imported = self.imported_vars(events)
self.assertIn('DISPLAY=:7', imported)
self.assertIn('XAUTHORITY=/run/user/1000/ferese-xauth-abcd', imported)
self.assertIn('WAYLAND_DISPLAY=public-display', imported)
def test_a_session_without_x11_imports_empty_values_instead_of_omitting_them(self):
status, events = self.run_session()
self.assertEqual(status, 0)
imported = self.imported_vars(events)
self.assertIn('DISPLAY=', imported)
self.assertIn('XAUTHORITY=', imported)
def test_activation_import_never_leaks_the_private_socket_or_cookies(self):
status, events = self.run_session(
display=':7', xauthority='/run/user/1000/ferese-xauth-abcd',
extra_env={'WAYLAND_SOCKET': '77',
'FERESE_SHELL_CONTROL_SOCKET': '/run/user/1000/ferese/shell.sock',
'FERESE_PRIVATE_X11_COOKIE': 'deadbeefdeadbeefdeadbeefdeadbeef'})
self.assertEqual(status, 0)
imported = self.imported_vars(events)
for leaked in ('WAYLAND_SOCKET', 'FERESE_SHELL_CONTROL_SOCKET',
'FERESE_PRIVATE_X11_COOKIE', 'deadbeef'):
self.assertFalse([entry for entry in imported if leaked in entry],
f'{leaked} must not reach the activation environment')
self.assertIn('WAYLAND_DISPLAY=public-display', imported)
def test_nested_session_never_changes_host_activation(self):
status, events = self.run_session(direct=False)