1#!/usr/bin/env python
2# Copyright 2015 the V8 project authors. All rights reserved.
3# Copyright 2014 The Chromium Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7"""Adaptor script called through build/isolate.gypi.
8
9Creates a wrapping .isolate which 'includes' the original one, that can be
10consumed by tools/swarming_client/isolate.py. Path variables are determined
11based on the current working directory. The relative_cwd in the .isolated file
12is determined based on the .isolate file that declare the 'command' variable to
13be used so the wrapping .isolate doesn't affect this value.
14
15This script loads build.ninja and processes it to determine all the executables
16referenced by the isolated target. It adds them in the wrapping .isolate file.
17
18WARNING: The target to use for build.ninja analysis is the base name of the
19.isolate file plus '_run'. For example, 'foo_test.isolate' would have the target
20'foo_test_run' analysed.
21"""
22
23import errno
24import glob
25import json
26import logging
27import os
28import posixpath
29import StringIO
30import subprocess
31import sys
32import time
33
34TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
35SWARMING_CLIENT_DIR = os.path.join(TOOLS_DIR, 'swarming_client')
36SRC_DIR = os.path.dirname(TOOLS_DIR)
37
38sys.path.insert(0, SWARMING_CLIENT_DIR)
39
40import isolate_format
41
42
43def load_ninja_recursively(build_dir, ninja_path, build_steps):
44  """Crudely extracts all the subninja and build referenced in ninja_path.
45
46  In particular, it ignores rule and variable declarations. The goal is to be
47  performant (well, as much as python can be performant) which is currently in
48  the <200ms range for a complete chromium tree. As such the code is laid out
49  for performance instead of readability.
50  """
51  logging.debug('Loading %s', ninja_path)
52  try:
53    with open(os.path.join(build_dir, ninja_path), 'rb') as f:
54      line = None
55      merge_line = ''
56      subninja = []
57      for line in f:
58        line = line.rstrip()
59        if not line:
60          continue
61
62        if line[-1] == '$':
63          # The next line needs to be merged in.
64          merge_line += line[:-1]
65          continue
66
67        if merge_line:
68          line = merge_line + line
69          merge_line = ''
70
71        statement = line[:line.find(' ')]
72        if statement == 'build':
73          # Save the dependency list as a raw string. Only the lines needed will
74          # be processed with raw_build_to_deps(). This saves a good 70ms of
75          # processing time.
76          build_target, dependencies = line[6:].split(': ', 1)
77          # Interestingly, trying to be smart and only saving the build steps
78          # with the intended extensions ('', '.stamp', '.so') slows down
79          # parsing even if 90% of the build rules can be skipped.
80          # On Windows, a single step may generate two target, so split items
81          # accordingly. It has only been seen for .exe/.exe.pdb combos.
82          for i in build_target.strip().split():
83            build_steps[i] = dependencies
84        elif statement == 'subninja':
85          subninja.append(line[9:])
86  except IOError:
87    print >> sys.stderr, 'Failed to open %s' % ninja_path
88    raise
89
90  total = 1
91  for rel_path in subninja:
92    try:
93      # Load each of the files referenced.
94      # TODO(maruel): Skip the files known to not be needed. It saves an aweful
95      # lot of processing time.
96      total += load_ninja_recursively(build_dir, rel_path, build_steps)
97    except IOError:
98      print >> sys.stderr, '... as referenced by %s' % ninja_path
99      raise
100  return total
101
102
103def load_ninja(build_dir):
104  """Loads the tree of .ninja files in build_dir."""
105  build_steps = {}
106  total = load_ninja_recursively(build_dir, 'build.ninja', build_steps)
107  logging.info('Loaded %d ninja files, %d build steps', total, len(build_steps))
108  return build_steps
109
110
111def using_blacklist(item):
112  """Returns True if an item should be analyzed.
113
114  Ignores many rules that are assumed to not depend on a dynamic library. If
115  the assumption doesn't hold true anymore for a file format, remove it from
116  this list. This is simply an optimization.
117  """
118  # *.json is ignored below, *.isolated.gen.json is an exception, it is produced
119  # by isolate_driver.py in 'test_isolation_mode==prepare'.
120  if item.endswith('.isolated.gen.json'):
121    return True
122  IGNORED = (
123    '.a', '.cc', '.css', '.dat', '.def', '.frag', '.h', '.html', '.isolate',
124    '.js', '.json', '.manifest', '.o', '.obj', '.pak', '.png', '.pdb', '.py',
125    '.strings', '.test', '.txt', '.vert',
126  )
127  # ninja files use native path format.
128  ext = os.path.splitext(item)[1]
129  if ext in IGNORED:
130    return False
131  # Special case Windows, keep .dll.lib but discard .lib.
132  if item.endswith('.dll.lib'):
133    return True
134  if ext == '.lib':
135    return False
136  return item not in ('', '|', '||')
137
138
139def raw_build_to_deps(item):
140  """Converts a raw ninja build statement into the list of interesting
141  dependencies.
142  """
143  # TODO(maruel): Use a whitelist instead? .stamp, .so.TOC, .dylib.TOC,
144  # .dll.lib, .exe and empty.
145  # The first item is the build rule, e.g. 'link', 'cxx', 'phony', etc.
146  return filter(using_blacklist, item.split(' ')[1:])
147
148
149def collect_deps(target, build_steps, dependencies_added, rules_seen):
150  """Recursively adds all the interesting dependencies for |target|
151  into |dependencies_added|.
152  """
153  if rules_seen is None:
154    rules_seen = set()
155  if target in rules_seen:
156    # TODO(maruel): Figure out how it happens.
157    logging.warning('Circular dependency for %s!', target)
158    return
159  rules_seen.add(target)
160  try:
161    dependencies = raw_build_to_deps(build_steps[target])
162  except KeyError:
163    logging.info('Failed to find a build step to generate: %s', target)
164    return
165  logging.debug('collect_deps(%s) -> %s', target, dependencies)
166  for dependency in dependencies:
167    dependencies_added.add(dependency)
168    collect_deps(dependency, build_steps, dependencies_added, rules_seen)
169
170
171def post_process_deps(build_dir, dependencies):
172  """Processes the dependency list with OS specific rules."""
173  def filter_item(i):
174    if i.endswith('.so.TOC'):
175      # Remove only the suffix .TOC, not the .so!
176      return i[:-4]
177    if i.endswith('.dylib.TOC'):
178      # Remove only the suffix .TOC, not the .dylib!
179      return i[:-4]
180    if i.endswith('.dll.lib'):
181      # Remove only the suffix .lib, not the .dll!
182      return i[:-4]
183    return i
184
185  def is_exe(i):
186    # This script is only for adding new binaries that are created as part of
187    # the component build.
188    ext = os.path.splitext(i)[1]
189    # On POSIX, executables have no extension.
190    if ext not in ('', '.dll', '.dylib', '.exe', '.nexe', '.so'):
191      return False
192    if os.path.isabs(i):
193      # In some rare case, there's dependency set explicitly on files outside
194      # the checkout.
195      return False
196
197    # Check for execute access and strip directories. This gets rid of all the
198    # phony rules.
199    p = os.path.join(build_dir, i)
200    return os.access(p, os.X_OK) and not os.path.isdir(p)
201
202  return filter(is_exe, map(filter_item, dependencies))
203
204
205def create_wrapper(args, isolate_index, isolated_index):
206  """Creates a wrapper .isolate that add dynamic libs.
207
208  The original .isolate is not modified.
209  """
210  cwd = os.getcwd()
211  isolate = args[isolate_index]
212  # The code assumes the .isolate file is always specified path-less in cwd. Fix
213  # if this assumption doesn't hold true.
214  assert os.path.basename(isolate) == isolate, isolate
215
216  # This will look like ../out/Debug. This is based against cwd. Note that this
217  # must equal the value provided as PRODUCT_DIR.
218  build_dir = os.path.dirname(args[isolated_index])
219
220  # This will look like chrome/unit_tests.isolate. It is based against SRC_DIR.
221  # It's used to calculate temp_isolate.
222  src_isolate = os.path.relpath(os.path.join(cwd, isolate), SRC_DIR)
223
224  # The wrapping .isolate. This will look like
225  # ../out/Debug/gen/chrome/unit_tests.isolate.
226  temp_isolate = os.path.join(build_dir, 'gen', src_isolate)
227  temp_isolate_dir = os.path.dirname(temp_isolate)
228
229  # Relative path between the new and old .isolate file.
230  isolate_relpath = os.path.relpath(
231      '.', temp_isolate_dir).replace(os.path.sep, '/')
232
233  # It's a big assumption here that the name of the isolate file matches the
234  # primary target '_run'. Fix accordingly if this doesn't hold true, e.g.
235  # complain to maruel@.
236  target = isolate[:-len('.isolate')] + '_run'
237  build_steps = load_ninja(build_dir)
238  binary_deps = set()
239  collect_deps(target, build_steps, binary_deps, None)
240  binary_deps = post_process_deps(build_dir, binary_deps)
241  logging.debug(
242      'Binary dependencies:%s', ''.join('\n  ' + i for i in binary_deps))
243
244  # Now do actual wrapping .isolate.
245  isolate_dict = {
246    'includes': [
247      posixpath.join(isolate_relpath, isolate),
248    ],
249    'variables': {
250      # Will look like ['<(PRODUCT_DIR)/lib/flibuser_prefs.so'].
251      'files': sorted(
252          '<(PRODUCT_DIR)/%s' % i.replace(os.path.sep, '/')
253          for i in binary_deps),
254    },
255  }
256  # Some .isolate files have the same temp directory and the build system may
257  # run this script in parallel so make directories safely here.
258  try:
259    os.makedirs(temp_isolate_dir)
260  except OSError as e:
261    if e.errno != errno.EEXIST:
262      raise
263  comment = (
264      '# Warning: this file was AUTOGENERATED.\n'
265      '# DO NO EDIT.\n')
266  out = StringIO.StringIO()
267  isolate_format.print_all(comment, isolate_dict, out)
268  isolate_content = out.getvalue()
269  with open(temp_isolate, 'wb') as f:
270    f.write(isolate_content)
271  logging.info('Added %d dynamic libs', len(binary_deps))
272  logging.debug('%s', isolate_content)
273  args[isolate_index] = temp_isolate
274
275
276def prepare_isolate_call(args, output):
277  """Gathers all information required to run isolate.py later.
278
279  Dumps it as JSON to |output| file.
280  """
281  with open(output, 'wb') as f:
282    json.dump({
283      'args': args,
284      'dir': os.getcwd(),
285      'version': 1,
286    }, f, indent=2, sort_keys=True)
287
288
289def rebase_directories(args, abs_base):
290  """Rebases all paths to be relative to abs_base."""
291  def replace(index):
292    args[index] = os.path.relpath(os.path.abspath(args[index]), abs_base)
293  for i, arg in enumerate(args):
294    if arg in ['--isolate', '--isolated']:
295      replace(i + 1)
296    if arg == '--path-variable':
297      # Path variables have a triple form: --path-variable NAME <path>.
298      replace(i + 2)
299
300
301def main():
302  logging.basicConfig(level=logging.ERROR, format='%(levelname)7s %(message)s')
303  args = sys.argv[1:]
304  mode = args[0] if args else None
305  isolate = None
306  isolated = None
307  for i, arg in enumerate(args):
308    if arg == '--isolate':
309      isolate = i + 1
310    if arg == '--isolated':
311      isolated = i + 1
312  if isolate is None or isolated is None or not mode:
313    print >> sys.stderr, 'Internal failure'
314    return 1
315
316  # Make sure all paths are relative to the isolate file. This is an
317  # expectation of the go binaries. In gn, this script is not called
318  # relative to the isolate file, but relative to the product dir.
319  new_base = os.path.abspath(os.path.dirname(args[isolate]))
320  rebase_directories(args, new_base)
321  assert args[isolate] == os.path.basename(args[isolate])
322  os.chdir(new_base)
323
324  create_wrapper(args, isolate, isolated)
325
326  # In 'prepare' mode just collect all required information for postponed
327  # isolated.py invocation later, store it in *.isolated.gen.json file.
328  if mode == 'prepare':
329    prepare_isolate_call(args[1:], args[isolated] + '.gen.json')
330    return 0
331
332  swarming_client = os.path.join(SRC_DIR, 'tools', 'swarming_client')
333  sys.stdout.flush()
334  result = subprocess.call(
335      [sys.executable, os.path.join(swarming_client, 'isolate.py')] + args)
336  return result
337
338
339if __name__ == '__main__':
340  sys.exit(main())
341