1#!/usr/bin/env python3
2#
3# Copyright (C) 2021 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""
18This scripts compiles Java files which are needed to execute run-tests.
19It is intended to be used only from soong genrule.
20"""
21
22import argparse
23import functools
24import glob
25import os
26import pathlib
27import re
28import shlex
29import shutil
30import subprocess
31import sys
32import zipfile
33
34from argparse import ArgumentParser
35from concurrent.futures import ThreadPoolExecutor
36from fcntl import lockf, LOCK_EX, LOCK_NB
37from importlib.machinery import SourceFileLoader
38from os import environ, getcwd, chdir, cpu_count, chmod
39from os.path import relpath
40from pathlib import Path
41from pprint import pprint
42from re import match
43from shutil import copytree, rmtree
44from subprocess import run
45from tempfile import TemporaryDirectory, NamedTemporaryFile
46from typing import Dict, List, Union, Set, Optional
47
48USE_RBE = 100  # Percentage of tests that can use RBE (between 0 and 100)
49
50lock_file = None  # Keep alive as long as this process is alive.
51
52RBE_COMPARE = False  # Debugging: Check that RBE and local output are identical.
53
54RBE_D8_DISABLED_FOR = {
55  "952-invoke-custom",        # b/228312861: RBE uses wrong inputs.
56  "979-const-method-handle",  # b/228312861: RBE uses wrong inputs.
57}
58
59# Debug option. Report commands that are taking a lot of user CPU time.
60REPORT_SLOW_COMMANDS = False
61
62class BuildTestContext:
63  def __init__(self, args, android_build_top, test_dir):
64    self.android_build_top = android_build_top.absolute()
65    self.bootclasspath = args.bootclasspath.absolute()
66    self.test_name = test_dir.name
67    self.test_dir = test_dir.absolute()
68    self.mode = args.mode
69    self.jvm = (self.mode == "jvm")
70    self.host = (self.mode == "host")
71    self.target = (self.mode == "target")
72    assert self.jvm or self.host or self.target
73
74    self.java_home = Path(os.environ.get("JAVA_HOME")).absolute()
75    self.java_path = self.java_home / "bin/java"
76    self.javac_path = self.java_home / "bin/javac"
77    self.javac_args = "-g -Xlint:-options"
78
79    # Helper functions to execute tools.
80    self.d8_path = args.d8.absolute()
81    self.d8 = functools.partial(self.run, args.d8.absolute())
82    self.jasmin = functools.partial(self.run, args.jasmin.absolute())
83    self.javac = functools.partial(self.run, self.javac_path)
84    self.smali_path = args.smali.absolute()
85    self.rbe_rewrapper = args.rewrapper.absolute()
86    self.smali = functools.partial(self.run, args.smali.absolute())
87    self.soong_zip = functools.partial(self.run, args.soong_zip.absolute())
88    self.zipalign = functools.partial(self.run, args.zipalign.absolute())
89    if args.hiddenapi:
90      self.hiddenapi = functools.partial(self.run, args.hiddenapi.absolute())
91
92    # RBE wrapper for some of the tools.
93    if "RBE_server_address" in os.environ and USE_RBE > (hash(self.test_name) % 100):
94      self.rbe_exec_root = os.environ.get("RBE_exec_root")
95
96      # TODO(b/307932183) Regression: RBE produces wrong output for D8 in ART
97      disable_d8 = any((self.test_dir / n).exists() for n in ["classes", "src2", "src-art"])
98
99      if self.test_name not in RBE_D8_DISABLED_FOR and not disable_d8:
100        self.d8 = functools.partial(self.rbe_d8, args.d8.absolute())
101      self.javac = functools.partial(self.rbe_javac, self.javac_path)
102      self.smali = functools.partial(self.rbe_smali, args.smali.absolute())
103
104    # Minimal environment needed for bash commands that we execute.
105    self.bash_env = {
106      "ANDROID_BUILD_TOP": self.android_build_top,
107      "D8": args.d8.absolute(),
108      "JAVA": self.java_path,
109      "JAVAC": self.javac_path,
110      "JAVAC_ARGS": self.javac_args,
111      "JAVA_HOME": self.java_home,
112      "PATH": os.environ["PATH"],
113      "PYTHONDONTWRITEBYTECODE": "1",
114      "SMALI": args.smali.absolute(),
115      "SOONG_ZIP": args.soong_zip.absolute(),
116      "TEST_NAME": self.test_name,
117    }
118
119  def bash(self, cmd):
120    return subprocess.run(cmd,
121                          shell=True,
122                          cwd=self.test_dir,
123                          env=self.bash_env,
124                          check=True)
125
126  def run(self, executable: pathlib.Path, args: List[Union[pathlib.Path, str]]):
127    assert isinstance(executable, pathlib.Path), executable
128    cmd: List[Union[pathlib.Path, str]] = []
129    if REPORT_SLOW_COMMANDS:
130      cmd += ["/usr/bin/time"]
131    if executable.suffix == ".sh":
132      cmd += ["/bin/bash"]
133    cmd += [executable]
134    cmd += args
135    env = self.bash_env
136    env.update({k: v for k, v in os.environ.items() if k.startswith("RBE_")})
137    # Make paths relative as otherwise we could create too long command line.
138    for i, arg in enumerate(cmd):
139      if isinstance(arg, pathlib.Path):
140        assert arg.absolute(), arg
141        cmd[i] = relpath(arg, self.test_dir)
142      elif isinstance(arg, list):
143        assert all(p.absolute() for p in arg), arg
144        cmd[i] = ":".join(relpath(p, self.test_dir) for p in arg)
145      else:
146        assert isinstance(arg, str), arg
147    p = subprocess.run(cmd,
148                       encoding=sys.stdout.encoding,
149                       cwd=self.test_dir,
150                       env=self.bash_env,
151                       stderr=subprocess.STDOUT,
152                       stdout=subprocess.PIPE)
153    if REPORT_SLOW_COMMANDS:
154      m = re.search("([0-9\.]+)user", p.stdout)
155      assert m, p.stdout
156      t = float(m.group(1))
157      if t > 1.0:
158        cmd_text = " ".join(map(str, cmd[1:]))[:100]
159        print(f"[{self.test_name}] Command took {t:.2f}s: {cmd_text}")
160
161    if p.returncode != 0:
162      raise Exception("Command failed with exit code {}\n$ {}\n{}".format(
163                      p.returncode, " ".join(map(str, cmd)), p.stdout))
164    return p
165
166  def rbe_wrap(self, args, inputs: Set[pathlib.Path]=None):
167    with NamedTemporaryFile(mode="w+t") as input_list:
168      inputs = inputs or set()
169      for i in inputs:
170        assert i.exists(), i
171      for i, arg in enumerate(args):
172        if isinstance(arg, pathlib.Path):
173          assert arg.absolute(), arg
174          inputs.add(arg)
175        elif isinstance(arg, list):
176          assert all(p.absolute() for p in arg), arg
177          inputs.update(arg)
178      input_list.writelines([relpath(i, self.rbe_exec_root)+"\n" for i in inputs])
179      input_list.flush()
180      dbg_args = ["-compare", "-num_local_reruns=1", "-num_remote_reruns=1"] if RBE_COMPARE else []
181      return self.run(self.rbe_rewrapper, [
182        "--platform=" + os.environ["RBE_platform"],
183        "--input_list_paths=" + input_list.name,
184      ] + dbg_args + args)
185
186  def rbe_javac(self, javac_path:Path, args):
187    output = relpath(Path(args[args.index("-d") + 1]), self.rbe_exec_root)
188    return self.rbe_wrap(["--output_directories", output, javac_path] + args)
189
190  def rbe_d8(self, d8_path:Path, args):
191    inputs = set([d8_path.parent.parent / "framework/d8.jar"])
192    output = relpath(Path(args[args.index("--output") + 1]), self.rbe_exec_root)
193    return self.rbe_wrap([
194      "--output_files" if output.endswith(".jar") else "--output_directories", output,
195      "--toolchain_inputs=prebuilts/jdk/jdk17/linux-x86/bin/java",
196      d8_path] + args, inputs)
197
198  def rbe_smali(self, smali_path:Path, args):
199    # The output of smali is non-deterministic, so create wrapper script,
200    # which runs D8 on the output to normalize it.
201    api = args[args.index("--api") + 1]
202    output = Path(args[args.index("--output") + 1])
203    wrapper = output.with_suffix(".sh")
204    wrapper.write_text('''
205      set -e
206      {smali} $@
207      mkdir dex_normalize
208      {d8} --min-api {api} --output dex_normalize {output}
209      cp dex_normalize/classes.dex {output}
210      rm -rf dex_normalize
211    '''.strip().format(
212      smali=relpath(self.smali_path, self.test_dir),
213      d8=relpath(self.d8_path, self.test_dir),
214      api=api,
215      output=relpath(output, self.test_dir),
216    ))
217
218    inputs = set([
219      wrapper,
220      self.smali_path,
221      self.smali_path.parent.parent / "framework/android-smali.jar",
222      self.d8_path,
223      self.d8_path.parent.parent / "framework/d8.jar",
224    ])
225    res = self.rbe_wrap([
226      "--output_files", relpath(output, self.rbe_exec_root),
227      "--toolchain_inputs=prebuilts/jdk/jdk17/linux-x86/bin/java",
228      "/bin/bash", wrapper] + args, inputs)
229    wrapper.unlink()
230    return res
231
232  def build(self) -> None:
233    script = self.test_dir / "build.py"
234    if script.exists():
235      module = SourceFileLoader("build_" + self.test_name,
236                                str(script)).load_module()
237      module.build(self)
238    else:
239      self.default_build()
240
241  def default_build(
242      self,
243      use_desugar=True,
244      use_hiddenapi=True,
245      need_dex=None,
246      zip_compression_method="deflate",
247      zip_align_bytes=None,
248      api_level:Union[int, str]=26,  # Can also be named alias (string).
249      javac_args=[],
250      javac_classpath: List[Path]=[],
251      d8_flags=[],
252      d8_dex_container=True,
253      smali_args=[],
254      use_smali=True,
255      use_jasmin=True,
256      javac_source_arg="1.8",
257      javac_target_arg="1.8"
258    ):
259    javac_classpath = javac_classpath.copy()  # Do not modify default value.
260
261    # Wrap "pathlib.Path" with our own version that ensures all paths are absolute.
262    # Plain filenames are assumed to be relative to self.test_dir and made absolute.
263    class Path(pathlib.Path):
264      def __new__(cls, filename: str):
265        path = pathlib.Path(filename)
266        return path if path.is_absolute() else (self.test_dir / path)
267
268    need_dex = (self.host or self.target) if need_dex is None else need_dex
269
270    if self.jvm:
271      # No desugaring on jvm because it supports the latest functionality.
272      use_desugar = False
273
274    # Set API level for smali and d8.
275    if isinstance(api_level, str):
276      API_LEVEL = {
277        "default-methods": 24,
278        "parameter-annotations": 25,
279        "agents": 26,
280        "method-handles": 26,
281        "var-handles": 28,
282        "const-method-type": 28,
283      }
284      api_level = API_LEVEL[api_level]
285    assert isinstance(api_level, int), api_level
286
287    def zip(zip_target: Path, *files: Path):
288      zip_args = ["-o", zip_target, "-C", zip_target.parent]
289      if zip_compression_method == "store":
290        zip_args.extend(["-L", "0"])
291      for f in files:
292        zip_args.extend(["-f", f])
293      self.soong_zip(zip_args)
294
295      if zip_align_bytes:
296        # zipalign does not operate in-place, so write results to a temp file.
297        with TemporaryDirectory() as tmp_dir:
298          tmp_file = Path(tmp_dir) / "aligned.zip"
299          self.zipalign(["-f", str(zip_align_bytes), zip_target, tmp_file])
300          # replace original zip target with our temp file.
301          tmp_file.rename(zip_target)
302
303
304    def make_jasmin(dst_dir: Path, src_dir: Path) -> Optional[Path]:
305      if not use_jasmin or not src_dir.exists():
306        return None  # No sources to compile.
307      dst_dir.mkdir()
308      self.jasmin(["-d", dst_dir] + sorted(src_dir.glob("**/*.j")))
309      return dst_dir
310
311    def make_smali(dst_dex: Path, src_dir: Path) -> Optional[Path]:
312      if not use_smali or not src_dir.exists():
313        return None  # No sources to compile.
314      p = self.smali(["-JXmx512m", "assemble"] + smali_args + ["--api", str(api_level)] +
315                     ["--output", dst_dex] + sorted(src_dir.glob("**/*.smali")))
316      assert dst_dex.exists(), p.stdout  # NB: smali returns 0 exit code even on failure.
317      return dst_dex
318
319    def make_java(dst_dir: Path, *src_dirs: Path) -> Optional[Path]:
320      if not any(src_dir.exists() for src_dir in src_dirs):
321        return None  # No sources to compile.
322      dst_dir.mkdir(exist_ok=True)
323      args = self.javac_args.split(" ") + javac_args
324      args += ["-implicit:none", "-encoding", "utf8", "-d", dst_dir]
325      args += ["-source", javac_source_arg, "-target", javac_target_arg]
326      if not self.jvm and float(javac_target_arg) < 17.0:
327        args += ["-bootclasspath", self.bootclasspath]
328      if javac_classpath:
329        args += ["-classpath", javac_classpath]
330      for src_dir in src_dirs:
331        args += sorted(src_dir.glob("**/*.java"))
332      self.javac(args)
333      javac_post = Path("javac_post.sh")
334      if javac_post.exists():
335        self.run(javac_post, [dst_dir])
336      return dst_dir
337
338
339    # Make a "dex" file given a directory of classes. This will be
340    # packaged in a jar file.
341    def make_dex(src_dir: Path):
342      dst_jar = Path(src_dir.name + ".jar")
343      args = []
344      if d8_dex_container:
345        args += ["-JDcom.android.tools.r8.dexContainerExperiment"]
346      args += d8_flags + ["--min-api", str(api_level), "--output", dst_jar]
347      args += ["--lib", self.bootclasspath] if use_desugar else ["--no-desugaring"]
348      args += sorted(src_dir.glob("**/*.class"))
349      self.d8(args)
350
351      # D8 outputs to JAR files today rather than DEX files as DX used
352      # to. To compensate, we extract the DEX from d8's output to meet the
353      # expectations of make_dex callers.
354      dst_dex = Path(src_dir.name + ".dex")
355      with TemporaryDirectory() as tmp_dir:
356        zipfile.ZipFile(dst_jar, "r").extractall(tmp_dir)
357        (Path(tmp_dir) / "classes.dex").rename(dst_dex)
358
359    # Merge all the dex files.
360    # Skip non-existing files, but at least 1 file must exist.
361    def make_dexmerge(dst_dex: Path, *src_dexs: Path):
362      # Include destination. Skip any non-existing files.
363      srcs = [f for f in [dst_dex] + list(src_dexs) if f.exists()]
364
365      # NB: We merge even if there is just single input.
366      # It is useful to normalize non-deterministic smali output.
367      tmp_dir = self.test_dir / "dexmerge"
368      tmp_dir.mkdir()
369      flags = []
370      if d8_dex_container:
371        flags += ["-JDcom.android.tools.r8.dexContainerExperiment"]
372      flags += ["--min-api", str(api_level), "--output", tmp_dir]
373      self.d8(flags + srcs)
374      assert not (tmp_dir / "classes2.dex").exists()
375      for src_file in srcs:
376        src_file.unlink()
377      (tmp_dir / "classes.dex").rename(dst_dex)
378      tmp_dir.rmdir()
379
380
381    def make_hiddenapi(*dex_files: Path):
382      if not use_hiddenapi or not Path("hiddenapi-flags.csv").exists():
383        return  # Nothing to do.
384      args: List[Union[str, Path]] = ["encode"]
385      for dex_file in dex_files:
386        args.extend(["--input-dex=" + str(dex_file), "--output-dex=" + str(dex_file)])
387      args.append("--api-flags=hiddenapi-flags.csv")
388      args.append("--no-force-assign-all")
389      self.hiddenapi(args)
390
391
392    if Path("classes.dex").exists():
393      zip(Path(self.test_name + ".jar"), Path("classes.dex"))
394      return
395
396    if Path("classes.dm").exists():
397      zip(Path(self.test_name + ".jar"), Path("classes.dm"))
398      return
399
400    if make_jasmin(Path("jasmin_classes"), Path("jasmin")):
401      javac_classpath.append(Path("jasmin_classes"))
402
403    if make_jasmin(Path("jasmin_classes2"), Path("jasmin-multidex")):
404      javac_classpath.append(Path("jasmin_classes2"))
405
406    # To allow circular references, compile src/, src-multidex/, src-aotex/,
407    # src-bcpex/, src-ex/ together and pass the output as class path argument.
408    # Replacement sources in src-art/, src2/ and src-ex2/ can replace symbols
409    # used by the other src-* sources we compile here but everything needed to
410    # compile the other src-* sources should be present in src/ (and jasmin*/).
411    extra_srcs = ["src-multidex", "src-aotex", "src-bcpex", "src-ex"]
412    replacement_srcs = ["src2", "src-ex2"] + ([] if self.jvm else ["src-art"])
413    if (Path("src").exists() and
414        any(Path(p).exists() for p in extra_srcs + replacement_srcs)):
415      make_java(Path("classes-tmp-all"), Path("src"), *map(Path, extra_srcs))
416      javac_classpath.append(Path("classes-tmp-all"))
417
418    if make_java(Path("classes-aotex"), Path("src-aotex")) and need_dex:
419      make_dex(Path("classes-aotex"))
420      # rename it so it shows up as "classes.dex" in the zip file.
421      Path("classes-aotex.dex").rename(Path("classes.dex"))
422      zip(Path(self.test_name + "-aotex.jar"), Path("classes.dex"))
423
424    if make_java(Path("classes-bcpex"), Path("src-bcpex")) and need_dex:
425      make_dex(Path("classes-bcpex"))
426      # rename it so it shows up as "classes.dex" in the zip file.
427      Path("classes-bcpex.dex").rename(Path("classes.dex"))
428      zip(Path(self.test_name + "-bcpex.jar"), Path("classes.dex"))
429
430    make_java(Path("classes"), Path("src"))
431
432    if not self.jvm:
433      # Do not attempt to build src-art directories on jvm,
434      # since it would fail without libcore.
435      make_java(Path("classes"), Path("src-art"))
436
437    if make_java(Path("classes2"), Path("src-multidex")) and need_dex:
438      make_dex(Path("classes2"))
439
440    make_java(Path("classes"), Path("src2"))
441
442    # If the classes directory is not-empty, package classes in a DEX file.
443    # NB: some tests provide classes rather than java files.
444    if any(Path("classes").glob("*")) and need_dex:
445      make_dex(Path("classes"))
446
447    if Path("jasmin_classes").exists():
448      # Compile Jasmin classes as if they were part of the classes.dex file.
449      if need_dex:
450        make_dex(Path("jasmin_classes"))
451        make_dexmerge(Path("classes.dex"), Path("jasmin_classes.dex"))
452      else:
453        # Move jasmin classes into classes directory so that they are picked up
454        # with -cp classes.
455        Path("classes").mkdir(exist_ok=True)
456        copytree(Path("jasmin_classes"), Path("classes"), dirs_exist_ok=True)
457
458    if need_dex and make_smali(Path("smali_classes.dex"), Path("smali")):
459      # Merge smali files into classes.dex,
460      # this takes priority over any jasmin files.
461      make_dexmerge(Path("classes.dex"), Path("smali_classes.dex"))
462
463    # Compile Jasmin classes in jasmin-multidex as if they were part of
464    # the classes2.jar
465    if Path("jasmin-multidex").exists():
466      if need_dex:
467        make_dex(Path("jasmin_classes2"))
468        make_dexmerge(Path("classes2.dex"), Path("jasmin_classes2.dex"))
469      else:
470        # Move jasmin classes into classes2 directory so that
471        # they are picked up with -cp classes2.
472        Path("classes2").mkdir()
473        copytree(Path("jasmin_classes2"), Path("classes2"), dirs_exist_ok=True)
474        rmtree(Path("jasmin_classes2"))
475
476    if need_dex and make_smali(Path("smali_classes2.dex"), Path("smali-multidex")):
477      # Merge smali_classes2.dex into classes2.dex
478      make_dexmerge(Path("classes2.dex"), Path("smali_classes2.dex"))
479
480    make_java(Path("classes-ex"), Path("src-ex"))
481
482    make_java(Path("classes-ex"), Path("src-ex2"))
483
484    if Path("classes-ex").exists() and need_dex:
485      make_dex(Path("classes-ex"))
486
487    if need_dex and make_smali(Path("smali_classes-ex.dex"), Path("smali-ex")):
488      # Merge smali files into classes-ex.dex.
489      make_dexmerge(Path("classes-ex.dex"), Path("smali_classes-ex.dex"))
490
491    if Path("classes-ex.dex").exists():
492      # Apply hiddenapi on the dex files if the test has API list file(s).
493      make_hiddenapi(Path("classes-ex.dex"))
494
495      # quick shuffle so that the stored name is "classes.dex"
496      Path("classes.dex").rename(Path("classes-1.dex"))
497      Path("classes-ex.dex").rename(Path("classes.dex"))
498      zip(Path(self.test_name + "-ex.jar"), Path("classes.dex"))
499      Path("classes.dex").rename(Path("classes-ex.dex"))
500      Path("classes-1.dex").rename(Path("classes.dex"))
501
502    # Apply hiddenapi on the dex files if the test has API list file(s).
503    if need_dex:
504      if any(Path(".").glob("*-multidex")):
505        make_hiddenapi(Path("classes.dex"), Path("classes2.dex"))
506      else:
507        make_hiddenapi(Path("classes.dex"))
508
509    # Create a single dex jar with two dex files for multidex.
510    if need_dex:
511      if Path("classes2.dex").exists():
512        zip(Path(self.test_name + ".jar"), Path("classes.dex"), Path("classes2.dex"))
513      else:
514        zip(Path(self.test_name + ".jar"), Path("classes.dex"))
515
516
517# If we build just individual shard, we want to split the work among all the cores,
518# but if the build system builds all shards, we don't want to overload the machine.
519# We don't know which situation we are in, so as simple work-around, we use a lock
520# file to allow only one shard to use multiprocessing at the same time.
521def use_multiprocessing(mode: str) -> bool:
522  if "RBE_server_address" in os.environ:
523    return True
524  global lock_file
525  lock_path = Path(environ["TMPDIR"]) / ("art-test-run-test-build-py-" + mode)
526  lock_file = open(lock_path, "w")
527  try:
528    lockf(lock_file, LOCK_EX | LOCK_NB)
529    return True  # We are the only instance of this script in the build system.
530  except BlockingIOError:
531    return False  # Some other instance is already running.
532
533
534def main() -> None:
535  parser = ArgumentParser(description=__doc__)
536  parser.add_argument("--out", type=Path, help="Final zip file")
537  parser.add_argument("--mode", choices=["host", "jvm", "target"])
538  parser.add_argument("--bootclasspath", type=Path)
539  parser.add_argument("--d8", type=Path)
540  parser.add_argument("--hiddenapi", type=Path)
541  parser.add_argument("--jasmin", type=Path)
542  parser.add_argument("--rewrapper", type=Path)
543  parser.add_argument("--smali", type=Path)
544  parser.add_argument("--soong_zip", type=Path)
545  parser.add_argument("--zipalign", type=Path)
546  parser.add_argument("--test-dir-regex")
547  parser.add_argument("srcs", nargs="+", type=Path)
548  args = parser.parse_args()
549
550  android_build_top = Path(getcwd()).absolute()
551  ziproot = args.out.absolute().parent / "zip"
552  test_dir_regex = re.compile(args.test_dir_regex) if args.test_dir_regex else re.compile(".*")
553  srcdirs = set(
554    s.parents[-4].absolute()
555    for s in args.srcs
556    if test_dir_regex.search(str(s))
557  )
558
559  # Special hidden-api shard: If the --hiddenapi flag is provided, build only
560  # hiddenapi tests. Otherwise exclude all hiddenapi tests from normal shards.
561  def filter_by_hiddenapi(srcdir: Path) -> bool:
562    return (args.hiddenapi != None) == ("hiddenapi" in srcdir.name)
563
564  # Initialize the test objects.
565  # We need to do this before we change the working directory below.
566  tests: List[BuildTestContext] = []
567  for srcdir in filter(filter_by_hiddenapi, srcdirs):
568    dstdir = ziproot / args.mode / srcdir.name
569    copytree(srcdir, dstdir)
570    tests.append(BuildTestContext(args, android_build_top, dstdir))
571
572  # We can not change the working directory per each thread since they all run in parallel.
573  # Create invalid read-only directory to catch accidental use of current working directory.
574  with TemporaryDirectory("-do-not-use-cwd") as invalid_tmpdir:
575    os.chdir(invalid_tmpdir)
576    os.chmod(invalid_tmpdir, 0)
577    with ThreadPoolExecutor(cpu_count() if use_multiprocessing(args.mode) else 1) as pool:
578      jobs = {}
579      for ctx in tests:
580        jobs[ctx.test_name] = pool.submit(ctx.build)
581      for test_name, job in jobs.items():
582        try:
583          job.result()
584        except Exception as e:
585          raise Exception("Failed to build " + test_name) from e
586
587  # Create the final zip file which contains the content of the temporary directory.
588  proc = run([android_build_top / args.soong_zip, "-o", android_build_top / args.out,
589              "-C", ziproot, "-D", ziproot], check=True)
590
591
592if __name__ == "__main__":
593  main()
594