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