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

pantsbuild / pants / 20332790708

18 Dec 2025 09:48AM UTC coverage: 64.992% (-15.3%) from 80.295%
20332790708

Pull #22949

github

web-flow
Merge f730a56cd into 407284c67
Pull Request #22949: Add experimental uv resolver for Python lockfiles

54 of 97 new or added lines in 5 files covered. (55.67%)

8270 existing lines in 295 files now uncovered.

48990 of 75379 relevant lines covered (64.99%)

1.81 hits per line

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

68.67
/src/python/pants/pantsd/process_manager.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
5✔
5

6
import logging
5✔
7
import os
5✔
8
import signal
5✔
9
import sys
5✔
10
import time
5✔
11
import traceback
5✔
12
from abc import ABCMeta
5✔
13
from collections.abc import Callable
5✔
14
from hashlib import sha256
5✔
15
from typing import cast
5✔
16

17
import psutil
5✔
18

19
from pants.base.build_environment import get_buildroot
5✔
20
from pants.bin.pants_env_vars import DAEMON_ENTRYPOINT
5✔
21
from pants.engine.internals.native_engine import pantsd_fingerprint_compute
5✔
22
from pants.option.options import Options
5✔
23
from pants.option.scope import GLOBAL_SCOPE
5✔
24
from pants.pantsd.lock import OwnerPrintingInterProcessFileLock
5✔
25
from pants.util.dirutil import read_file, rm_rf, safe_file_dump, safe_mkdir
5✔
26
from pants.util.memo import memoized_classproperty, memoized_property
5✔
27

28
logger = logging.getLogger(__name__)
5✔
29

30

31
class ProcessManager:
5✔
32
    """Manages contextual, on-disk process metadata.
33

34
    Metadata is stored under a per-host fingerprinted directory, and a nested per-named-process
35
    directory. The per-host directory defends against attempting to use process metadata that has
36
    been mounted into virtual machines or docker images.
37
    """
38

39
    class MetadataError(Exception):
5✔
40
        pass
5✔
41

42
    class Timeout(Exception):
5✔
43
        pass
5✔
44

45
    class NonResponsiveProcess(Exception):
5✔
46
        pass
5✔
47

48
    class NotStarted(Exception):
5✔
49
        pass
5✔
50

51
    KILL_WAIT_SEC = 5
5✔
52
    KILL_CHAIN = (signal.SIGTERM, signal.SIGKILL)
5✔
53

54
    FAIL_WAIT_SEC = 10
5✔
55
    INFO_INTERVAL_SEC = 5
5✔
56
    WAIT_INTERVAL_SEC = 0.1
5✔
57

58
    SOCKET_KEY = "socket"
5✔
59
    PROCESS_NAME_KEY = "process_name"
5✔
60
    PID_KEY = "pid"
5✔
61
    FINGERPRINT_KEY = "fingerprint"
5✔
62

63
    def __init__(self, name: str, metadata_base_dir: str) -> None:
5✔
64
        """
65
        :param string name: The process identity/name (e.g. 'pantsd' or 'ng_Zinc').
66
        :param str metadata_base_dir: The overridden base directory for process metadata.
67
        """
68
        super().__init__()
2✔
69
        self._metadata_base_dir = metadata_base_dir
2✔
70
        self._name = name.lower().strip()
2✔
71
        # TODO: Extract process spawning code.
72
        self._buildroot = get_buildroot()
2✔
73

74
    @memoized_classproperty
5✔
75
    def host_fingerprint(cls) -> str:
5✔
76
        """A fingerprint that attempts to identify the potential scope of a live process.
77

78
        See the class pydoc.
79

80
        In the absence of kernel hotswapping, a new uname means a restart or virtual machine, both
81
        of which mean that process metadata is invalid. Additionally, docker generates a random
82
        hostname per instance, which improves the reliability of this hash.
83

84
        TODO: It would be nice to be able to use `uptime` (e.g. https://crates.io/crates/uptime_lib)
85
        to identify reboots, but it's more challenging than it should be because it would involve
86
        subtracting from the current time, which might hit aliasing issues.
87
        """
88
        hasher = sha256()
2✔
89
        for component in os.uname():
2✔
90
            hasher.update(component.encode())
2✔
91
        return hasher.hexdigest()[:12]
2✔
92

93
    @staticmethod
5✔
94
    def _maybe_cast(item, caster):
5✔
95
        """Given a casting function, attempt to cast to that type while masking common cast
96
        exceptions.
97

98
        N.B. This is mostly suitable for casting string types to numeric types - e.g. a port number
99
        read from disk into an int.
100

101
        :param func caster: A casting callable (e.g. `int`).
102
        :returns: The result of caster(item) or item if TypeError or ValueError are raised during cast.
103
        """
104
        try:
2✔
105
            return caster(item)
2✔
UNCOV
106
        except (TypeError, ValueError):
×
107
            # N.B. the TypeError catch here (already) protects against the case that caster is None.
UNCOV
108
            return item
×
109

110
    @classmethod
5✔
111
    def _deadline_until(
5✔
112
        cls,
113
        closure: Callable[[], bool],
114
        ongoing_msg: str,
115
        completed_msg: str,
116
        timeout: float = FAIL_WAIT_SEC,
117
        wait_interval: float = WAIT_INTERVAL_SEC,
118
        info_interval: float = INFO_INTERVAL_SEC,
119
    ):
120
        """Execute a function/closure repeatedly until a True condition or timeout is met.
121

122
        :param func closure: the function/closure to execute (should not block for long periods of time
123
                             and must return True on success).
124
        :param str ongoing_msg: a description of the action that is being executed, to be rendered as
125
                                info while we wait, and as part of any rendered exception.
126
        :param str completed_msg: a description of the action that is being executed, to be rendered
127
                                after the action has succeeded (but only if we have previously rendered
128
                                the ongoing_msg).
129
        :param float timeout: the maximum amount of time to wait for a true result from the closure in
130
                              seconds. N.B. this is timing based, so won't be exact if the runtime of
131
                              the closure exceeds the timeout.
132
        :param float wait_interval: the amount of time to sleep between closure invocations.
133
        :param float info_interval: the amount of time to wait before and between reports via info
134
                                    logging that we're still waiting for the closure to succeed.
135
        :raises: :class:`ProcessManager.Timeout` on execution timeout.
136
        """
137
        now = time.time()
2✔
138
        deadline = now + timeout
2✔
139
        info_deadline = now + info_interval
2✔
140
        rendered_ongoing = False
2✔
141
        while 1:
2✔
142
            if closure():
2✔
143
                if rendered_ongoing:
2✔
144
                    logger.info(completed_msg)
×
145
                return True
2✔
146

UNCOV
147
            now = time.time()
×
UNCOV
148
            if now > deadline:
×
UNCOV
149
                raise cls.Timeout(
×
150
                    f"exceeded timeout of {timeout} seconds while waiting for {ongoing_msg}"
151
                )
152

UNCOV
153
            if now > info_deadline:
×
UNCOV
154
                logger.info(f"waiting for {ongoing_msg}...")
×
UNCOV
155
                rendered_ongoing = True
×
UNCOV
156
                info_deadline = info_deadline + info_interval
×
UNCOV
157
            elif wait_interval:
×
UNCOV
158
                time.sleep(wait_interval)
×
159

160
    @classmethod
5✔
161
    def _wait_for_file(
5✔
162
        cls,
163
        filename: str,
164
        ongoing_msg: str,
165
        completed_msg: str,
166
        timeout: float = FAIL_WAIT_SEC,
167
        want_content: bool = True,
168
    ):
169
        """Wait up to timeout seconds for filename to appear with a non-zero size or raise
170
        Timeout()."""
171

172
        def file_waiter():
2✔
173
            return os.path.exists(filename) and (not want_content or os.path.getsize(filename))
2✔
174

175
        return cls._deadline_until(file_waiter, ongoing_msg, completed_msg, timeout=timeout)
2✔
176

177
    @classmethod
5✔
178
    def _get_metadata_dir_by_name(cls, name: str, metadata_base_dir: str) -> str:
5✔
179
        """Retrieve the metadata dir by name.
180

181
        This should always live outside of the workdir to survive a clean-all.
182
        """
183
        return os.path.join(metadata_base_dir, cls.host_fingerprint, name)
2✔
184

185
    def _metadata_file_path(self, metadata_key) -> str:
5✔
186
        return self.metadata_file_path(self.name, metadata_key, self._metadata_base_dir)
2✔
187

188
    @classmethod
5✔
189
    def metadata_file_path(cls, name, metadata_key, metadata_base_dir) -> str:
5✔
190
        return os.path.join(cls._get_metadata_dir_by_name(name, metadata_base_dir), metadata_key)
2✔
191

192
    def read_metadata_by_name(self, metadata_key, caster=None):
5✔
193
        """Read process metadata using a named identity.
194

195
        :param string metadata_key: The metadata key (e.g. 'pid').
196
        :param func caster: A casting callable to apply to the read value (e.g. `int`).
197
        """
198
        file_path = self._metadata_file_path(metadata_key)
2✔
199
        try:
2✔
200
            metadata = read_file(file_path).strip()
2✔
201
            return self._maybe_cast(metadata, caster)
2✔
202
        except OSError:
2✔
203
            return None
2✔
204

205
    def write_metadata_by_name(self, metadata_key, metadata_value) -> None:
5✔
206
        """Write process metadata using a named identity.
207

208
        :param string metadata_key: The metadata key (e.g. 'pid').
209
        :param string metadata_value: The metadata value (e.g. '1729').
210
        """
UNCOV
211
        safe_mkdir(self._get_metadata_dir_by_name(self.name, self._metadata_base_dir))
×
UNCOV
212
        file_path = self._metadata_file_path(metadata_key)
×
UNCOV
213
        safe_file_dump(file_path, metadata_value)
×
214

215
    def await_metadata_by_name(
5✔
216
        self, metadata_key, ongoing_msg: str, completed_msg: str, timeout: float, caster=None
217
    ):
218
        """Block up to a timeout for process metadata to arrive on disk.
219

220
        :param string metadata_key: The metadata key (e.g. 'pid').
221
        :param str ongoing_msg: A message that describes what is being waited for while waiting.
222
        :param str completed_msg: A message that describes what was being waited for after completion.
223
        :param float timeout: The deadline to write metadata.
224
        :param type caster: A type-casting callable to apply to the read value (e.g. int, str).
225
        :returns: The value of the metadata key (read from disk post-write).
226
        :raises: :class:`ProcessManager.Timeout` on timeout.
227
        """
228
        file_path = self._metadata_file_path(metadata_key)
2✔
229
        self._wait_for_file(file_path, ongoing_msg, completed_msg, timeout=timeout)
2✔
230
        return self.read_metadata_by_name(metadata_key, caster)
2✔
231

232
    def purge_metadata_by_name(self, name) -> None:
5✔
233
        """Purge a processes metadata directory.
234

235
        :raises: `ProcessManager.MetadataError` when OSError is encountered on metadata dir removal.
236
        """
237
        meta_dir = self._get_metadata_dir_by_name(name, self._metadata_base_dir)
2✔
238
        logger.debug(f"purging metadata directory: {meta_dir}")
2✔
239
        try:
2✔
240
            rm_rf(meta_dir)
2✔
UNCOV
241
        except OSError as e:
×
UNCOV
242
            raise ProcessManager.MetadataError(
×
243
                f"failed to purge metadata directory {meta_dir}: {e!r}"
244
            )
245

246
    @property
5✔
247
    def name(self):
5✔
248
        """The logical name/label of the process."""
249
        return self._name
2✔
250

251
    @memoized_property
5✔
252
    def lifecycle_lock(self):
5✔
253
        """An identity-keyed inter-process lock for safeguarding lifecycle and other operations."""
254
        safe_mkdir(self._metadata_base_dir)
2✔
255
        return OwnerPrintingInterProcessFileLock(
2✔
256
            # N.B. This lock can't key into the actual named metadata dir (e.g.
257
            # `.pants.d/pids/pantsd/lock` via `ProcessManager._get_metadata_dir_by_name()`)
258
            # because of a need to purge the named metadata dir on startup to avoid stale
259
            # metadata reads.
260
            os.path.join(self._metadata_base_dir, f".lock.{self._name}")
261
        )
262

263
    @property
5✔
264
    def fingerprint(self):
5✔
265
        """The fingerprint of the current process.
266

267
        This reads the current fingerprint from the `ProcessManager` metadata.
268

269
        :returns: The fingerprint of the running process as read from ProcessManager metadata or `None`.
270
        :rtype: string
271
        """
272
        return self.read_metadata_by_name(self.FINGERPRINT_KEY)
×
273

274
    @property
5✔
275
    def pid(self):
5✔
276
        """The running processes pid (or None)."""
277
        return self.read_metadata_by_name(self.PID_KEY, int)
2✔
278

279
    @property
5✔
280
    def process_name(self):
5✔
281
        """The process name, to be compared to the psutil exe_name for stale pid checking."""
282
        return self.read_metadata_by_name(self.PROCESS_NAME_KEY, str)
2✔
283

284
    @property
5✔
285
    def socket(self):
5✔
286
        """The running processes socket/port information (or None)."""
287
        return self.read_metadata_by_name(self.SOCKET_KEY, int)
×
288

289
    def has_current_fingerprint(self, fingerprint):
5✔
290
        """Determines if a new fingerprint is the current fingerprint of the running process.
291

292
        :param string fingerprint: The new fingerprint to compare to.
293
        :rtype: bool
294
        """
295
        return fingerprint == self.fingerprint
×
296

297
    def needs_restart(self, fingerprint):
5✔
298
        """Determines if the current ProcessManager needs to be started or restarted.
299

300
        :param string fingerprint: The new fingerprint to compare to.
301
        :rtype: bool
302
        """
303
        return self.is_dead() or not self.has_current_fingerprint(fingerprint)
×
304

305
    def await_pid(self, timeout: float) -> int:
5✔
306
        """Wait up to a given timeout for a process to write pid metadata."""
307
        return cast(
2✔
308
            int,
309
            self.await_metadata_by_name(
310
                self.PID_KEY,
311
                f"{self._name} to start",
312
                f"{self._name} started",
313
                timeout,
314
                caster=int,
315
            ),
316
        )
317

318
    def await_socket(self, timeout: float) -> int:
5✔
319
        """Wait up to a given timeout for a process to write socket info."""
UNCOV
320
        return cast(
×
321
            int,
322
            self.await_metadata_by_name(
323
                self.SOCKET_KEY,
324
                f"{self._name} socket to be opened",
325
                f"{self._name} socket opened",
326
                timeout,
327
                caster=int,
328
            ),
329
        )
330

331
    def write_pid(self, pid: int | None = None):
5✔
332
        """Write the current process's PID."""
UNCOV
333
        pid = os.getpid() if pid is None else pid
×
UNCOV
334
        self.write_metadata_by_name(self.PID_KEY, str(pid))
×
335

336
    def _get_process_name(self, process: psutil.Process | None = None) -> str:
5✔
337
        proc = process or self._as_process()
2✔
338
        cmdline = proc.cmdline()
2✔
339
        return cast(str, cmdline[0] if cmdline else proc.name())
2✔
340

341
    def write_process_name(self, process_name: str | None = None):
5✔
342
        """Write the current process's name."""
UNCOV
343
        process_name = process_name or self._get_process_name()
×
UNCOV
344
        self.write_metadata_by_name(self.PROCESS_NAME_KEY, process_name)
×
345

346
    def write_socket(self, socket_info: int):
5✔
347
        """Write the local processes socket information (TCP port or UNIX socket)."""
UNCOV
348
        self.write_metadata_by_name(self.SOCKET_KEY, str(socket_info))
×
349

350
    def write_fingerprint(self, fingerprint: str) -> None:
5✔
351
        self.write_metadata_by_name(self.FINGERPRINT_KEY, fingerprint)
×
352

353
    def _as_process(self):
5✔
354
        """Returns a psutil `Process` object wrapping our pid.
355

356
        NB: Even with a process object in hand, subsequent method calls against it can always raise
357
        `NoSuchProcess`.  Care is needed to document the raises in the public API or else trap them and
358
        do something sensible for the API.
359

360
        :returns: a psutil Process object or else None if we have no pid.
361
        :rtype: :class:`psutil.Process`
362
        :raises: :class:`psutil.NoSuchProcess` if the process identified by our pid has died.
363
        :raises: :class:`self.NotStarted` if no pid has been recorded for this process.
364
        """
365
        pid = self.pid
2✔
366
        if not pid:
2✔
367
            raise self.NotStarted()
2✔
368
        return psutil.Process(pid)
2✔
369

370
    def is_dead(self):
5✔
371
        """Return a boolean indicating whether the process is dead or not."""
372
        return not self.is_alive()
2✔
373

374
    def is_alive(self, extended_check=None):
5✔
375
        """Return a boolean indicating whether the process is running or not.
376

377
        :param func extended_check: An additional callable that will be invoked to perform an extended
378
                                    liveness check. This callable should take a single argument of a
379
                                    `psutil.Process` instance representing the context-local process
380
                                    and return a boolean True/False to indicate alive vs not alive.
381
        """
382
        try:
2✔
383
            process = self._as_process()
2✔
384
            return not (
2✔
385
                # Can happen if we don't find our pid.
386
                (not process)
387
                or
388
                # Check for walkers.
389
                (process.status() == psutil.STATUS_ZOMBIE)
390
                or
391
                # Check for stale pids.
392
                (self.process_name and self.process_name != self._get_process_name(process))
393
                or
394
                # Extended checking.
395
                (extended_check and not extended_check(process))
396
            )
397
        except (self.NotStarted, psutil.NoSuchProcess, psutil.AccessDenied):
2✔
398
            # On some platforms, accessing attributes of a zombie'd Process results in NoSuchProcess.
399
            return False
2✔
400

401
    def purge_metadata(self, force=False):
5✔
402
        """Instance-based version of ProcessManager.purge_metadata_by_name() that checks for process
403
        liveness before purging metadata.
404

405
        :param bool force: If True, skip process liveness check before purging metadata.
406
        :raises: `ProcessManager.MetadataError` when OSError is encountered on metadata dir removal.
407
        """
408
        if not force and self.is_alive():
2✔
UNCOV
409
            raise ProcessManager.MetadataError("cannot purge metadata for a running process!")
×
410

411
        self.purge_metadata_by_name(self._name)
2✔
412

413
    def _kill(self, kill_sig):
5✔
414
        """Send a signal to the current process."""
UNCOV
415
        if self.pid:
×
UNCOV
416
            os.kill(self.pid, kill_sig)
×
417

418
    def terminate(self, signal_chain=KILL_CHAIN, kill_wait=KILL_WAIT_SEC, purge=True):
5✔
419
        """Ensure a process is terminated by sending a chain of kill signals (SIGTERM, SIGKILL)."""
420
        alive = self.is_alive()
2✔
421
        if alive:
2✔
UNCOV
422
            logger.debug(f"terminating {self._name}")
×
UNCOV
423
            for signal_type in signal_chain:
×
UNCOV
424
                pid = self.pid
×
UNCOV
425
                try:
×
UNCOV
426
                    logger.debug(f"sending signal {signal_type} to pid {pid}")
×
UNCOV
427
                    self._kill(signal_type)
×
UNCOV
428
                except OSError as e:
×
UNCOV
429
                    logger.warning(
×
430
                        f"caught OSError({e!s}) during attempt to kill -{signal_type} {pid}!"
431
                    )
432

433
                # Wait up to kill_wait seconds to terminate or move onto the next signal.
UNCOV
434
                try:
×
UNCOV
435
                    if self._deadline_until(
×
436
                        self.is_dead,
437
                        f"{self._name} to exit",
438
                        f"{self._name} exited",
439
                        timeout=kill_wait,
440
                    ):
UNCOV
441
                        alive = False
×
UNCOV
442
                        logger.debug(f"successfully terminated pid {pid}")
×
UNCOV
443
                        break
×
UNCOV
444
                except self.Timeout:
×
445
                    # Loop to the next kill signal on timeout.
UNCOV
446
                    pass
×
447

448
        if alive:
2✔
UNCOV
449
            raise ProcessManager.NonResponsiveProcess(
×
450
                f"failed to kill pid {self.pid} with signals {signal_chain}"
451
            )
452

453
        if purge:
2✔
454
            self.purge_metadata(force=True)
2✔
455

456
    def daemon_spawn(
5✔
457
        self, pre_fork_opts=None, post_fork_parent_opts=None, post_fork_child_opts=None
458
    ):
459
        """Perform a single-fork to run a subprocess and write the child pid file.
460

461
        Use this if your post_fork_child block invokes a subprocess via subprocess.Popen(). In this
462
        case, a second fork is extraneous given that Popen() also forks. Using this daemonization
463
        method leaves the responsibility of writing the pid to the caller to allow for library-
464
        agnostic flexibility in subprocess execution.
465
        """
UNCOV
466
        self.purge_metadata()
×
UNCOV
467
        self.pre_fork(**pre_fork_opts or {})
×
UNCOV
468
        pid = os.fork()
×
UNCOV
469
        if pid == 0:
×
470
            # fork's child execution
UNCOV
471
            try:
×
UNCOV
472
                os.setsid()
×
UNCOV
473
                os.chdir(self._buildroot)
×
UNCOV
474
                self.post_fork_child(**post_fork_child_opts or {})
×
475
            except Exception:
×
476
                logger.critical(traceback.format_exc())
×
477
            finally:
UNCOV
478
                os._exit(0)
×
479
        else:
480
            # fork's parent execution
UNCOV
481
            try:
×
UNCOV
482
                self.post_fork_parent(**post_fork_parent_opts or {})
×
483
            except Exception:
×
484
                logger.critical(traceback.format_exc())
×
485

486
    def pre_fork(self):
5✔
487
        """Pre-fork callback for subclasses."""
488

489
    def post_fork_child(self):
5✔
490
        """Pre-fork child callback for subclasses."""
491

492
    def post_fork_parent(self):
5✔
493
        """Post-fork parent callback for subclasses."""
494

495

496
class PantsDaemonProcessManager(ProcessManager, metaclass=ABCMeta):
5✔
497
    """An ABC for classes that interact with pantsd's metadata.
498

499
    This is extended by both a pantsd client handle, and by the server: the client reads process
500
    metadata, and the server writes it.
501
    """
502

503
    def __init__(self, bootstrap_options: Options, daemon_entrypoint: str):
5✔
504
        super().__init__(
2✔
505
            name="pantsd",
506
            metadata_base_dir=bootstrap_options.for_global_scope().pants_subprocessdir,
507
        )
508
        self._bootstrap_options = bootstrap_options
2✔
509
        self._daemon_entrypoint = daemon_entrypoint
2✔
510

511
    @property
5✔
512
    def options_fingerprint(self) -> str:
5✔
513
        """Returns the options fingerprint for the pantsd process.
514

515
        This should cover all options consumed by the pantsd process itself in order to start: also
516
        known as the "micro-bootstrap" options. These options are marked `daemon=True` in the global
517
        options.
518

519
        The `daemon=True` options are a small subset of the bootstrap options. Independently, the
520
        PantsDaemonCore fingerprints the entire set of bootstrap options to identify when the
521
        Scheduler needs need to be re-initialized.
522
        """
523
        fingerprintable_options = self._bootstrap_options.get_fingerprintable_for_scope(
×
524
            GLOBAL_SCOPE, daemon_only=True
525
        )
526
        fingerprintable_option_names = {name for name, _, _ in fingerprintable_options}
×
527
        return pantsd_fingerprint_compute(fingerprintable_option_names)
×
528

529
    def needs_restart(self, option_fingerprint):
5✔
530
        """Overrides ProcessManager.needs_restart, to account for the case where pantsd is running
531
        but we want to shutdown after this run.
532

533
        :param option_fingerprint: A fingerprint of the global bootstrap options.
534
        :return: True if the daemon needs to restart.
535
        """
536
        return super().needs_restart(option_fingerprint)
×
537

538
    def post_fork_child(self):
5✔
539
        """Post-fork() child callback for ProcessManager.daemon_spawn()."""
540
        spawn_control_env = {
×
541
            DAEMON_ENTRYPOINT: f"{self._daemon_entrypoint}:launch_new_pantsd_instance",
542
            # The daemon should run under the same sys.path as us; so we ensure
543
            # this. NB: It will scrub PYTHONPATH once started to avoid infecting
544
            # its own unrelated subprocesses.
545
            "PYTHONPATH": os.pathsep.join(sys.path),
546
        }
547
        exec_env = {**os.environ, **spawn_control_env}
×
548

549
        # Pass all of sys.argv so that we can proxy arg flags e.g. `-ldebug`.
550
        cmd = [sys.executable] + sys.argv
×
551

552
        spawn_control_env_vars = " ".join(f"{k}={v}" for k, v in spawn_control_env.items())
×
553
        cmd_line = " ".join(cmd)
×
554
        logger.debug(f"pantsd command is: {spawn_control_env_vars} {cmd_line}")
×
555

556
        # TODO: Improve error handling on launch failures.
557
        os.spawnve(os.P_NOWAIT, sys.executable, cmd, env=exec_env)
×
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