blob: 48d9b27b628b42453c9a03fe14d0777c41a68171 [file]
#!/usr/bin/env python3
# Copyright 2021 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Creates an server to offload non-critical-path GN targets."""
import argparse
import dataclasses
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
from typing import Dict, List, Optional, Tuple
sys.path.append(os.path.join(os.path.dirname(__file__), 'gyp'))
from util import server_utils
class Logger:
"""Class to store global state for logging."""
num_processes: int = 0
completed_tasks: int = 0
total_tasks: int = 0
@classmethod
def _plural(cls, word: str, num: int, suffix: str = 's'):
if num == 1:
return word
return word + suffix
@classmethod
def _prefix(cls):
# Ninja's prefix is: [205 processes, 6/734 @ 6.5/s : 0.922s ]
# Time taken and task completion rate are not important for the build server
# since it is always running in the background and uses idle priority for
# its tasks.
processes_str = cls._plural('process', cls.num_processes, suffix='es')
return (f'{cls.num_processes} {processes_str}, '
f'{cls.completed_tasks}/{cls.total_tasks}')
@classmethod
def log(cls, msg: str, *, end: str = ''):
# Shrink the message (leaving a 2-char prefix and use the rest of the room
# for the suffix) according to terminal size so it is always one line.
width = shutil.get_terminal_size().columns
prefix = f'[{cls._prefix()}] '
max_msg_width = width - len(prefix)
if len(msg) > max_msg_width:
length_to_show = max_msg_width - 5 # Account for ellipsis and header.
msg = f'{msg[:2]}...{msg[-length_to_show:]}'
# \r to return the carriage to the beginning of line.
# \033[K to replace the normal \n to erase until the end of the line.
# Avoid the default line ending so the next \r overwrites the same line just
# like ninja's output.
print(f'\r{prefix}{msg}\033[K', end=end, flush=True)
@dataclasses.dataclass
class Task:
"""Class to represent a single build task."""
name: str
cwd: str
cmd: List[str]
stamp_file: str
_proc: Optional[subprocess.Popen] = None
_thread: Optional[threading.Thread] = None
_terminated: bool = False
_return_code: Optional[int] = None
@property
def key(self):
return (self.cwd, self.name)
def start(self):
assert self._proc is None
Logger.num_processes += 1
Logger.log(f'STARTING {self.name}')
# The environment variable forces the script to actually run in order to
# avoid infinite recursion.
env = os.environ.copy()
env[server_utils.BUILD_SERVER_ENV_VARIABLE] = '1'
# Use os.nice(19) to ensure the lowest priority (idle) for these analysis
# tasks since we want to avoid slowing down the actual build.
# TODO(wnwen): Also use ionice to reduce resource consumption. Possibly use
# cgroups to make these processes use even fewer resources than
# idle priority.
self._proc = subprocess.Popen(
self.cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=self.cwd,
env=env,
text=True,
preexec_fn=lambda: os.nice(19),
)
# Avoid daemon=True to allow threads to finish running cleanup on Ctrl-C.
self._thread = threading.Thread(target=self._complete_when_process_finishes)
self._thread.start()
def terminate(self):
if self._terminated:
return
self._terminated = True
if self._proc:
self._proc.terminate()
self._proc.wait()
if self._thread:
self._thread.join()
def _complete_when_process_finishes(self):
assert self._proc
# We know Popen.communicate will return a str and not a byte since it is
# constructed with text=True.
stdout: str = self._proc.communicate()[0]
self._return_code = self._proc.returncode
self._proc = None
self._complete(stdout)
def _complete(self, stdout: str):
assert self._proc is None
Logger.completed_tasks += 1
Logger.num_processes -= 1
failed = False
if self._terminated:
Logger.log(f'TERMINATED {self.name}')
# Ignore stdout as it is now outdated.
failed = True
else:
Logger.log(f'FINISHED {self.name}')
if stdout or self._return_code != 0:
failed = True
# An extra new line is needed since _log does not end with a new line.
print(f'\nFAILED: {self.name} Return code: {self._return_code}')
print(' '.join(self.cmd))
print(stdout)
if failed:
# Force ninja to consider failed targets as dirty.
try:
os.unlink(os.path.join(self.cwd, self.stamp_file))
except FileNotFoundError:
pass
else:
# Ninja will rebuild targets when their inputs change even if their stamp
# file has a later modified time. Thus we do not need to worry about the
# script being run by the build server updating the mtime incorrectly.
pass
def _listen_for_request_data(sock: socket.socket):
while True:
conn = sock.accept()[0]
received = []
with conn:
while True:
data = conn.recv(4096)
if not data:
break
received.append(data)
if received:
yield json.loads(b''.join(received))
def _process_requests(sock: socket.socket):
# Since dicts in python can contain anything, explicitly type tasks to help
# make static type checking more useful.
tasks: Dict[Tuple[str, str], Task] = {}
try:
for data in _listen_for_request_data(sock):
task = Task(name=data['name'],
cwd=data['cwd'],
cmd=data['cmd'],
stamp_file=data['stamp_file'])
Logger.total_tasks += 1
existing_task = tasks.get(task.key)
if existing_task:
existing_task.terminate()
tasks[task.key] = task
# TODO(wnwen): Rather than start it right away, add this task to a running
# queue and run either a limited number of processes (10) or
# even just 1 until the server load is very low (or ninja has
# finished).
task.start()
except KeyboardInterrupt:
Logger.log('STOPPING SERVER...', end='\n')
# Gracefully exit by terminating all running tasks and allowing their io
# watcher threads to finish and run cleanup on their own.
for task in tasks.values():
task.terminate()
Logger.log('STOPPED', end='\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
with socket.socket(socket.AF_UNIX) as sock:
sock.bind(server_utils.SOCKET_ADDRESS)
sock.listen()
_process_requests(sock)
if __name__ == '__main__':
sys.exit(main())