xref: /aosp_15_r20/external/cronet/testing/test_env.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
1#!/usr/bin/env python
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"""Sets environment variables needed to run a chromium unit test."""
7
8from __future__ import print_function
9import io
10import os
11import signal
12import subprocess
13import sys
14import time
15
16
17# This is hardcoded to be src/ relative to this script.
18ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
20
21def trim_cmd(cmd):
22  """Removes internal flags from cmd since they're just used to communicate from
23  the host machine to this script running on the swarm slaves."""
24  sanitizers = ['asan', 'lsan', 'msan', 'tsan', 'coverage-continuous-mode',
25                'skip-set-lpac-acls']
26  internal_flags = frozenset('--%s=%d' % (name, value)
27                             for name in sanitizers
28                             for value in [0, 1])
29  return [i for i in cmd if i not in internal_flags]
30
31
32def fix_python_path(cmd):
33  """Returns the fixed command line to call the right python executable."""
34  out = cmd[:]
35  if out[0] == 'python':
36    out[0] = sys.executable
37  elif out[0].endswith('.py'):
38    out.insert(0, sys.executable)
39  return out
40
41
42def get_sanitizer_env(asan, lsan, msan, tsan, cfi_diag):
43  """Returns the environment flags needed for sanitizer tools."""
44
45  extra_env = {}
46
47  # Instruct GTK to use malloc while running sanitizer-instrumented tests.
48  extra_env['G_SLICE'] = 'always-malloc'
49
50  extra_env['NSS_DISABLE_ARENA_FREE_LIST'] = '1'
51  extra_env['NSS_DISABLE_UNLOAD'] = '1'
52
53  # TODO(glider): remove the symbolizer path once
54  # https://code.google.com/p/address-sanitizer/issues/detail?id=134 is fixed.
55  symbolizer_path = os.path.join(ROOT_DIR,
56      'third_party', 'llvm-build', 'Release+Asserts', 'bin', 'llvm-symbolizer')
57
58  if lsan or tsan:
59    # LSan is not sandbox-compatible, so we can use online symbolization. In
60    # fact, it needs symbolization to be able to apply suppressions.
61    symbolization_options = ['symbolize=1',
62                             'external_symbolizer_path=%s' % symbolizer_path]
63  elif (asan or msan or cfi_diag) and sys.platform not in ['win32', 'cygwin']:
64    # ASan uses a script for offline symbolization, except on Windows.
65    # Important note: when running ASan with leak detection enabled, we must use
66    # the LSan symbolization options above.
67    symbolization_options = ['symbolize=0']
68    # Set the path to llvm-symbolizer to be used by asan_symbolize.py
69    extra_env['LLVM_SYMBOLIZER_PATH'] = symbolizer_path
70  else:
71    symbolization_options = []
72
73  # Leverage sanitizer to print stack trace on abort (e.g. assertion failure).
74  symbolization_options.append('handle_abort=1')
75
76  if asan:
77    asan_options = symbolization_options[:]
78    if lsan:
79      asan_options.append('detect_leaks=1')
80      # LSan appears to have trouble with later versions of glibc.
81      # See https://github.com/google/sanitizers/issues/1322
82      if 'linux' in sys.platform:
83        asan_options.append('intercept_tls_get_addr=0')
84
85    if asan_options:
86      extra_env['ASAN_OPTIONS'] = ' '.join(asan_options)
87
88  if lsan:
89    if asan or msan:
90      lsan_options = []
91    else:
92      lsan_options = symbolization_options[:]
93    if sys.platform == 'linux2':
94      # Use the debug version of libstdc++ under LSan. If we don't, there will
95      # be a lot of incomplete stack traces in the reports.
96      extra_env['LD_LIBRARY_PATH'] = '/usr/lib/x86_64-linux-gnu/debug:'
97
98    extra_env['LSAN_OPTIONS'] = ' '.join(lsan_options)
99
100  if msan:
101    msan_options = symbolization_options[:]
102    if lsan:
103      msan_options.append('detect_leaks=1')
104    extra_env['MSAN_OPTIONS'] = ' '.join(msan_options)
105    extra_env['VK_ICD_FILENAMES'] = ''
106    extra_env['LIBGL_DRIVERS_PATH'] = ''
107
108  if tsan:
109    tsan_options = symbolization_options[:]
110    extra_env['TSAN_OPTIONS'] = ' '.join(tsan_options)
111
112  # CFI uses the UBSan runtime to provide diagnostics.
113  if cfi_diag:
114    ubsan_options = symbolization_options[:] + ['print_stacktrace=1']
115    extra_env['UBSAN_OPTIONS'] = ' '.join(ubsan_options)
116
117  return extra_env
118
119def get_coverage_continuous_mode_env(env):
120  """Append %c (clang code coverage continuous mode) flag to LLVM_PROFILE_FILE
121  pattern string."""
122  llvm_profile_file = env.get('LLVM_PROFILE_FILE')
123  if not llvm_profile_file:
124    return {}
125
126  # Do not insert %c into LLVM_PROFILE_FILE if it's already there as that'll
127  # cause the coverage instrumentation to write coverage data to default.profraw
128  # instead of LLVM_PROFILE_FILE.
129  if "%c" in llvm_profile_file:
130    return {
131      'LLVM_PROFILE_FILE': llvm_profile_file
132    }
133
134  dirname, basename = os.path.split(llvm_profile_file)
135  root, ext = os.path.splitext(basename)
136
137  return {
138    'LLVM_PROFILE_FILE': os.path.join(dirname, root + "%c" + ext)
139  }
140
141def get_sanitizer_symbolize_command(json_path=None, executable_path=None):
142  """Construct the command to invoke offline symbolization script."""
143  script_path = os.path.join(
144      ROOT_DIR, 'tools', 'valgrind', 'asan', 'asan_symbolize.py')
145  cmd = [sys.executable, script_path]
146  if json_path is not None:
147    cmd.append('--test-summary-json-file=%s' % json_path)
148  if executable_path is not None:
149    cmd.append('--executable-path=%s' % executable_path)
150  return cmd
151
152
153def get_json_path(cmd):
154  """Extract the JSON test summary path from a command line."""
155  json_path_flag = '--test-launcher-summary-output='
156  for arg in cmd:
157    if arg.startswith(json_path_flag):
158      return arg.split(json_path_flag).pop()
159  return None
160
161
162def symbolize_snippets_in_json(cmd, env):
163  """Symbolize output snippets inside the JSON test summary."""
164  json_path = get_json_path(cmd)
165  if json_path is None:
166    return
167
168  try:
169    symbolize_command = get_sanitizer_symbolize_command(
170        json_path=json_path, executable_path=cmd[0])
171    p = subprocess.Popen(symbolize_command, stderr=subprocess.PIPE, env=env)
172    (_, stderr) = p.communicate()
173  except OSError as e:
174    print('Exception while symbolizing snippets: %s' % e, file=sys.stderr)
175    raise
176
177  if p.returncode != 0:
178    print("Error: failed to symbolize snippets in JSON:\n", file=sys.stderr)
179    print(stderr, file=sys.stderr)
180    raise subprocess.CalledProcessError(p.returncode, symbolize_command)
181
182
183def get_escalate_sanitizer_warnings_command(json_path):
184  """Construct the command to invoke sanitizer warnings script."""
185  script_path = os.path.join(
186      ROOT_DIR, 'tools', 'memory', 'sanitizer',
187      'escalate_sanitizer_warnings.py')
188  cmd = [sys.executable, script_path]
189  cmd.append('--test-summary-json-file=%s' % json_path)
190  return cmd
191
192
193def escalate_sanitizer_warnings_in_json(cmd, env):
194  """Escalate sanitizer warnings inside the JSON test summary."""
195  json_path = get_json_path(cmd)
196  if json_path is None:
197    print("Warning: Cannot escalate sanitizer warnings without a json summary "
198          "file:\n", file=sys.stderr)
199    return 0
200
201  try:
202    escalate_command = get_escalate_sanitizer_warnings_command(json_path)
203    p = subprocess.Popen(escalate_command, stderr=subprocess.PIPE, env=env)
204    (_, stderr) = p.communicate()
205  except OSError as e:
206    print('Exception while escalating sanitizer warnings: %s' % e,
207          file=sys.stderr)
208    raise
209
210  if p.returncode != 0:
211    print("Error: failed to escalate sanitizer warnings status in JSON:\n",
212          file=sys.stderr)
213    print(stderr, file=sys.stderr)
214  return p.returncode
215
216
217
218def run_command_with_output(argv, stdoutfile, env=None, cwd=None):
219  """Run command and stream its stdout/stderr to the console & |stdoutfile|.
220
221  Also forward_signals to obey
222  https://chromium.googlesource.com/infra/luci/luci-py/+/main/appengine/swarming/doc/Bot.md#graceful-termination_aka-the-sigterm-and-sigkill-dance
223
224  Returns:
225    integer returncode of the subprocess.
226  """
227  print('Running %r in %r (env: %r)' % (argv, cwd, env), file=sys.stderr)
228  assert stdoutfile
229  with io.open(stdoutfile, 'wb') as writer, \
230      io.open(stdoutfile, 'rb', 1) as reader:
231    process = _popen(argv, env=env, cwd=cwd, stdout=writer,
232                     stderr=subprocess.STDOUT)
233    forward_signals([process])
234    while process.poll() is None:
235      sys.stdout.write(reader.read().decode('utf-8'))
236      # This sleep is needed for signal propagation. See the
237      # wait_with_signals() docstring.
238      time.sleep(0.1)
239    # Read the remaining.
240    sys.stdout.write(reader.read().decode('utf-8'))
241    print('Command %r returned exit code %d' % (argv, process.returncode),
242          file=sys.stderr)
243    return process.returncode
244
245
246def run_command(argv, env=None, cwd=None, log=True):
247  """Run command and stream its stdout/stderr both to stdout.
248
249  Also forward_signals to obey
250  https://chromium.googlesource.com/infra/luci/luci-py/+/main/appengine/swarming/doc/Bot.md#graceful-termination_aka-the-sigterm-and-sigkill-dance
251
252  Returns:
253    integer returncode of the subprocess.
254  """
255  if log:
256    print('Running %r in %r (env: %r)' % (argv, cwd, env), file=sys.stderr)
257  process = _popen(argv, env=env, cwd=cwd, stderr=subprocess.STDOUT)
258  forward_signals([process])
259  exit_code = wait_with_signals(process)
260  if log:
261    print('Command returned exit code %d' % exit_code, file=sys.stderr)
262  return exit_code
263
264
265def run_command_output_to_handle(argv, file_handle, env=None, cwd=None):
266  """Run command and stream its stdout/stderr both to |file_handle|.
267
268  Also forward_signals to obey
269  https://chromium.googlesource.com/infra/luci/luci-py/+/main/appengine/swarming/doc/Bot.md#graceful-termination_aka-the-sigterm-and-sigkill-dance
270
271  Returns:
272    integer returncode of the subprocess.
273  """
274  print('Running %r in %r (env: %r)' % (argv, cwd, env))
275  process = _popen(
276      argv, env=env, cwd=cwd, stderr=file_handle, stdout=file_handle)
277  forward_signals([process])
278  exit_code = wait_with_signals(process)
279  print('Command returned exit code %d' % exit_code)
280  return exit_code
281
282
283def wait_with_signals(process):
284  """A version of process.wait() that works cross-platform.
285
286  This version properly surfaces the SIGBREAK signal.
287
288  From reading the subprocess.py source code, it seems we need to explicitly
289  call time.sleep(). The reason is that subprocess.Popen.wait() on Windows
290  directly calls WaitForSingleObject(), but only time.sleep() properly surface
291  the SIGBREAK signal.
292
293  Refs:
294  https://github.com/python/cpython/blob/v2.7.15/Lib/subprocess.py#L692
295  https://github.com/python/cpython/blob/v2.7.15/Modules/timemodule.c#L1084
296
297  Returns:
298    returncode of the process.
299  """
300  while process.poll() is None:
301    time.sleep(0.1)
302  return process.returncode
303
304
305def forward_signals(procs):
306  """Forwards unix's SIGTERM or win's CTRL_BREAK_EVENT to the given processes.
307
308  This plays nicely with swarming's timeout handling. See also
309  https://chromium.googlesource.com/infra/luci/luci-py/+/main/appengine/swarming/doc/Bot.md#graceful-termination_aka-the-sigterm-and-sigkill-dance
310
311  Args:
312      procs: A list of subprocess.Popen objects representing child processes.
313  """
314  assert all(isinstance(p, subprocess.Popen) for p in procs)
315  def _sig_handler(sig, _):
316    for p in procs:
317      if p.poll() is not None:
318        continue
319      # SIGBREAK is defined only for win32.
320      # pylint: disable=no-member
321      if sys.platform == 'win32' and sig == signal.SIGBREAK:
322        p.send_signal(signal.CTRL_BREAK_EVENT)
323      else:
324        print("Forwarding signal(%d) to process %d" % (sig, p.pid))
325        p.send_signal(sig)
326      # pylint: enable=no-member
327  if sys.platform == 'win32':
328    signal.signal(signal.SIGBREAK, _sig_handler) # pylint: disable=no-member
329  else:
330    signal.signal(signal.SIGTERM, _sig_handler)
331    signal.signal(signal.SIGINT, _sig_handler)
332
333
334def run_executable(cmd, env, stdoutfile=None, cwd=None):
335  """Runs an executable with:
336    - CHROME_HEADLESS set to indicate that the test is running on a
337      bot and shouldn't do anything interactive like show modal dialogs.
338    - environment variable CR_SOURCE_ROOT set to the root directory.
339    - environment variable LANGUAGE to en_US.UTF-8.
340    - environment variable CHROME_DEVEL_SANDBOX set
341    - Reuses sys.executable automatically.
342  """
343  extra_env = {
344      # Set to indicate that the executable is running non-interactively on
345      # a bot.
346      'CHROME_HEADLESS': '1',
347
348       # Many tests assume a English interface...
349      'LANG': 'en_US.UTF-8',
350  }
351
352  # Used by base/base_paths_linux.cc as an override. Just make sure the default
353  # logic is used.
354  env.pop('CR_SOURCE_ROOT', None)
355
356  # Copy logic from  tools/build/scripts/slave/runtest.py.
357  asan = '--asan=1' in cmd
358  lsan = '--lsan=1' in cmd
359  msan = '--msan=1' in cmd
360  tsan = '--tsan=1' in cmd
361  cfi_diag = '--cfi-diag=1' in cmd
362  # Treat sanitizer warnings as test case failures.
363  use_sanitizer_warnings_script = '--fail-san=1' in cmd
364  if stdoutfile or sys.platform in ['win32', 'cygwin']:
365    # Symbolization works in-process on Windows even when sandboxed.
366    use_symbolization_script = False
367  else:
368    # If any sanitizer is enabled, we print unsymbolized stack trace
369    # that is required to run through symbolization script.
370    use_symbolization_script = (asan or msan or cfi_diag or lsan or tsan)
371
372  if asan or lsan or msan or tsan or cfi_diag:
373    extra_env.update(get_sanitizer_env(asan, lsan, msan, tsan, cfi_diag))
374
375  if lsan or tsan:
376    # LSan and TSan are not sandbox-friendly.
377    cmd.append('--no-sandbox')
378
379  # Enable clang code coverage continuous mode.
380  if '--coverage-continuous-mode=1' in cmd:
381    extra_env.update(get_coverage_continuous_mode_env(env))
382
383  # pylint: disable=import-outside-toplevel
384  if '--skip-set-lpac-acls=1' not in cmd and sys.platform == 'win32':
385    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
386        'scripts'))
387    from scripts import common
388    common.set_lpac_acls(ROOT_DIR, is_test_script=True)
389  # pylint: enable=import-outside-toplevel
390
391  cmd = trim_cmd(cmd)
392
393  # Ensure paths are correctly separated on windows.
394  cmd[0] = cmd[0].replace('/', os.path.sep)
395  cmd = fix_python_path(cmd)
396
397  # We also want to print the GTEST env vars that were set by the caller,
398  # because you need them to reproduce the task properly.
399  env_to_print = extra_env.copy()
400  for env_var_name in ('GTEST_SHARD_INDEX', 'GTEST_TOTAL_SHARDS'):
401    if env_var_name in env:
402      env_to_print[env_var_name] = env[env_var_name]
403
404  print('Additional test environment:\n%s\n'
405        'Command: %s\n' % (
406        '\n'.join('    %s=%s' % (k, v)
407                  for k, v in sorted(env_to_print.items())),
408        ' '.join(cmd)))
409  sys.stdout.flush()
410  env.update(extra_env or {})
411  try:
412    if stdoutfile:
413      # Write to stdoutfile and poll to produce terminal output.
414      return run_command_with_output(cmd,
415                                     env=env,
416                                     stdoutfile=stdoutfile,
417                                     cwd=cwd)
418    if use_symbolization_script:
419      # See above comment regarding offline symbolization.
420      # Need to pipe to the symbolizer script.
421      p1 = _popen(cmd, env=env, stdout=subprocess.PIPE,
422                  cwd=cwd, stderr=sys.stdout)
423      p2 = _popen(
424          get_sanitizer_symbolize_command(executable_path=cmd[0]),
425          env=env, stdin=p1.stdout)
426      p1.stdout.close()  # Allow p1 to receive a SIGPIPE if p2 exits.
427      forward_signals([p1, p2])
428      wait_with_signals(p1)
429      wait_with_signals(p2)
430      # Also feed the out-of-band JSON output to the symbolizer script.
431      symbolize_snippets_in_json(cmd, env)
432      returncode = p1.returncode
433    else:
434      returncode = run_command(cmd, env=env, cwd=cwd, log=False)
435    # Check if we should post-process sanitizer warnings.
436    if use_sanitizer_warnings_script:
437      escalate_returncode = escalate_sanitizer_warnings_in_json(cmd, env)
438      if not returncode and escalate_returncode:
439        print('Tests with sanitizer warnings led to task failure.')
440        returncode = escalate_returncode
441    return returncode
442  except OSError:
443    print('Failed to start %s' % cmd, file=sys.stderr)
444    raise
445
446
447def _popen(*args, **kwargs):
448  assert 'creationflags' not in kwargs
449  if sys.platform == 'win32':
450    # Necessary for signal handling. See crbug.com/733612#c6.
451    kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
452  return subprocess.Popen(*args, **kwargs)
453
454
455def main():
456  return run_executable(sys.argv[1:], os.environ.copy())
457
458
459if __name__ == '__main__':
460  sys.exit(main())
461