• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 19068377358

04 Nov 2025 12:18PM UTC coverage: 92.46% (+12.2%) from 80.3%
19068377358

Pull #22816

github

web-flow
Merge a242f1805 into 89462b7ef
Pull Request #22816: Update Pants internal Python to 3.14

13 of 14 new or added lines in 12 files covered. (92.86%)

244 existing lines in 13 files now uncovered.

89544 of 96846 relevant lines covered (92.46%)

3.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.15
/src/python/pants/pantsd/pantsd_integration_test.py
1
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import glob
1✔
7
import os
1✔
8
import shutil
1✔
9
import signal
1✔
10
import sys
1✔
11
import threading
1✔
12
import time
1✔
13
import unittest
1✔
14
from pathlib import Path
1✔
15
from textwrap import dedent
1✔
16

17
import psutil
1✔
18
import pytest
1✔
19

20
from pants.pantsd.pantsd_integration_test_base import PantsDaemonIntegrationTestBase, launch_waiter
1✔
21
from pants.testutil.pants_integration_test import read_pants_log, setup_tmpdir, temporary_workdir
1✔
22
from pants.util.contextutil import environment_as, temporary_dir, temporary_file
1✔
23
from pants.util.dirutil import rm_rf, safe_file_dump, safe_mkdir, safe_open, safe_rmtree, touch
1✔
24

25

26
def launch_file_toucher(f):
1✔
27
    """Launch a loop to touch the given file, and return a function to call to stop and join it."""
28
    if not os.path.isfile(f):
×
29
        raise AssertionError("Refusing to touch a non-file.")
×
30

31
    halt = threading.Event()
×
32

33
    def file_toucher():
×
34
        while not halt.isSet():
×
35
            touch(f)
×
36
            time.sleep(1)
×
37

38
    thread = threading.Thread(target=file_toucher)
×
39
    thread.daemon = True
×
40
    thread.start()
×
41

42
    def join():
×
43
        halt.set()
×
44
        thread.join(timeout=10)
×
45

46
    return join
×
47

48

49
compilation_failure_dir_layout = {
1✔
50
    os.path.join("compilation_failure", "main.py"): "if __name__ == '__main__':\n    import sysยก",
51
    os.path.join(
52
        "compilation_failure", "BUILD"
53
    ): "python_sources()\npex_binary(name='bin', entry_point='main.py')",
54
}
55

56

57
class TestPantsDaemonIntegration(PantsDaemonIntegrationTestBase):
1✔
58
    hermetic = False
1✔
59

60
    def test_pantsd_run(self):
1✔
61
        with self.pantsd_successful_run_context(log_level="debug") as ctx:
1✔
62
            with setup_tmpdir({"foo/BUILD": "target()"}) as tmpdir:
1✔
63
                ctx.runner(["list", f"{tmpdir}/foo::"])
1✔
64
                ctx.checker.assert_started()
1✔
65

66
                ctx.runner(["list", f"{tmpdir}/foo::"])
1✔
67
                ctx.checker.assert_running()
1✔
68

69
    def test_pantsd_broken_pipe(self):
1✔
70
        with self.pantsd_test_context() as (workdir, pantsd_config, checker):
1✔
71
            run = self.run_pants_with_workdir(
1✔
72
                "help | head -1",
73
                workdir=workdir,
74
                config=pantsd_config,
75
                shell=True,
76
                # FIXME: Why is this necessary to set?
77
                set_pants_ignore=False,
78
            )
79
            self.assertNotIn("broken pipe", run.stderr.lower())
1✔
80
            checker.assert_started()
1✔
81

82
    def test_pantsd_pantsd_runner_doesnt_die_after_failed_run(self):
1✔
83
        with self.pantsd_test_context() as (workdir, pantsd_config, checker):
1✔
84
            # Run target that throws an exception in pants.
85
            with setup_tmpdir(compilation_failure_dir_layout) as tmpdir:
1✔
86
                self.run_pants_with_workdir(
1✔
87
                    ["lint", os.path.join(tmpdir, "compilation_failure", "main.py")],
88
                    workdir=workdir,
89
                    config=pantsd_config,
90
                ).assert_failure()
91
            checker.assert_started()
1✔
92

93
            # Assert pantsd is in a good functional state.
94
            self.run_pants_with_workdir(
1✔
95
                ["help"], workdir=workdir, config=pantsd_config
96
            ).assert_success()
97
            checker.assert_running()
1✔
98

99
    def test_pantsd_lifecycle_invalidation(self):
1✔
100
        """Run with different values of daemon=True options, which should trigger restarts."""
101
        with self.pantsd_successful_run_context() as ctx:
1✔
102
            last_pid = None
1✔
103
            for idx in range(3):
1✔
104
                # Run with a different value of a daemon=True option in each iteration.
105
                ctx.runner([f"--pantsd-invalidation-globs=ridiculous{idx}", "help"])
1✔
106
                next_pid = ctx.checker.assert_started()
1✔
107
                if last_pid is not None:
1✔
108
                    assert last_pid != next_pid
1✔
109
                last_pid = next_pid
1✔
110

111
    def test_pantsd_lifecycle_invalidation_from_auth_plugin(self) -> None:
1✔
112
        """If the dynamic remote options changed, we should reinitialize the scheduler but not
113
        restart the daemon."""
114
        plugin = dedent(
1✔
115
            """\
116
            from datetime import datetime
117
            from pants.option.bootstrap_options import AuthPluginState, AuthPluginResult
118

119
            def remote_auth(
120
                initial_execution_headers, initial_store_headers, options, env, prior_result
121
            ):
122
                # If the first run, don't change the headers, but use the `expiration` as a
123
                # sentinel so that future runs know to change it.
124
                if prior_result is None:
125
                    return AuthPluginResult(
126
                        state=AuthPluginState.OK,
127
                        execution_headers=initial_execution_headers,
128
                        store_headers=initial_store_headers,
129
                        expiration=datetime.min,
130
                    )
131

132
                # If second run, still don't change the headers, but update the `expiration` as a
133
                # sentinel for the next run.
134
                if prior_result.expiration == datetime.min:
135
                    return AuthPluginResult(
136
                        state=AuthPluginState.OK,
137
                        execution_headers=initial_execution_headers,
138
                        store_headers=initial_store_headers,
139
                        expiration=datetime.max,
140
                    )
141

142
                # Finally, on the third run, change the headers.
143
                if prior_result.expiration == datetime.max:
144
                    return AuthPluginResult(
145
                        state=AuthPluginState.OK,
146
                        execution_headers={"custom": "foo"},
147
                        store_headers=initial_store_headers,
148
                    )
149

150
                # If there was a fourth run, or `prior_result` didn't preserve the `expiration`
151
                # field properly, error.
152
                raise AssertionError(f"Unexpected prior_result: {prior_result}")
153
            """
154
        )
155
        with self.pantsd_successful_run_context() as ctx:
1✔
156

157
            def run_auth_plugin() -> tuple[str, int]:
1✔
158
                # This very hackily traverses up to the process's parent directory, rather than the
159
                # workdir.
160
                plugin_dir = Path(ctx.workdir).parent.parent / "auth_plugin"
1✔
161
                plugin_dir.mkdir(parents=True, exist_ok=True)
1✔
162
                (plugin_dir / "__init__.py").touch()
1✔
163
                (plugin_dir / "register.py").write_text(plugin)
1✔
164
                sys.path.append(str(plugin_dir))
1✔
165
                try:
1✔
166
                    result = ctx.runner(
1✔
167
                        [
168
                            "--pythonpath=auth_plugin",
169
                            "--backend-packages=auth_plugin",
170
                            "--remote-cache-read",
171
                            "--remote-store-address=grpc://fake",
172
                            "help",
173
                        ]
174
                    )
175
                finally:
176
                    sys.path.pop()
1✔
177
                    shutil.rmtree(plugin_dir)
1✔
178
                return result.stderr, ctx.checker.assert_started()
1✔
179

180
            first_stderr, first_pid = run_auth_plugin()
1✔
181
            assert (
1✔
182
                "Initializing scheduler" in first_stderr
183
                or "Reinitializing scheduler" in first_stderr
184
            )
185

186
            second_stderr, second_pid = run_auth_plugin()
1✔
187
            assert "Reinitializing scheduler" not in second_stderr
1✔
188
            assert first_pid == second_pid
1✔
189

190
            third_stderr, third_pid = run_auth_plugin()
1✔
191
            assert "Remote cache/execution options updated" in third_stderr
1✔
192
            assert "execution_headers: {}" in third_stderr and "'custom': 'foo'" in third_stderr
1✔
193
            assert "Reinitializing scheduler" in third_stderr
1✔
194
            assert second_pid == third_pid
1✔
195

196
    def test_pantsd_lifecycle_invalidation_bootstrap_options(self) -> None:
1✔
197
        """Test that changing bootstrap options triggers scheduler reinitialization with diff
198
        logs."""
199
        with self.pantsd_successful_run_context() as ctx:
1✔
200
            ctx.runner(["help"])
1✔
201
            first_pid = ctx.checker.assert_started()
1✔
202

203
            second_result = ctx.runner(["help"])
1✔
204
            second_pid = ctx.checker.assert_running()
1✔
205
            assert first_pid == second_pid
1✔
206
            assert "Reinitializing scheduler" not in second_result.stderr
1✔
207

208
            third_result = ctx.runner(["--pantsd-max-memory-usage=1000000", "help"])
1✔
209
            third_pid = ctx.checker.assert_running()
1✔
210
            assert second_pid == third_pid
1✔
211
            assert "Initialization options changed" in third_result.stderr
1✔
212
            assert "pantsd_max_memory_usage" in third_result.stderr
1✔
213
            assert "Reinitializing scheduler" in third_result.stderr
1✔
214

215
    def test_pantsd_lifecycle_non_invalidation(self):
1✔
216
        with self.pantsd_successful_run_context() as ctx:
1✔
217
            cmds = (["help"], ["--no-colors", "help"], ["help"])
1✔
218
            last_pid = None
1✔
219
            for cmd in cmds:
1✔
220
                # Run with a CLI flag.
221
                ctx.runner(cmd)
1✔
222
                next_pid = ctx.checker.assert_started()
1✔
223
                if last_pid is not None:
1✔
224
                    self.assertEqual(last_pid, next_pid)
1✔
225
                last_pid = next_pid
1✔
226

227
    def test_pantsd_lifecycle_non_invalidation_on_config_string(self):
1✔
228
        with temporary_dir() as dist_dir_root, temporary_dir() as config_dir:
1✔
229
            # Create a variety of config files that change an option that does _not_ affect the
230
            # daemon's fingerprint (only the Scheduler's), and confirm that it stays up.
231
            config_files = [
1✔
232
                os.path.abspath(os.path.join(config_dir, f"pants.{i}.toml")) for i in range(3)
233
            ]
234
            for idx, config_file in enumerate(config_files):
1✔
235
                print(f"writing {config_file}")
1✔
236
                with open(config_file, "w") as fh:
1✔
237
                    fh.write(
1✔
238
                        f"""[GLOBAL]\npants_distdir = "{os.path.join(dist_dir_root, str(idx))}"\n"""
239
                    )
240

241
            with self.pantsd_successful_run_context() as ctx:
1✔
242
                cmds = [[f"--pants-config-files={f}", "help"] for f in config_files]
1✔
243
                last_pid = None
1✔
244
                for cmd in cmds:
1✔
245
                    ctx.runner(cmd)
1✔
246
                    next_pid = ctx.checker.assert_started()
1✔
247
                    if last_pid is not None:
1✔
248
                        self.assertEqual(last_pid, next_pid)
1✔
249
                    last_pid = next_pid
1✔
250

251
    def test_pantsd_lifecycle_shutdown_for_broken_scheduler(self):
1✔
252
        with self.pantsd_test_context() as (workdir, config, checker):
1✔
253
            # Run with valid options.
254
            self.run_pants_with_workdir(["help"], workdir=workdir, config=config).assert_success()
1✔
255
            checker.assert_started()
1✔
256

257
            # And again with invalid scheduler-fingerprinted options that trigger a re-init.
258
            self.run_pants_with_workdir(
1✔
259
                ["--backend-packages=nonsensical", "help"], workdir=workdir, config=config
260
            ).assert_failure()
261
            checker.assert_stopped()
1✔
262

263
    def test_pantsd_aligned_output(self) -> None:
1✔
264
        # Set for pytest output display.
265
        self.maxDiff = None
1✔
266

267
        cmds = [["help", "goals"], ["help", "targets"], ["roots"]]
1✔
268

269
        config = {
1✔
270
            "GLOBAL": {
271
                # These must match the ones we configure in pantsd_integration_test_base.py.
272
                "backend_packages": ["pants.backend.python", "pants.backend.python.lint.flake8"],
273
            }
274
        }
275
        non_daemon_runs = [self.run_pants(cmd, config=config) for cmd in cmds]
1✔
276

277
        with self.pantsd_successful_run_context() as ctx:
1✔
278
            daemon_runs = [ctx.runner(cmd) for cmd in cmds]
1✔
279
            ctx.checker.assert_started()
1✔
280

281
        for cmd, run in zip(cmds, daemon_runs):
1✔
282
            print(f"(cmd, run) = ({cmd}, {run.stdout}, {run.stderr})")
1✔
283
            self.assertNotEqual(run.stdout, "", f"Empty stdout for {cmd}")
1✔
284

285
        for run_pair in zip(non_daemon_runs, daemon_runs):
1✔
286
            non_daemon_stdout = run_pair[0].stdout
1✔
287
            daemon_stdout = run_pair[1].stdout
1✔
288

289
            for line_pair in zip(non_daemon_stdout.splitlines(), daemon_stdout.splitlines()):
1✔
290
                assert line_pair[0] == line_pair[1]
1✔
291

292
    @unittest.skip("flaky: https://github.com/pantsbuild/pants/issues/7622")
1✔
293
    @pytest.mark.no_error_if_skipped
1✔
294
    def test_pantsd_filesystem_invalidation(self):
1✔
295
        """Runs with pantsd enabled, in a loop, while another thread invalidates files."""
296
        with self.pantsd_successful_run_context() as ctx:
×
297
            cmd = ["list", "::"]
×
298
            ctx.runner(cmd)
×
299
            ctx.checker.assert_started()
×
300

301
            # Launch a separate thread to poke files in 3rdparty.
302
            join = launch_file_toucher("3rdparty/jvm/com/google/auto/value/BUILD")
×
303

304
            # Repeatedly re-list 3rdparty while the file is being invalidated.
305
            for _ in range(0, 16):
×
306
                ctx.runner(cmd)
×
307
                ctx.checker.assert_running()
×
308

309
            join()
×
310

311
    def test_pantsd_client_env_var_is_inherited_by_pantsd_runner_children(self):
1✔
312
        expected_key = "TEST_ENV_VAR_FOR_PANTSD_INTEGRATION_TEST"
1✔
313
        expected_value = "333"
1✔
314
        with self.pantsd_successful_run_context() as ctx:
1✔
315
            # First, launch the daemon without any local env vars set.
316
            ctx.runner(["help"])
1✔
317
            ctx.checker.assert_started()
1✔
318

319
            # Then, set an env var on the secondary call.
320
            # We additionally set the `HERMETIC_ENV` env var to allow the integration test harness
321
            # to pass this variable through.
322
            env = {
1✔
323
                expected_key: expected_value,
324
                "HERMETIC_ENV": expected_key,
325
            }
326
            with environment_as(**env):
1✔
327
                result = ctx.runner(
1✔
328
                    ["run", "testprojects/src/python/print_env:binary", "--", expected_key]
329
                )
330
                ctx.checker.assert_running()
1✔
331

332
            self.assertEqual(expected_value, "".join(result.stdout).strip())
1✔
333

334
    def test_pantsd_launch_env_var_is_not_inherited_by_pantsd_runner_children(self):
1✔
335
        with self.pantsd_test_context() as (workdir, pantsd_config, checker):
1✔
336
            with environment_as(NO_LEAKS="33"):
1✔
337
                self.run_pants_with_workdir(
1✔
338
                    ["help"], workdir=workdir, config=pantsd_config
339
                ).assert_success()
340
                checker.assert_started()
1✔
341

342
            self.run_pants_with_workdir(
1✔
343
                ["run", "testprojects/src/python/print_env:binary", "--", "NO_LEAKS"],
344
                workdir=workdir,
345
                config=pantsd_config,
346
            ).assert_failure()
347
            checker.assert_running()
1✔
348

349
    def test_pantsd_touching_a_file_does_not_restart_daemon(self):
1✔
350
        test_file = "testprojects/src/python/print_env/main.py"
1✔
351
        config = {
1✔
352
            "GLOBAL": {"pantsd_invalidation_globs": '["testprojects/src/python/print_env/*"]'}
353
        }
354
        with self.pantsd_successful_run_context(extra_config=config) as ctx:
1✔
355
            ctx.runner(["help"])
1✔
356
            ctx.checker.assert_started()
1✔
357

358
            # Let any fs events quiesce.
359
            time.sleep(5)
1✔
360

361
            ctx.checker.assert_running()
1✔
362

363
            touch(test_file)
1✔
364
            # Permit ample time for the async file event propagate in CI.
365
            time.sleep(10)
1✔
366
            ctx.checker.assert_running()
1✔
367

368
    @unittest.skip("flaky: https://github.com/pantsbuild/pants/issues/18664")
1✔
369
    @pytest.mark.no_error_if_skipped
1✔
370
    def test_pantsd_invalidation_file_tracking(self):
1✔
371
        test_dir = "testprojects/src/python/print_env"
×
372
        config = {"GLOBAL": {"pantsd_invalidation_globs": f'["{test_dir}/*"]'}}
×
373
        with self.pantsd_successful_run_context(extra_config=config) as ctx:
×
374
            ctx.runner(["help"])
×
375
            ctx.checker.assert_started()
×
376

377
            # See comment in `test_pantsd_invalidation_pants_toml_file`.
378
            time.sleep(15)
×
379
            ctx.checker.assert_running()
×
380

381
            def full_pants_log():
×
382
                return "\n".join(read_pants_log(ctx.workdir))
×
383

384
            # Create a new file in test_dir
385
            with temporary_file(suffix=".py", binary_mode=False, root_dir=test_dir) as temp_f:
×
386
                temp_f.write("import that\n")
×
387
                temp_f.close()
×
388

389
                ctx.checker.assert_stopped()
×
390

391
            self.assertIn("saw filesystem changes covered by invalidation globs", full_pants_log())
×
392

393
    @unittest.skip("flaky: https://github.com/pantsbuild/pants/issues/18664")
1✔
394
    @pytest.mark.no_error_if_skipped
1✔
395
    def test_pantsd_invalidation_pants_toml_file(self):
1✔
396
        # Test tmp_pants_toml (--pants-config-files=$tmp_pants_toml)'s removal
397
        tmp_pants_toml = os.path.abspath("testprojects/test_pants.toml")
×
398

399
        # Create tmp_pants_toml file
400
        with safe_open(tmp_pants_toml, "w") as f:
×
401
            f.write("[DEFAULT]\n")
×
402

403
        with self.pantsd_successful_run_context() as ctx:
×
404
            ctx.runner([f"--pants-config-files={tmp_pants_toml}", "help"])
×
405
            ctx.checker.assert_started()
×
406
            # This accounts for the amount of time it takes for the SchedulerService to begin watching
407
            # these files. That happens asynchronously after `pantsd` startup, and may take a long
408
            # time in a heavily loaded test environment.
409
            time.sleep(15)
×
410

411
            # Delete tmp_pants_toml
412
            os.unlink(tmp_pants_toml)
×
413
            ctx.checker.assert_stopped()
×
414

415
    def test_pantsd_pid_deleted(self):
1✔
416
        with self.pantsd_successful_run_context() as ctx:
1✔
417
            ctx.runner(["help"])
1✔
418
            ctx.checker.assert_started()
1✔
419

420
            # Let any fs events quiesce.
421
            time.sleep(10)
1✔
422

423
            ctx.checker.assert_running()
1✔
424
            subprocess_dir = ctx.pantsd_config["GLOBAL"]["pants_subprocessdir"]
1✔
425
            safe_rmtree(subprocess_dir)
1✔
426

427
            ctx.checker.assert_stopped()
1✔
428

429
    def test_pantsd_pid_change(self):
1✔
430
        with self.pantsd_successful_run_context() as ctx:
1✔
431
            ctx.runner(["help"])
1✔
432
            ctx.checker.assert_started()
1✔
433

434
            # Let any fs events quiesce.
435
            time.sleep(10)
1✔
436

437
            ctx.checker.assert_running()
1✔
438
            subprocess_dir = ctx.pantsd_config["GLOBAL"]["pants_subprocessdir"]
1✔
439
            (pidpath,) = glob.glob(os.path.join(subprocess_dir, "*", "pantsd", "pid"))
1✔
440
            with open(pidpath, "w") as f:
1✔
441
                f.write("9")
1✔
442

443
            ctx.checker.assert_stopped()
1✔
444

445
            # Remove the pidfile so that the teardown script doesn't try to kill process 9.
446
            os.unlink(pidpath)
1✔
447

448
    @pytest.mark.skip(reason="flaky: https://github.com/pantsbuild/pants/issues/8193")
1✔
449
    @pytest.mark.no_error_if_skipped
1✔
450
    def test_pantsd_memory_usage(self):
1✔
451
        """Validates that after N runs, memory usage has increased by no more than X percent."""
452
        number_of_runs = 10
×
453
        max_memory_increase_fraction = 0.40  # TODO https://github.com/pantsbuild/pants/issues/7647
×
454
        with self.pantsd_successful_run_context() as ctx:
×
455
            # NB: This doesn't actually run against all testprojects, only those that are in the chroot,
456
            # i.e. explicitly declared in this test file's BUILD.
457
            cmd = ["list", "testprojects::"]
×
458
            ctx.runner(cmd).assert_success()
×
459
            initial_memory_usage = ctx.checker.current_memory_usage()
×
460
            for _ in range(number_of_runs):
×
461
                ctx.runner(cmd).assert_success()
×
462
                ctx.checker.assert_running()
×
463

464
            final_memory_usage = ctx.checker.current_memory_usage()
×
465
            self.assertTrue(
×
466
                initial_memory_usage <= final_memory_usage,
467
                "Memory usage inverted unexpectedly: {} > {}".format(
468
                    initial_memory_usage, final_memory_usage
469
                ),
470
            )
471

472
            increase_fraction = (float(final_memory_usage) / initial_memory_usage) - 1.0
×
473
            self.assertTrue(
×
474
                increase_fraction <= max_memory_increase_fraction,
475
                "Memory usage increased more than expected: {} -> {}: {} actual increase (expected < {})".format(
476
                    initial_memory_usage,
477
                    final_memory_usage,
478
                    increase_fraction,
479
                    max_memory_increase_fraction,
480
                ),
481
            )
482

483
    def test_pantsd_max_memory_usage(self):
1✔
484
        """Validates that the max_memory_usage setting is respected."""
485
        # We set a very, very low max memory usage, which forces pantsd to restart immediately.
486
        max_memory_usage_bytes = 130
1✔
487
        with self.pantsd_successful_run_context() as ctx:
1✔
488
            # TODO: We run the command, but we expect it to race pantsd shutting down, so we don't
489
            # assert success. https://github.com/pantsbuild/pants/issues/8200 will address waiting
490
            # until after the current command completes to invalidate the scheduler, at which point
491
            # we can assert success here.
492
            ctx.runner(
1✔
493
                [f"--pantsd-max-memory-usage={max_memory_usage_bytes}", "list", "testprojects::"]
494
            )
495

496
            # Assert that a pid file is written, but that the server stops afterward.
497
            ctx.checker.assert_started_and_stopped()
1✔
498

499
    def test_pantsd_invalidation_stale_sources(self):
1✔
500
        test_path = "daemon_correctness_test_0001"
1✔
501
        test_build_file = os.path.join(test_path, "BUILD")
1✔
502
        test_src_file = os.path.join(test_path, "some_file.py")
1✔
503
        filedeps_cmd = ["--unmatched-build-file-globs=warn", "filedeps", test_path]
1✔
504

505
        try:
1✔
506
            with self.pantsd_successful_run_context() as ctx:
1✔
507
                safe_mkdir(test_path, clean=True)
1✔
508

509
                ctx.runner(["help"])
1✔
510
                ctx.checker.assert_started()
1✔
511

512
                safe_file_dump(
1✔
513
                    test_build_file, "python_sources(sources=['some_non_existent_file.py'])"
514
                )
515
                non_existent_file = os.path.join(test_path, "some_non_existent_file.py")
1✔
516

517
                result = ctx.runner(filedeps_cmd)
1✔
518
                ctx.checker.assert_running()
1✔
519
                assert non_existent_file not in result.stdout
1✔
520

521
                safe_file_dump(test_build_file, "python_sources(sources=['*.py'])")
1✔
522
                result = ctx.runner(filedeps_cmd)
1✔
523
                ctx.checker.assert_running()
1✔
524
                assert non_existent_file not in result.stdout
1✔
525

526
                safe_file_dump(test_src_file, "print('hello')\n")
1✔
527
                result = ctx.runner(filedeps_cmd)
1✔
528
                ctx.checker.assert_running()
1✔
529
                assert test_src_file in result.stdout
1✔
530
        finally:
531
            rm_rf(test_path)
1✔
532

533
    def _assert_pantsd_keyboardinterrupt_signal(
1✔
534
        self,
535
        signum: int,
536
        regexps: list[str] | None = None,
537
        not_regexps: list[str] | None = None,
538
        cleanup_wait_time: int = 0,
539
    ):
540
        """Send a signal to the thin pailgun client and observe the error messaging.
541

542
        :param signum: The signal to send.
543
        :param regexps: Assert that all of these regexps match somewhere in stderr.
544
        :param not_regexps: Assert that all of these regexps do not match somewhere in stderr.
545
        :param cleanup_wait_time: passed through to waiter, dictated how long simulated cleanup will take
546
        """
547
        with self.pantsd_test_context() as (workdir, config, checker):
1✔
548
            client_handle, waiter_pid, child_pid, _ = launch_waiter(
1✔
549
                workdir=workdir, config=config, cleanup_wait_time=cleanup_wait_time
550
            )
551
            client_pid = client_handle.process.pid
1✔
552
            waiter_process = psutil.Process(waiter_pid)
1✔
553
            child_process = psutil.Process(waiter_pid)
1✔
554

555
            assert waiter_process.is_running()
1✔
556
            assert child_process.is_running()
1✔
557
            checker.assert_started()
1✔
558

559
            # give time to enter the try/finally block in the child process
560
            time.sleep(5)
1✔
561

562
            # This should kill the client, which will cancel the run on the server, which will
563
            # kill the waiting process and its child.
564
            os.kill(client_pid, signum)
1✔
565
            client_run = client_handle.join()
1✔
566
            client_run.assert_failure()
1✔
567

568
            for regexp in regexps or []:
1✔
569
                self.assertRegex(client_run.stderr, regexp)
1✔
570

571
            for regexp in not_regexps or []:
1✔
572
                self.assertNotRegex(client_run.stderr, regexp)
1✔
573

574
            # pantsd should still be running, but the waiter process and child should have been
575
            # killed.
576
            time.sleep(5)
1✔
577
            assert not waiter_process.is_running()
1✔
578
            assert not child_process.is_running()
1✔
579
            checker.assert_running()
1✔
580

581
    def test_pantsd_graceful_shutdown(self):
1✔
582
        """Test that SIGINT is propagated to child processes and they are given time to shutdown."""
583
        self._assert_pantsd_keyboardinterrupt_signal(
1✔
584
            signal.SIGINT,
585
            regexps=[
586
                "Interrupted by user.",
587
                "keyboard int received",
588
                "waiter cleaning up",
589
                "waiter cleanup complete",
590
            ],
591
            cleanup_wait_time=0,
592
        )
593

594
    def test_pantsd_graceful_shutdown_deadline(self):
1✔
595
        """Test that a child process that does not respond to SIGINT within 5 seconds, is forcibly
596
        cleaned up with a SIGKILL."""
597
        self._assert_pantsd_keyboardinterrupt_signal(
1✔
598
            signal.SIGINT,
599
            regexps=[
600
                "Interrupted by user.",
601
                "keyboard int received",
602
                "waiter cleaning up",
603
            ],
604
            not_regexps=[
605
                "waiter cleanup complete",
606
            ],
607
            cleanup_wait_time=6,
608
        )
609

610
    def test_sigint_kills_request_waiting_for_lock(self):
1✔
611
        """Test that, when a pailgun request is blocked waiting for another one to end, sending
612
        SIGINT to the blocked run will kill it."""
613
        config = {"GLOBAL": {"pantsd_timeout_when_multiple_invocations": -1.0, "level": "debug"}}
1✔
614
        with self.pantsd_test_context(extra_config=config) as (workdir, config, checker):
1✔
615
            # Run a process that will wait forever.
616
            first_run_handle, _, _, file_to_create = launch_waiter(workdir=workdir, config=config)
1✔
617

618
            checker.assert_started()
1✔
619
            checker.assert_running()
1✔
620

621
            # And another that will block on the first.
622
            blocking_run_handle = self.run_pants_with_workdir_without_waiting(
1✔
623
                command=["goals"], workdir=workdir, config=config
624
            )
625

626
            # Block until the second request is waiting for the lock.
627
            time.sleep(10)
1✔
628

629
            # Sends SIGINT to the run that is waiting.
630
            blocking_run_client_pid = blocking_run_handle.process.pid
1✔
631
            os.kill(blocking_run_client_pid, signal.SIGINT)
1✔
632
            blocking_run_handle.join()
1✔
633

634
            # Check that pantsd is still serving the other request.
635
            checker.assert_running()
1✔
636

637
            # Exit the second run by writing the file it is waiting for, and confirm that it
638
            # exited, and that pantsd is still running.
639
            safe_file_dump(file_to_create, "content!")
1✔
640
            result = first_run_handle.join()
1✔
641
            result.assert_success()
1✔
642
            checker.assert_running()
1✔
643

644
    def test_pantsd_unicode_environment(self):
1✔
645
        with self.pantsd_successful_run_context(extra_env={"XXX": "ยก"}) as ctx:
1✔
646
            result = ctx.runner(["help"])
1✔
647
            ctx.checker.assert_started()
1✔
648
            result.assert_success()
1✔
649

650
    # This is a regression test for a bug where we would incorrectly detect a cycle if two targets swapped their
651
    # dependency relationship (#7404).
652
    def test_dependencies_swap(self):
1✔
653
        template = dedent(
1✔
654
            """
655
            python_source(
656
              name='A',
657
              source='A.py',
658
              {a_deps}
659
            )
660

661
            python_source(
662
              name='B',
663
              source='B.py',
664
              {b_deps}
665
            )
666
            """
667
        )
668
        with self.pantsd_successful_run_context() as ctx, temporary_dir(".") as directory:
1✔
669
            safe_file_dump(os.path.join(directory, "A.py"), mode="w")
1✔
670
            safe_file_dump(os.path.join(directory, "B.py"), mode="w")
1✔
671

672
            if directory.startswith("./"):
1✔
UNCOV
673
                directory = directory[2:]
×
674

675
            def list_and_verify(a_deps: str, b_deps: str) -> None:
1✔
676
                Path(directory, "BUILD").write_text(template.format(a_deps=a_deps, b_deps=b_deps))
1✔
677
                result = ctx.runner(["list", f"{directory}:"])
1✔
678
                ctx.checker.assert_started()
1✔
679
                result.assert_success()
1✔
680
                expected_targets = {f"{directory}:{target}" for target in ("A", "B")}
1✔
681
                assert expected_targets == set(result.stdout.strip().split("\n"))
1✔
682

683
            list_and_verify(a_deps='dependencies = [":B"],', b_deps="")
1✔
UNCOV
684
            list_and_verify(a_deps="", b_deps='dependencies = [":A"],')
×
685

686
    def test_concurrent_overrides_pantsd(self):
1✔
687
        """Tests that the --concurrent flag overrides the --pantsd flag, because we don't allow
688
        concurrent runs under pantsd."""
689
        config = {"GLOBAL": {"concurrent": True, "pantsd": True}}
1✔
690
        with temporary_workdir() as workdir:
1✔
691
            pants_run = self.run_pants_with_workdir(
1✔
692
                ["-ldebug", "help", "goals"], workdir=workdir, config=config
693
            )
694
            pants_run.assert_success()
1✔
695
            self.assertNotIn("Connecting to pantsd", pants_run.stderr)
1✔
696

697
    def test_unhandled_exceptions_only_log_exceptions_once(self):
1✔
698
        """Tests that the unhandled exceptions triggered by LocalPantsRunner instances don't
699
        manifest as a PantsRunFinishedWithFailureException.
700

701
        That is, that we unset the global Exiter override set by LocalPantsRunner before we try to log the exception.
702

703
        This is a regression test for the most glaring case of https://github.com/pantsbuild/pants/issues/7597.
704
        """
705
        with self.pantsd_run_context(success=False) as ctx, temporary_dir(".") as directory:
1✔
706
            Path(directory, "BUILD").write_text(
1✔
707
                dedent(
708
                    """\
709
                    python_requirement(name="badreq", requirements=["badreq==99.99.99"])
710
                    pex_binary(name="pex", dependencies=[":badreq"])
711
                    """
712
                )
713
            )
714
            result = ctx.runner(["package", f"{directory}:pex"])
1✔
715
            ctx.checker.assert_running()
1✔
716
            result.assert_failure()
1✔
717
            # Assert that the desired exception has been triggered once.
718
            self.assertRegex(result.stderr, r"ERROR:.*badreq==99.99.99")
1✔
719
            # Assert that it has only been triggered once.
720
            assert (
1✔
721
                "During handling of the above exception, another exception occurred:"
722
                not in result.stderr
723
            )
724
            assert (
1✔
725
                "pants.bin.daemon_pants_runner._PantsRunFinishedWithFailureException: Terminated with 1"
726
                not in result.stderr
727
            )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc