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