xref: /aosp_15_r20/external/cronet/testing/xvfb.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
1#!/usr/bin/env vpython3
2# Copyright 2012 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Runs tests with Xvfb or Xorg and Openbox or Weston on Linux and normally on
7other platforms."""
8
9from __future__ import print_function
10
11import copy
12import os
13import os.path
14import random
15import re
16import signal
17import socket
18import subprocess
19import sys
20import tempfile
21import threading
22import time
23
24import psutil
25
26import test_env
27
28DEFAULT_XVFB_WHD = '1280x800x24'
29
30# pylint: disable=useless-object-inheritance
31
32
33class _X11ProcessError(Exception):
34  """Exception raised when Xvfb or Xorg cannot start."""
35
36class _WestonProcessError(Exception):
37  """Exception raised when Weston cannot start."""
38
39
40def kill(proc, name, timeout_in_seconds=10):
41  """Tries to kill |proc| gracefully with a timeout for each signal."""
42  if not proc:
43    return
44
45  thread = threading.Thread(target=proc.wait)
46  try:
47    proc.terminate()
48    thread.start()
49
50    thread.join(timeout_in_seconds)
51    if thread.is_alive():
52      print('%s running after SIGTERM, trying SIGKILL.\n' % name,
53        file=sys.stderr)
54      proc.kill()
55  except OSError as e:
56    # proc.terminate()/kill() can raise, not sure if only ProcessLookupError
57    # which is explained in https://bugs.python.org/issue40550#msg382427
58    print('Exception while killing process %s: %s' % (name, e), file=sys.stderr)
59
60  thread.join(timeout_in_seconds)
61  if thread.is_alive():
62    print('%s running after SIGTERM and SIGKILL; good luck!\n' % name,
63          file=sys.stderr)
64
65
66def launch_dbus(env): # pylint: disable=inconsistent-return-statements
67  """Starts a DBus session.
68
69  Works around a bug in GLib where it performs operations which aren't
70  async-signal-safe (in particular, memory allocations) between fork and exec
71  when it spawns subprocesses. This causes threads inside Chrome's browser and
72  utility processes to get stuck, and this harness to hang waiting for those
73  processes, which will never terminate. This doesn't happen on users'
74  machines, because they have an active desktop session and the
75  DBUS_SESSION_BUS_ADDRESS environment variable set, but it can happen on
76  headless environments. This is fixed by glib commit [1], but this workaround
77  will be necessary until the fix rolls into Chromium's CI.
78
79  [1] f2917459f745bebf931bccd5cc2c33aa81ef4d12
80
81  Modifies the passed in environment with at least DBUS_SESSION_BUS_ADDRESS and
82  DBUS_SESSION_BUS_PID set.
83
84  Returns the pid of the dbus-daemon if started, or None otherwise.
85  """
86  if 'DBUS_SESSION_BUS_ADDRESS' in os.environ:
87    return
88  try:
89    dbus_output = subprocess.check_output(
90        ['dbus-launch'], env=env).decode('utf-8').split('\n')
91    for line in dbus_output:
92      m = re.match(r'([^=]+)\=(.+)', line)
93      if m:
94        env[m.group(1)] = m.group(2)
95    return int(env['DBUS_SESSION_BUS_PID'])
96  except (subprocess.CalledProcessError, OSError, KeyError, ValueError) as e:
97    print('Exception while running dbus_launch: %s' % e)
98
99
100# TODO(crbug.com/949194): Encourage setting flags to False.
101def run_executable(
102    cmd, env, stdoutfile=None, use_openbox=True, use_xcompmgr=True,
103    xvfb_whd=None, cwd=None):
104  """Runs an executable within Weston, Xvfb or Xorg on Linux or normally on
105     other platforms.
106
107  The method sets SIGUSR1 handler for Xvfb to return SIGUSR1
108  when it is ready for connections.
109  https://www.x.org/archive/X11R7.5/doc/man/man1/Xserver.1.html under Signals.
110
111  Args:
112    cmd: Command to be executed.
113    env: A copy of environment variables. "DISPLAY" and will be set if Xvfb is
114      used. "WAYLAND_DISPLAY" will be set if Weston is used.
115    stdoutfile: If provided, symbolization via script is disabled and stdout
116      is written to this file as well as to stdout.
117    use_openbox: A flag to use openbox process.
118      Some ChromeOS tests need a window manager.
119    use_xcompmgr: A flag to use xcompmgr process.
120      Some tests need a compositing wm to make use of transparent visuals.
121    xvfb_whd: WxHxD to pass to xvfb or DEFAULT_XVFB_WHD if None
122    cwd: Current working directory.
123
124  Returns:
125    the exit code of the specified commandline, or 1 on failure.
126  """
127
128  # It might seem counterintuitive to support a --no-xvfb flag in a script
129  # whose only job is to start xvfb, but doing so allows us to consolidate
130  # the logic in the layers of buildbot scripts so that we *always* use
131  # xvfb by default and don't have to worry about the distinction, it
132  # can remain solely under the control of the test invocation itself.
133  use_xvfb = True
134  if '--no-xvfb' in cmd:
135    use_xvfb = False
136    cmd.remove('--no-xvfb')
137
138  # Xorg is mostly a drop in replacement to Xvfb but has better support for
139  # dummy drivers and multi-screen testing (See: crbug.com/40257169 and
140  # http://tinyurl.com/4phsuupf). Requires Xorg binaries
141  # (package: xserver-xorg-core)
142  use_xorg = False
143  if '--use-xorg' in cmd:
144    use_xvfb = False
145    use_xorg = True
146    cmd.remove('--use-xorg')
147
148  # Tests that run on Linux platforms with Ozone/Wayland backend require
149  # a Weston instance. However, it is also required to disable xvfb so
150  # that Weston can run in a pure headless environment.
151  use_weston = False
152  if '--use-weston' in cmd:
153    if use_xvfb or use_xorg:
154      print('Unable to use Weston with xvfb or Xorg.\n', file=sys.stderr)
155      return 1
156    use_weston = True
157    cmd.remove('--use-weston')
158
159  if sys.platform.startswith('linux') and (use_xvfb or use_xorg):
160    return _run_with_x11(cmd, env, stdoutfile, use_openbox, use_xcompmgr,
161      use_xorg, xvfb_whd or DEFAULT_XVFB_WHD, cwd)
162  if use_weston:
163    return _run_with_weston(cmd, env, stdoutfile, cwd)
164  return test_env.run_executable(cmd, env, stdoutfile, cwd)
165
166
167def _make_xorg_config(whd):
168  """Generates an Xorg config file based on the specified WxHxD string and
169  returns the file path. See:
170  https://www.x.org/releases/current/doc/man/man5/xorg.conf.5.xhtml"""
171  (width, height, depth) = whd.split('x')
172  modeline = subprocess.check_output(
173    ['cvt', width, height, '60'], stderr=subprocess.STDOUT, text=True)
174  modeline_label = re.search(
175    'Modeline "(.*)"', modeline, re.IGNORECASE).group(1)
176  config = f"""
177Section "Monitor"
178  Identifier "Monitor0"
179  {modeline}
180EndSection
181Section "Device"
182  Identifier "Device0"
183  # Dummy driver requires package `xserver-xorg-video-dummy`.
184  Driver "dummy"
185  VideoRam 256000
186EndSection
187Section "Screen"
188  DefaultDepth {depth}
189  Identifier "Screen0"
190  Device "Device0"
191  Monitor "Monitor0"
192  SubSection "Display"
193    Depth {depth}
194    Modes "{modeline_label}"
195  EndSubSection
196EndSection
197  """
198  config_file = os.path.join(tempfile.gettempdir(), 'xorg.config')
199  with open(config_file, 'w') as f:
200    f.write(config)
201  return config_file
202
203def _run_with_x11(cmd, env, stdoutfile, use_openbox,
204                   use_xcompmgr, use_xorg, xvfb_whd, cwd):
205  """Runs with an X11 server. Uses Xvfb by default and Xorg when use_xorg is
206  True."""
207  openbox_proc = None
208  openbox_ready = MutableBoolean()
209  def set_openbox_ready(*_):
210    openbox_ready.setvalue(True)
211
212  xcompmgr_proc = None
213  x11_proc = None
214  x11_ready = MutableBoolean()
215  def set_x11_ready(*_):
216    x11_ready.setvalue(True)
217
218  dbus_pid = None
219  x11_binary = 'Xorg' if use_xorg else 'Xvfb'
220  xorg_config_file = _make_xorg_config(xvfb_whd) if use_xorg else None
221  try:
222    signal.signal(signal.SIGTERM, raise_x11_error)
223    signal.signal(signal.SIGINT, raise_x11_error)
224
225    xvfb_help = None
226    if not use_xorg:
227      # Before [1], the maximum number of X11 clients was 256.  After, the
228      # default limit is 256 with a configurable maximum of 512.  On systems
229      # with a large number of CPUs, the old limit of 256 may be hit for certain
230      # test suites [2] [3], so we set the limit to 512 when possible.  This
231      # flag is not available on Ubuntu 16.04 or 18.04, so a feature check is
232      # required.  Xvfb does not have a '-version' option, so checking the
233      # '-help' output is required.
234      #
235      # [1] d206c240c0b85c4da44f073d6e9a692afb6b96d2
236      # [2] https://crbug.com/1187948
237      # [3] https://crbug.com/1120107
238      xvfb_help = subprocess.check_output(
239        ['Xvfb', '-help'], stderr=subprocess.STDOUT).decode('utf8')
240
241    # Due to race condition for display number, Xvfb/Xorg might fail to run.
242    # If it does fail, try again up to 10 times, similarly to xvfb-run.
243    for _ in range(10):
244      x11_ready.setvalue(False)
245      display = find_display()
246
247      x11_cmd = None
248      if use_xorg:
249        x11_cmd = ['Xorg', display, '-config', xorg_config_file]
250      else:
251        x11_cmd = ['Xvfb', display, '-screen', '0', xvfb_whd, '-ac',
252                    '-nolisten', 'tcp', '-dpi', '96', '+extension', 'RANDR']
253        if '-maxclients' in xvfb_help:
254          x11_cmd += ['-maxclients', '512']
255
256      # Sets SIGUSR1 to ignore for Xvfb/Xorg to signal current process
257      # when it is ready. Due to race condition, USR1 signal could be sent
258      # before the process resets the signal handler, we cannot rely on
259      # signal handler to change on time.
260      signal.signal(signal.SIGUSR1, signal.SIG_IGN)
261      x11_proc = subprocess.Popen(x11_cmd, stderr=subprocess.STDOUT, env=env)
262      signal.signal(signal.SIGUSR1, set_x11_ready)
263      for _ in range(30):
264        time.sleep(.1)  # gives Xvfb/Xorg time to start or fail.
265        if x11_ready.getvalue() or x11_proc.poll() is not None:
266          break  # xvfb/xorg sent ready signal, or already failed and stopped.
267
268      if x11_proc.poll() is None:
269        if x11_ready.getvalue():
270          break  # xvfb/xorg is ready
271        kill(x11_proc, x11_binary)  # still not ready, give up and retry
272
273    if x11_proc.poll() is not None:
274      raise _X11ProcessError('Failed to start after 10 tries')
275
276    env['DISPLAY'] = display
277    # Set dummy variable for scripts.
278    env['XVFB_DISPLAY'] = display
279
280    dbus_pid = launch_dbus(env)
281
282    if use_openbox:
283      # Openbox will send a SIGUSR1 signal to the current process notifying the
284      # script it has started up.
285      current_proc_id = os.getpid()
286
287      # The CMD that is passed via the --startup flag.
288      openbox_startup_cmd = 'kill --signal SIGUSR1 %s' % str(current_proc_id)
289      # Setup the signal handlers before starting the openbox instance.
290      signal.signal(signal.SIGUSR1, signal.SIG_IGN)
291      signal.signal(signal.SIGUSR1, set_openbox_ready)
292      openbox_proc = subprocess.Popen(
293          ['openbox', '--sm-disable', '--startup',
294           openbox_startup_cmd], stderr=subprocess.STDOUT, env=env)
295
296      for _ in range(30):
297        time.sleep(.1)  # gives Openbox time to start or fail.
298        if openbox_ready.getvalue() or openbox_proc.poll() is not None:
299          break  # openbox sent ready signal, or failed and stopped.
300
301      if openbox_proc.poll() is not None or not openbox_ready.getvalue():
302        raise _X11ProcessError('Failed to start OpenBox.')
303
304    if use_xcompmgr:
305      xcompmgr_proc = subprocess.Popen(
306          'xcompmgr', stderr=subprocess.STDOUT, env=env)
307
308    return test_env.run_executable(cmd, env, stdoutfile, cwd)
309  except OSError as e:
310    print('Failed to start %s or Openbox: %s\n' % (x11_binary, str(e)),
311          file=sys.stderr)
312    return 1
313  except _X11ProcessError as e:
314    print('%s fail: %s\n' % (x11_binary, str(e)), file=sys.stderr)
315    return 1
316  finally:
317    kill(openbox_proc, 'openbox')
318    kill(xcompmgr_proc, 'xcompmgr')
319    kill(x11_proc, x11_binary)
320    if xorg_config_file != None:
321      os.remove(xorg_config_file)
322
323    # dbus-daemon is not a subprocess, so we can't SIGTERM+waitpid() on it.
324    # To ensure it exits, use SIGKILL which should be safe since all other
325    # processes that it would have been servicing have exited.
326    if dbus_pid:
327      os.kill(dbus_pid, signal.SIGKILL)
328
329
330# TODO(https://crbug.com/1060466): Write tests.
331def _run_with_weston(cmd, env, stdoutfile, cwd):
332  weston_proc = None
333
334  try:
335    signal.signal(signal.SIGTERM, raise_weston_error)
336    signal.signal(signal.SIGINT, raise_weston_error)
337
338    dbus_pid = launch_dbus(env)
339
340    # The bundled weston (//third_party/weston) is used by Linux Ozone Wayland
341    # CI and CQ testers and compiled by //ui/ozone/platform/wayland whenever
342    # there is a dependency on the Ozone/Wayland and use_bundled_weston is set
343    # in gn args. However, some tests do not require Wayland or do not use
344    # //ui/ozone at all, but still have --use-weston flag set by the
345    # OZONE_WAYLAND variant (see //testing/buildbot/variants.pyl). This results
346    # in failures and those tests cannot be run because of the exception that
347    # informs about missing weston binary. Thus, to overcome the issue before
348    # a better solution is found, add a check for the "weston" binary here and
349    # run tests without Wayland compositor if the weston binary is not found.
350    # TODO(https://1178788): find a better solution.
351    if not os.path.isfile("./weston"):
352      print('Weston is not available. Starting without Wayland compositor')
353      return test_env.run_executable(cmd, env, stdoutfile, cwd)
354
355    # Set $XDG_RUNTIME_DIR if it is not set.
356    _set_xdg_runtime_dir(env)
357
358    # Write options that can't be passed via CLI flags to the config file.
359    # 1) panel-position=none - disables the panel, which might interfere with
360    # the tests by blocking mouse input.
361    with open(_weston_config_file_path(), 'w') as weston_config_file:
362      weston_config_file.write('[shell]\npanel-position=none')
363
364    # Weston is compiled along with the Ozone/Wayland platform, and is
365    # fetched as data deps. Thus, run it from the current directory.
366    #
367    # Weston is used with the following flags:
368    # 1) --backend=headless-backend.so - runs Weston in a headless mode
369    # that does not require a real GPU card.
370    # 2) --idle-time=0 - disables idle timeout, which prevents Weston
371    # to enter idle state. Otherwise, Weston stops to send frame callbacks,
372    # and tests start to time out (this typically happens after 300 seconds -
373    # the default time after which Weston enters the idle state).
374    # 3) --modules=ui-controls.so,systemd-notify.so - enables support for the
375    # ui-controls Wayland protocol extension and the systemd-notify protocol.
376    # 4) --width && --height set size of a virtual display: we need to set
377    # an adequate size so that tests can have more room for managing size
378    # of windows.
379    # 5) --config=... - tells Weston to use our custom config.
380    weston_cmd = ['./weston', '--backend=headless-backend.so', '--idle-time=0',
381          '--modules=ui-controls.so,systemd-notify.so', '--width=1280',
382          '--height=800', '--config=' + _weston_config_file_path()]
383
384    if '--weston-use-gl' in cmd:
385      # Runs Weston using hardware acceleration instead of SwiftShader.
386      weston_cmd.append('--use-gl')
387      cmd.remove('--weston-use-gl')
388
389    if '--weston-debug-logging' in cmd:
390      cmd.remove('--weston-debug-logging')
391      env = copy.deepcopy(env)
392      env['WAYLAND_DEBUG'] = '1'
393
394    # We use the systemd-notify protocol to detect whether weston has launched
395    # successfully. We listen on a unix socket and set the NOTIFY_SOCKET
396    # environment variable to the socket's path. If we tell it to load its
397    # systemd-notify module, weston will send a 'READY=1' message to the socket
398    # once it has loaded that module.
399    # See the sd_notify(3) man page and weston's compositor/systemd-notify.c for
400    # more details.
401    with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM
402                       | socket.SOCK_NONBLOCK) as notify_socket:
403      notify_socket.bind(_weston_notify_socket_address())
404      env['NOTIFY_SOCKET'] = _weston_notify_socket_address()
405
406      weston_proc_display = None
407      for _ in range(10):
408        weston_proc = subprocess.Popen(
409          weston_cmd,
410          stderr=subprocess.STDOUT, env=env)
411
412        for _ in range(25):
413          time.sleep(0.1)  # Gives weston some time to start.
414          try:
415            if notify_socket.recv(512) == b'READY=1':
416              break
417          except BlockingIOError:
418            continue
419
420        for _ in range(25):
421          # The 'READY=1' message is sent as soon as weston loads the
422          # systemd-notify module. This happens shortly before spawning its
423          # subprocesses (e.g. desktop-shell). Wait some more to ensure they
424          # have been spawned.
425          time.sleep(0.1)
426
427          # Get the $WAYLAND_DISPLAY set by Weston and pass it to the test
428          # launcher. Please note that this env variable is local for the
429          # process. That's the reason we have to read it from Weston
430          # separately.
431          weston_proc_display = _get_display_from_weston(weston_proc.pid)
432          if weston_proc_display is not None:
433            break # Weston could launch and we found the display.
434
435        # Also break from the outer loop.
436        if weston_proc_display is not None:
437          break
438
439    # If we couldn't find the display after 10 tries, raise an exception.
440    if weston_proc_display is None:
441      raise _WestonProcessError('Failed to start Weston.')
442
443    env.pop('NOTIFY_SOCKET')
444
445    env['WAYLAND_DISPLAY'] = weston_proc_display
446    if '--chrome-wayland-debugging' in cmd:
447      cmd.remove('--chrome-wayland-debugging')
448      env['WAYLAND_DEBUG'] = '1'
449    else:
450      env['WAYLAND_DEBUG'] = '0'
451
452    return test_env.run_executable(cmd, env, stdoutfile, cwd)
453  except OSError as e:
454    print('Failed to start Weston: %s\n' % str(e), file=sys.stderr)
455    return 1
456  except _WestonProcessError as e:
457    print('Weston fail: %s\n' % str(e), file=sys.stderr)
458    return 1
459  finally:
460    kill(weston_proc, 'weston')
461
462    if os.path.exists(_weston_notify_socket_address()):
463      os.remove(_weston_notify_socket_address())
464
465    if os.path.exists(_weston_config_file_path()):
466      os.remove(_weston_config_file_path())
467
468    # dbus-daemon is not a subprocess, so we can't SIGTERM+waitpid() on it.
469    # To ensure it exits, use SIGKILL which should be safe since all other
470    # processes that it would have been servicing have exited.
471    if dbus_pid:
472      os.kill(dbus_pid, signal.SIGKILL)
473
474def _weston_notify_socket_address():
475  return os.path.join(tempfile.gettempdir(), '.xvfb.py-weston-notify.sock')
476
477def _weston_config_file_path():
478  return os.path.join(tempfile.gettempdir(), '.xvfb.py-weston.ini')
479
480def _get_display_from_weston(weston_proc_pid):
481  """Retrieves $WAYLAND_DISPLAY set by Weston.
482
483  Returns the $WAYLAND_DISPLAY variable from one of weston's subprocesses.
484
485  Weston updates this variable early in its startup in the main process, but we
486  can only read the environment variables as they were when the process was
487  created. Therefore we must use one of weston's subprocesses, which are all
488  spawned with the new value for $WAYLAND_DISPLAY. Any of them will do, as they
489  all have the same value set.
490
491  Args:
492    weston_proc_pid: The process of id of the main Weston process.
493
494  Returns:
495    the display set by Wayland, which clients can use to connect to.
496  """
497
498  # Take the parent process.
499  parent = psutil.Process(weston_proc_pid)
500  if parent is None:
501    return None # The process is not found. Give up.
502
503  # Traverse through all the children processes and find one that has
504  # $WAYLAND_DISPLAY set.
505  children = parent.children(recursive=True)
506  for process in children:
507    weston_proc_display = process.environ().get('WAYLAND_DISPLAY')
508    # If display is set, Weston could start successfully and we can use
509    # that display for Wayland connection in Chromium.
510    if weston_proc_display is not None:
511      return weston_proc_display
512  return None
513
514
515class MutableBoolean(object):
516  """Simple mutable boolean class. Used to be mutated inside an handler."""
517
518  def __init__(self):
519    self._val = False
520
521  def setvalue(self, val):
522    assert isinstance(val, bool)
523    self._val = val
524
525  def getvalue(self):
526    return self._val
527
528
529def raise_x11_error(*_):
530  raise _X11ProcessError('Terminated')
531
532
533def raise_weston_error(*_):
534  raise _WestonProcessError('Terminated')
535
536
537def find_display():
538  """Iterates through X-lock files to find an available display number.
539
540  The lower bound follows xvfb-run standard at 99, and the upper bound
541  is set to 119.
542
543  Returns:
544    A string of a random available display number for Xvfb ':{99-119}'.
545
546  Raises:
547    _X11ProcessError: Raised when displays 99 through 119 are unavailable.
548  """
549
550  available_displays = [
551      d for d in range(99, 120)
552      if not os.path.isfile('/tmp/.X{}-lock'.format(d))
553  ]
554  if available_displays:
555    return ':{}'.format(random.choice(available_displays))
556  raise _X11ProcessError('Failed to find display number')
557
558
559def _set_xdg_runtime_dir(env):
560  """Sets the $XDG_RUNTIME_DIR variable if it hasn't been set before."""
561  runtime_dir = env.get('XDG_RUNTIME_DIR')
562  if not runtime_dir:
563    runtime_dir = '/tmp/xdg-tmp-dir/'
564    if not os.path.exists(runtime_dir):
565      os.makedirs(runtime_dir, 0o700)
566    env['XDG_RUNTIME_DIR'] = runtime_dir
567
568
569def main():
570  usage = ('Usage: xvfb.py '
571           '[command [--no-xvfb or --use_xorg or --use-weston] args...]')
572  # TODO(crbug.com/326283384): Argparse-ify this.
573  if len(sys.argv) < 2:
574    print(usage + '\n', file=sys.stderr)
575    return 2
576
577  # If the user still thinks the first argument is the execution directory then
578  # print a friendly error message and quit.
579  if os.path.isdir(sys.argv[1]):
580    print('Invalid command: \"%s\" is a directory\n' % sys.argv[1],
581          file=sys.stderr)
582    print(usage + '\n', file=sys.stderr)
583    return 3
584
585  return run_executable(sys.argv[1:], os.environ.copy())
586
587
588if __name__ == '__main__':
589  sys.exit(main())
590