| from __future__ import print_function |
| import os |
| import platform |
| import re |
| import signal |
| import subprocess |
| import sys |
| import tempfile |
| import textwrap |
| import time |
| |
| from timing import monotonic_time_nanos |
| from tracing import Tracing |
| from subprocutils import check_output, CalledProcessError |
| |
| MAX_BUCKD_RUN_COUNT = 64 |
| BUCKD_CLIENT_TIMEOUT_MILLIS = 60000 |
| GC_MAX_PAUSE_TARGET = 15000 |
| |
| BUCKD_LOG_FILE_PATTERN = re.compile('^NGServer.* port (\d+)\.$') |
| NAILGUN_CONNECTION_REFUSED_CODE = 230 |
| # TODO(natthu): CI servers, for some reason, encounter this error. |
| # For now, simply skip this error. Need to figure out why this happens. |
| NAILGUN_UNEXPECTED_CHUNK_TYPE = 229 |
| NAILGUN_CONNECTION_BROKEN_CODE = 227 |
| |
| |
| # Describes a resource used by this driver. |
| # - name: logical name of the resources |
| # - executable: whether the resource should/needs execute permissions |
| # - basename: required basename of the resource |
| class Resource(object): |
| def __init__(self, name, executable=False, basename=None): |
| self.name = name |
| self.executable = executable |
| self.basename = name if basename is None else basename |
| |
| |
| CLIENT = Resource("buck_client", executable=True, basename='ng') |
| LOG4J_CONFIG = Resource("log4j_config_file") |
| |
| # Resource that get propagated to buck via system properties. |
| EXPORTED_RESOURCES = [ |
| Resource("testrunner_classes"), |
| Resource("abi_processor_classes"), |
| Resource("path_to_asm_jar"), |
| Resource("logging_config_file"), |
| Resource("path_to_pathlib_py", basename='pathlib.py'), |
| Resource("path_to_compile_asset_catalogs_py"), |
| Resource("path_to_compile_asset_catalogs_build_phase_sh"), |
| Resource("path_to_intellij_py"), |
| Resource("path_to_python_test_main"), |
| Resource("path_to_sh_binary_template"), |
| Resource("jacoco_agent_jar"), |
| Resource("report_generator_jar"), |
| Resource("path_to_static_content"), |
| Resource("path_to_pex", executable=True), |
| Resource("quickstart_origin_dir"), |
| Resource("dx"), |
| Resource("android_agent_path"), |
| ] |
| |
| |
| class RestartBuck(Exception): |
| pass |
| |
| |
| class BuckToolException(Exception): |
| pass |
| |
| |
| class BuckTool(object): |
| |
| def __init__(self, buck_project): |
| self._buck_project = buck_project |
| self._tmp_dir = self._platform_path(buck_project.tmp_dir) |
| |
| self._pathsep = os.pathsep |
| if (sys.platform == 'cygwin'): |
| self._pathsep = ';' |
| |
| # Check whether the given resource exists. |
| def _has_resource(self, resource): |
| raise NotImplementedError() |
| |
| # Return an on-disk path to the given resource. This may cause |
| # implementations to unpack the resource at this point. |
| def _get_resource(self, resource): |
| raise NotImplementedError() |
| |
| def _use_buckd(self): |
| return not os.environ.get('NO_BUCKD') |
| |
| def _environ_for_buck(self): |
| env = os.environ.copy() |
| env['CLASSPATH'] = self._get_bootstrap_classpath() |
| env['BUCK_CLASSPATH'] = self._get_java_classpath() |
| return env |
| |
| def launch_buck(self, build_id): |
| with Tracing('BuckRepo.launch_buck'): |
| self.kill_autobuild() |
| if 'clean' in sys.argv: |
| self.kill_buckd() |
| |
| buck_version_uid = self._get_buck_version_uid() |
| |
| use_buckd = self._use_buckd() |
| has_watchman = bool(which('watchman')) |
| if use_buckd and has_watchman: |
| buckd_run_count = self._buck_project.get_buckd_run_count() |
| running_version = self._buck_project.get_running_buckd_version() |
| new_buckd_run_count = buckd_run_count + 1 |
| |
| if (buckd_run_count == MAX_BUCKD_RUN_COUNT or |
| running_version != buck_version_uid): |
| self.kill_buckd() |
| new_buckd_run_count = 0 |
| |
| if new_buckd_run_count == 0 or not self._is_buckd_running(): |
| self.launch_buckd(buck_version_uid=buck_version_uid) |
| else: |
| self._buck_project.update_buckd_run_count(new_buckd_run_count) |
| elif use_buckd and not has_watchman: |
| print("Not using buckd because watchman isn't installed.", |
| file=sys.stderr) |
| |
| env = self._environ_for_buck() |
| env['BUCK_BUILD_ID'] = build_id |
| |
| buck_client_file = self._get_resource(CLIENT) |
| if use_buckd and self._is_buckd_running() and os.path.exists(buck_client_file): |
| print("Using buckd.", file=sys.stderr) |
| buckd_port = self._buck_project.get_buckd_port() |
| if not buckd_port or not buckd_port.isdigit(): |
| print( |
| "Daemon port file is corrupt, starting new buck process.", |
| file=sys.stderr) |
| self.kill_buckd() |
| else: |
| command = [buck_client_file] |
| command.append("--nailgun-port") |
| command.append(buckd_port) |
| command.append("com.facebook.buck.cli.Main") |
| command.extend(sys.argv[1:]) |
| with Tracing('buck', args={'command': command}): |
| exit_code = subprocess.call(command, cwd=self._buck_project.root, env=env) |
| if exit_code == 2: |
| print('Daemon is busy, please wait', |
| 'or run "buckd --kill" to terminate it.', |
| file=sys.stderr) |
| return exit_code |
| |
| command = ["buck"] |
| command.extend(self._get_java_args(buck_version_uid)) |
| command.append("-Djava.io.tmpdir={0}".format(self._tmp_dir)) |
| command.append("com.facebook.buck.cli.bootstrapper.ClassLoaderBootstrapper") |
| command.append("com.facebook.buck.cli.Main") |
| command.extend(sys.argv[1:]) |
| |
| return subprocess.call(command, |
| cwd=self._buck_project.root, |
| env=env, |
| executable=which("java")) |
| |
| def launch_buckd(self, buck_version_uid=None): |
| with Tracing('BuckRepo.launch_buckd'): |
| self._setup_watchman_watch() |
| if buck_version_uid is None: |
| buck_version_uid = self._get_buck_version_uid() |
| # Override self._tmp_dir to a long lived directory. |
| buckd_tmp_dir = self._buck_project.create_buckd_tmp_dir() |
| ngserver_output_path = os.path.join(buckd_tmp_dir, 'ngserver-out') |
| |
| ''' |
| Use SoftRefLRUPolicyMSPerMB for immediate GC of javac output. |
| Set timeout to 60s (longer than the biggest GC pause seen for a 2GB |
| heap) and GC target to 15s. This means that the GC has to miss its |
| target by 100% or many 500ms heartbeats must be missed before a client |
| disconnection occurs. Specify port 0 to allow Nailgun to find an |
| available port, then parse the port number out of the first log entry. |
| ''' |
| command = ["buckd"] |
| command.extend(self._get_java_args(buck_version_uid)) |
| command.append("-Dbuck.buckd_launch_time_nanos={0}".format(monotonic_time_nanos())) |
| command.append("-XX:MaxGCPauseMillis={0}".format(GC_MAX_PAUSE_TARGET)) |
| command.append("-XX:SoftRefLRUPolicyMSPerMB=0") |
| command.append("-Djava.io.tmpdir={0}".format(buckd_tmp_dir)) |
| command.append("-Dcom.martiansoftware.nailgun.NGServer.outputPath={0}".format( |
| ngserver_output_path)) |
| command.append("com.facebook.buck.cli.bootstrapper.ClassLoaderBootstrapper") |
| command.append("com.martiansoftware.nailgun.NGServer") |
| command.append("localhost:0") |
| command.append("{0}".format(BUCKD_CLIENT_TIMEOUT_MILLIS)) |
| |
| ''' |
| Change the process group of the child buckd process so that when this |
| script is interrupted, it does not kill buckd. |
| ''' |
| def preexec_func(): |
| # Close any open file descriptors to further separate buckd from its |
| # invoking context (e.g. otherwise we'd hang when running things like |
| # `ssh localhost buck clean`). |
| |
| # N.B. preexec_func is POSIX-only, and any reasonable |
| # POSIX system has a /dev/null |
| os.setpgrp() |
| dev_null_fd = os.open("/dev/null", os.O_RDWR) |
| os.dup2(dev_null_fd, 0) |
| os.dup2(dev_null_fd, 1) |
| os.dup2(dev_null_fd, 2) |
| os.close(dev_null_fd) |
| |
| process = subprocess.Popen( |
| command, |
| executable=which("java"), |
| cwd=self._buck_project.root, |
| close_fds=True, |
| preexec_fn=preexec_func, |
| env=self._environ_for_buck()) |
| |
| buckd_port = None |
| for i in range(100): |
| if buckd_port: |
| break |
| try: |
| with open(ngserver_output_path) as f: |
| for line in f: |
| match = BUCKD_LOG_FILE_PATTERN.match(line) |
| if match: |
| buckd_port = match.group(1) |
| break |
| except IOError as e: |
| pass |
| finally: |
| time.sleep(0.1) |
| else: |
| print( |
| "nailgun server did not respond after 10s. Aborting buckd.", |
| file=sys.stderr) |
| return |
| |
| self._buck_project.save_buckd_port(buckd_port) |
| self._buck_project.save_buckd_version(buck_version_uid) |
| self._buck_project.update_buckd_run_count(0) |
| |
| def kill_autobuild(self): |
| autobuild_pid = self._buck_project.get_autobuild_pid() |
| if autobuild_pid: |
| if autobuild_pid.isdigit(): |
| try: |
| os.kill(autobuild_pid, signal.SIGTERM) |
| except OSError: |
| pass |
| |
| def kill_buckd(self): |
| with Tracing('BuckRepo.kill_buckd'): |
| buckd_port = self._buck_project.get_buckd_port() |
| |
| if buckd_port: |
| if not buckd_port.isdigit(): |
| print("WARNING: Corrupt buckd port: '{0}'.".format(buckd_port)) |
| else: |
| print("Shutting down nailgun server...", file=sys.stderr) |
| buck_client_file = self._get_resource(CLIENT) |
| command = [buck_client_file] |
| command.append('ng-stop') |
| command.append('--nailgun-port') |
| command.append(buckd_port) |
| try: |
| check_output( |
| command, |
| cwd=self._buck_project.root, |
| stderr=subprocess.STDOUT) |
| except CalledProcessError as e: |
| if (e.returncode not in |
| [NAILGUN_CONNECTION_REFUSED_CODE, |
| NAILGUN_CONNECTION_BROKEN_CODE, |
| NAILGUN_UNEXPECTED_CHUNK_TYPE]): |
| print(e.output, end='', file=sys.stderr) |
| raise |
| |
| self._buck_project.clean_up_buckd() |
| |
| def _setup_watchman_watch(self): |
| with Tracing('BuckRepo._setup_watchman_watch'): |
| if not which('watchman'): |
| message = textwrap.dedent("""\ |
| Watchman not found, please install when using buckd. |
| See https://github.com/facebook/watchman for details.""") |
| if sys.platform == "darwin": |
| message += "\n(brew install watchman on OS X)" |
| # Bail if watchman isn't installed as we know java's |
| # FileSystemWatcher will take too long to process events. |
| raise BuckToolException(message) |
| |
| print("Using watchman.", file=sys.stderr) |
| try: |
| check_output( |
| ['watchman', 'watch', self._buck_project.root], |
| stderr=subprocess.STDOUT) |
| except CalledProcessError as e: |
| print(e.output, end='', file=sys.stderr) |
| raise |
| |
| def _is_buckd_running(self): |
| with Tracing('BuckRepo._is_buckd_running'): |
| buckd_port = self._buck_project.get_buckd_port() |
| |
| if buckd_port is None or not buckd_port.isdigit(): |
| return False |
| |
| buck_client_file = self._get_resource(CLIENT) |
| command = [buck_client_file] |
| command.append('ng-stats') |
| command.append('--nailgun-port') |
| command.append(buckd_port) |
| try: |
| check_output( |
| command, |
| cwd=self._buck_project.root, |
| stderr=subprocess.STDOUT) |
| except CalledProcessError as e: |
| if e.returncode == NAILGUN_CONNECTION_REFUSED_CODE: |
| return False |
| else: |
| print(e.output, end='', file=sys.stderr) |
| raise |
| |
| return True |
| |
| def _get_buck_version_uid(self): |
| raise NotImplementedError() |
| |
| def _get_bootstrap_classpath(self): |
| raise NotImplementedError() |
| |
| def _get_java_classpath(self): |
| raise NotImplementedError() |
| |
| def _get_extra_java_args(self): |
| return [] |
| |
| def _get_java_args(self, version_uid): |
| java_args = [] if is_java8() else ["-XX:MaxPermSize=256m"] |
| java_args.extend([ |
| "-Xmx1000m", |
| "-Djava.awt.headless=true", |
| "-Djava.util.logging.config.class=com.facebook.buck.cli.bootstrapper.LogConfig", |
| "-Dbuck.test_util_no_tests_dir=true", |
| "-Dbuck.version_uid={0}".format(version_uid), |
| "-Dbuck.buckd_dir={0}".format(self._buck_project.buckd_dir), |
| "-Dlog4j.configuration=file:{0}".format( |
| self._get_resource(LOG4J_CONFIG)), |
| ]) |
| for resource in EXPORTED_RESOURCES: |
| if self._has_resource(resource): |
| java_args.append( |
| "-Dbuck.{0}={1}".format( |
| resource.name, self._get_resource(resource))) |
| |
| if os.environ.get("BUCK_DEBUG_MODE"): |
| java_args.append("-agentlib:jdwp=transport=dt_socket," |
| "server=y,suspend=y,address=8888") |
| |
| if os.environ.get("BUCK_DEBUG_SOY"): |
| java_args.append("-Dbuck.soy.debug=true") |
| |
| if self._buck_project.buck_javaargs: |
| java_args.extend(self._buck_project.buck_javaargs.split(' ')) |
| |
| java_args.extend(self._get_extra_java_args()) |
| |
| extra_java_args = os.environ.get("BUCK_EXTRA_JAVA_ARGS") |
| if extra_java_args: |
| java_args.extend(extra_java_args.split(' ')) |
| return java_args |
| |
| def _platform_path(self, path): |
| if sys.platform != 'cygwin': |
| return path |
| return subprocess.check_output(['cygpath', '-w', path]).strip() |
| |
| |
| # |
| # an almost exact copy of the shutil.which() implementation from python3.4 |
| # |
| def which(cmd, mode=os.F_OK | os.X_OK, path=None): |
| """Given a command, mode, and a PATH string, return the path which |
| conforms to the given mode on the PATH, or None if there is no such |
| file. |
| |
| `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result |
| of os.environ.get("PATH"), or can be overridden with a custom search |
| path. |
| |
| """ |
| # Check that a given file can be accessed with the correct mode. |
| # Additionally check that `file` is not a directory, as on Windows |
| # directories pass the os.access check. |
| def _access_check(fn, mode): |
| return (os.path.exists(fn) and os.access(fn, mode) |
| and not os.path.isdir(fn)) |
| |
| # If we're given a path with a directory part, look it up directly rather |
| # than referring to PATH directories. This includes checking relative to |
| # the current directory, e.g. ./script |
| if os.path.dirname(cmd): |
| if _access_check(cmd, mode): |
| return cmd |
| return None |
| |
| if path is None: |
| path = os.environ.get("PATH", os.defpath) |
| if not path: |
| return None |
| path = path.split(os.pathsep) |
| |
| if sys.platform == "win32": |
| # The current directory takes precedence on Windows. |
| if os.curdir not in path: |
| path.insert(0, os.curdir) |
| |
| # PATHEXT is necessary to check on Windows. |
| pathext = os.environ.get("PATHEXT", "").split(os.pathsep) |
| # See if the given file matches any of the expected path extensions. |
| # This will allow us to short circuit when given "python.exe". |
| # If it does match, only test that one, otherwise we have to try |
| # others. |
| if any(cmd.lower().endswith(ext.lower()) for ext in pathext): |
| files = [cmd] |
| else: |
| files = [cmd + ext for ext in pathext] |
| else: |
| # On other platforms you don't have things like PATHEXT to tell you |
| # what file suffixes are executable, so just pass on cmd as-is. |
| files = [cmd] |
| |
| seen = set() |
| for dir in path: |
| normdir = os.path.normcase(dir) |
| if normdir not in seen: |
| seen.add(normdir) |
| for thefile in files: |
| name = os.path.join(dir, thefile) |
| if _access_check(name, mode): |
| return name |
| return None |
| |
| |
| def is_java8(): |
| output = check_output(['java', '-version'], stderr=subprocess.STDOUT) |
| version_line = output.strip().splitlines()[0] |
| return re.compile('java version "1\.8\..*').match(version_line) |