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