|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Copyright 2024 Google Inc. All Rights Reserved. |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | + |
| 16 | +from textwrap import dedent |
| 17 | +import os |
| 18 | +import platform |
| 19 | +import subprocess |
| 20 | +import tempfile |
| 21 | +import typing as T |
| 22 | +import sys |
| 23 | +import unittest |
| 24 | + |
| 25 | +_SCRIPT_DIR = os.path.realpath(os.path.dirname(__file__)) |
| 26 | +_JOBSERVER_POOL_SCRIPT = os.path.join(_SCRIPT_DIR, "jobserver_pool.py") |
| 27 | +_JOBSERVER_TEST_HELPER_SCRIPT = os.path.join(_SCRIPT_DIR, "jobserver_test_helper.py") |
| 28 | + |
| 29 | +_PLATFORM_IS_WINDOWS = platform.system() == "Windows" |
| 30 | + |
| 31 | +default_env = dict(os.environ) |
| 32 | +default_env.pop("NINJA_STATUS", None) |
| 33 | +default_env.pop("MAKEFLAGS", None) |
| 34 | +default_env["TERM"] = "dumb" |
| 35 | +NINJA_PATH = os.path.abspath("./ninja") |
| 36 | + |
| 37 | + |
| 38 | +class BuildDir: |
| 39 | + def __init__(self, build_ninja: str): |
| 40 | + self.build_ninja = dedent(build_ninja) |
| 41 | + self.d: T.Optional[tempfile.TemporaryDirectory] = None |
| 42 | + |
| 43 | + def __enter__(self): |
| 44 | + self.d = tempfile.TemporaryDirectory() |
| 45 | + with open(os.path.join(self.d.name, "build.ninja"), "w") as f: |
| 46 | + f.write(self.build_ninja) |
| 47 | + return self |
| 48 | + |
| 49 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 50 | + self.d.cleanup() |
| 51 | + |
| 52 | + @property |
| 53 | + def path(self) -> str: |
| 54 | + assert self.d |
| 55 | + return self.d.name |
| 56 | + |
| 57 | + def run( |
| 58 | + self, |
| 59 | + cmd_flags: T.Sequence[str] = [], |
| 60 | + env: T.Dict[str, str] = default_env, |
| 61 | + ) -> None: |
| 62 | + """Run a command, raise exception on error. Do not capture outputs.""" |
| 63 | + ret = subprocess.run(cmd_flags, env=env) |
| 64 | + ret.check_returncode() |
| 65 | + |
| 66 | + def ninja_run( |
| 67 | + self, |
| 68 | + ninja_args: T.List[str], |
| 69 | + prefix_args: T.List[str] = [], |
| 70 | + extra_env: T.Dict[str, str] = {}, |
| 71 | + ) -> "subprocess.CompletedProcess[str]": |
| 72 | + ret = self.ninja_spawn( |
| 73 | + ninja_args, |
| 74 | + prefix_args=prefix_args, |
| 75 | + extra_env=extra_env, |
| 76 | + capture_output=False, |
| 77 | + ) |
| 78 | + ret.check_returncode() |
| 79 | + return ret |
| 80 | + |
| 81 | + def ninja_clean(self) -> None: |
| 82 | + self.ninja_run(["-t", "clean"]) |
| 83 | + |
| 84 | + def ninja_spawn( |
| 85 | + self, |
| 86 | + ninja_args: T.List[str], |
| 87 | + prefix_args: T.List[str] = [], |
| 88 | + extra_env: T.Dict[str, str] = {}, |
| 89 | + capture_output: bool = True, |
| 90 | + ) -> "subprocess.CompletedProcess[str]": |
| 91 | + """Run Ninja command and capture outputs.""" |
| 92 | + return subprocess.run( |
| 93 | + prefix_args + [NINJA_PATH, "-C", self.path] + ninja_args, |
| 94 | + text=True, |
| 95 | + capture_output=capture_output, |
| 96 | + env={**default_env, **extra_env}, |
| 97 | + ) |
| 98 | + |
| 99 | + |
| 100 | +def span_output_file(span_n: int) -> str: |
| 101 | + return "out%02d" % span_n |
| 102 | + |
| 103 | + |
| 104 | +def generate_build_plan(command_count: int) -> str: |
| 105 | + """Generate a Ninja build plan for |command_count| parallel tasks. |
| 106 | +
|
| 107 | + Each task calls the test helper script which waits for 50ms |
| 108 | + then writes its own start and end time to its output file. |
| 109 | + """ |
| 110 | + result = f""" |
| 111 | +rule span |
| 112 | + command = {sys.executable} -S {_JOBSERVER_TEST_HELPER_SCRIPT} $out |
| 113 | +
|
| 114 | +""" |
| 115 | + |
| 116 | + for n in range(command_count): |
| 117 | + result += "build %s: span\n" % span_output_file(n) |
| 118 | + |
| 119 | + result += "build all: phony %s\n" % " ".join( |
| 120 | + [span_output_file(n) for n in range(command_count)] |
| 121 | + ) |
| 122 | + return result |
| 123 | + |
| 124 | + |
| 125 | +def compute_max_overlapped_spans(build_dir: str, command_count: int) -> int: |
| 126 | + """Compute the maximum number of overlapped spanned tasks. |
| 127 | +
|
| 128 | + This reads the output files from |build_dir| and look at their start and end times |
| 129 | + to compute the maximum number of tasks that were run in parallel. |
| 130 | + """ |
| 131 | + # Read the output files. |
| 132 | + if command_count < 2: |
| 133 | + return 0 |
| 134 | + |
| 135 | + spans: T.List[T.Tuple[int, int]] = [] |
| 136 | + for n in range(command_count): |
| 137 | + with open(os.path.join(build_dir, span_output_file(n)), "rb") as f: |
| 138 | + content = f.read().decode("utf-8") |
| 139 | + lines = content.splitlines() |
| 140 | + assert len(lines) == 2, f"Unexpected output file content: [{content}]" |
| 141 | + spans.append((int(lines[0]), int(lines[1]))) |
| 142 | + |
| 143 | + # Stupid but simple, for each span, count the number of other spans that overlap it. |
| 144 | + max_overlaps = 1 |
| 145 | + for n in range(command_count): |
| 146 | + cur_start, cur_end = spans[n] |
| 147 | + cur_overlaps = 1 |
| 148 | + for m in range(command_count): |
| 149 | + other_start, other_end = spans[m] |
| 150 | + if n != m and other_end > cur_start and other_start < cur_end: |
| 151 | + cur_overlaps += 1 |
| 152 | + |
| 153 | + if cur_overlaps > max_overlaps: |
| 154 | + max_overlaps = cur_overlaps |
| 155 | + |
| 156 | + return max_overlaps |
| 157 | + |
| 158 | + |
| 159 | +class JobserverTest(unittest.TestCase): |
| 160 | + |
| 161 | + def test_no_jobserver_client(self): |
| 162 | + task_count = 10 |
| 163 | + build_plan = generate_build_plan(task_count) |
| 164 | + with BuildDir(build_plan) as b: |
| 165 | + output = b.run([NINJA_PATH, "-C", b.path, "-j0", "all"]) |
| 166 | + |
| 167 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 168 | + self.assertEqual(max_overlaps, task_count) |
| 169 | + |
| 170 | + b.ninja_clean() |
| 171 | + output = b.run([NINJA_PATH, "-C", b.path, "-j1", "all"]) |
| 172 | + |
| 173 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 174 | + self.assertEqual(max_overlaps, 1) |
| 175 | + |
| 176 | + def _run_client_test(self, jobserver_args: T.List[str]) -> None: |
| 177 | + task_count = 10 |
| 178 | + build_plan = generate_build_plan(task_count) |
| 179 | + with BuildDir(build_plan) as b: |
| 180 | + # First, run the full 10 tasks with with 10 tokens, this should allow all |
| 181 | + # tasks to run in parallel. |
| 182 | + ret = b.ninja_run( |
| 183 | + ninja_args=["all"], |
| 184 | + prefix_args=jobserver_args + [f"--jobs={task_count}"], |
| 185 | + ) |
| 186 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 187 | + self.assertEqual(max_overlaps, task_count) |
| 188 | + |
| 189 | + # Second, use 4 tokens only, and verify that this was enforced by Ninja. |
| 190 | + b.ninja_clean() |
| 191 | + b.ninja_run( |
| 192 | + ["all"], |
| 193 | + prefix_args=jobserver_args + ["--jobs=4"], |
| 194 | + ) |
| 195 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 196 | + self.assertEqual(max_overlaps, 4) |
| 197 | + |
| 198 | + # Third, verify that --jobs=1 serializes all tasks. |
| 199 | + b.ninja_clean() |
| 200 | + b.ninja_run( |
| 201 | + ["all"], |
| 202 | + prefix_args=jobserver_args + ["--jobs=1"], |
| 203 | + ) |
| 204 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 205 | + self.assertEqual(max_overlaps, 1) |
| 206 | + |
| 207 | + # Finally, verify that -j1 overrides the pool. |
| 208 | + b.ninja_clean() |
| 209 | + b.ninja_run( |
| 210 | + ["-j1", "all"], |
| 211 | + prefix_args=jobserver_args + [f"--jobs={task_count}"], |
| 212 | + ) |
| 213 | + max_overlaps = compute_max_overlapped_spans(b.path, task_count) |
| 214 | + self.assertEqual(max_overlaps, 1) |
| 215 | + |
| 216 | + @unittest.skipIf(_PLATFORM_IS_WINDOWS, "These test methods do not work on Windows") |
| 217 | + def test_jobserver_client_with_posix_pipe(self): |
| 218 | + self._run_client_test([sys.executable, "-S", _JOBSERVER_POOL_SCRIPT, "--pipe"]) |
| 219 | + |
| 220 | + @unittest.skipIf(_PLATFORM_IS_WINDOWS, "These test methods do not work on Windows") |
| 221 | + def test_jobserver_client_with_posix_fifo(self): |
| 222 | + self._run_client_test([sys.executable, "-S", _JOBSERVER_POOL_SCRIPT]) |
| 223 | + |
| 224 | + def _test_MAKEFLAGS_value( |
| 225 | + self, ninja_args: T.List[str] = [], prefix_args: T.List[str] = [] |
| 226 | + ): |
| 227 | + build_plan = r""" |
| 228 | +rule print |
| 229 | + command = echo MAKEFLAGS="[$$MAKEFLAGS]" |
| 230 | +
|
| 231 | +build all: print |
| 232 | +""" |
| 233 | + with BuildDir(build_plan) as b: |
| 234 | + ret = b.ninja_spawn( |
| 235 | + ninja_args + ["--quiet", "all"], prefix_args=prefix_args |
| 236 | + ) |
| 237 | + self.assertEqual(ret.returncode, 0) |
| 238 | + output = ret.stdout.strip() |
| 239 | + pos = output.find("MAKEFLAGS=[") |
| 240 | + self.assertNotEqual(pos, -1, "Could not find MAKEFLAGS in output!") |
| 241 | + makeflags, sep, _ = output[pos + len("MAKEFLAGS=[") :].partition("]") |
| 242 | + self.assertEqual(sep, "]", "Missing ] in output!: " + output) |
| 243 | + self.assertTrue( |
| 244 | + "--jobserver-auth=" in makeflags, |
| 245 | + f"Missing --jobserver-auth from MAKEFLAGS [{makeflags}]\nSTDOUT [{ret.stdout}]\nSTDERR [{ret.stderr}]", |
| 246 | + ) |
| 247 | + |
| 248 | + def test_client_passes_MAKEFLAGS(self): |
| 249 | + self._test_MAKEFLAGS_value( |
| 250 | + prefix_args=[sys.executable, "-S", _JOBSERVER_POOL_SCRIPT] |
| 251 | + ) |
| 252 | + |
| 253 | + |
| 254 | +if __name__ == "__main__": |
| 255 | + unittest.main() |
0 commit comments