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

pantsbuild / pants / 18517631058

15 Oct 2025 04:18AM UTC coverage: 69.207% (-11.1%) from 80.267%
18517631058

Pull #22745

github

web-flow
Merge 642a76ca1 into 99919310e
Pull Request #22745: [windows] Add windows support in the stdio crate.

53815 of 77759 relevant lines covered (69.21%)

2.42 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
7✔
5

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

17
import psutil
7✔
18

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

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

30

31
class ProcessManager:
7✔
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):
7✔
40
        pass
7✔
41

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

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

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

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

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

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

63
    def __init__(self, name: str, metadata_base_dir: str) -> None:
7✔
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__()
3✔
69
        self._metadata_base_dir = metadata_base_dir
3✔
70
        self._name = name.lower().strip()
3✔
71
        # TODO: Extract process spawning code.
72
        self._buildroot = get_buildroot()
3✔
73

74
    @memoized_classproperty
7✔
75
    def host_fingerprint(cls) -> str:
7✔
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()
3✔
89
        for component in os.uname():
3✔
90
            hasher.update(component.encode())
3✔
91
        return hasher.hexdigest()[:12]
3✔
92

93
    @staticmethod
7✔
94
    def _maybe_cast(item, caster):
7✔
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:
3✔
105
            return caster(item)
3✔
106
        except (TypeError, ValueError):
×
107
            # N.B. the TypeError catch here (already) protects against the case that caster is None.
108
            return item
×
109

110
    @classmethod
7✔
111
    def _deadline_until(
7✔
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()
3✔
138
        deadline = now + timeout
3✔
139
        info_deadline = now + info_interval
3✔
140
        rendered_ongoing = False
3✔
141
        while 1:
3✔
142
            if closure():
3✔
143
                if rendered_ongoing:
3✔
144
                    logger.info(completed_msg)
×
145
                return True
3✔
146

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

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

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

174
        def file_waiter():
3✔
175
            return os.path.exists(filename) and (not want_content or os.path.getsize(filename))
3✔
176

177
        return cls._deadline_until(file_waiter, ongoing_msg, completed_msg, timeout=timeout)
3✔
178

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

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

187
    def _metadata_file_path(self, metadata_key) -> str:
7✔
188
        return self.metadata_file_path(self.name, metadata_key, self._metadata_base_dir)
3✔
189

190
    @classmethod
7✔
191
    def metadata_file_path(cls, name, metadata_key, metadata_base_dir) -> str:
7✔
192
        return os.path.join(cls._get_metadata_dir_by_name(name, metadata_base_dir), metadata_key)
3✔
193

194
    def read_metadata_by_name(self, metadata_key, caster=None):
7✔
195
        """Read process metadata using a named identity.
196

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

207
    def write_metadata_by_name(self, metadata_key, metadata_value) -> None:
7✔
208
        """Write process metadata using a named identity.
209

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

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

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

234
    def purge_metadata_by_name(self, name) -> None:
7✔
235
        """Purge a processes metadata directory.
236

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

248
    @property
7✔
249
    def name(self):
7✔
250
        """The logical name/label of the process."""
251
        return self._name
3✔
252

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

265
    @property
7✔
266
    def fingerprint(self):
7✔
267
        """The fingerprint of the current process.
268

269
        This reads the current fingerprint from the `ProcessManager` metadata.
270

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

276
    @property
7✔
277
    def pid(self):
7✔
278
        """The running processes pid (or None)."""
279
        return self.read_metadata_by_name(self.PID_KEY, int)
3✔
280

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

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

291
    def has_current_fingerprint(self, fingerprint):
7✔
292
        """Determines if a new fingerprint is the current fingerprint of the running process.
293

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

299
    def needs_restart(self, fingerprint):
7✔
300
        """Determines if the current ProcessManager needs to be started or restarted.
301

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

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

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

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

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

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

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

352
    def write_fingerprint(self, fingerprint: str) -> None:
7✔
353
        self.write_metadata_by_name(self.FINGERPRINT_KEY, fingerprint)
×
354

355
    def _as_process(self):
7✔
356
        """Returns a psutil `Process` object wrapping our pid.
357

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

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

372
    def is_dead(self):
7✔
373
        """Return a boolean indicating whether the process is dead or not."""
374
        return not self.is_alive()
3✔
375

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

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

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

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

413
        self.purge_metadata_by_name(self._name)
3✔
414

415
    def _kill(self, kill_sig):
7✔
416
        """Send a signal to the current process."""
417
        if self.pid:
×
418
            os.kill(self.pid, kill_sig)
×
419

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

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

452
        if alive:
3✔
453
            raise ProcessManager.NonResponsiveProcess(
×
454
                "failed to kill pid {pid} with signals {chain}".format(
455
                    pid=self.pid, chain=signal_chain
456
                )
457
            )
458

459
        if purge:
3✔
460
            self.purge_metadata(force=True)
3✔
461

462
    def daemon_spawn(
7✔
463
        self, pre_fork_opts=None, post_fork_parent_opts=None, post_fork_child_opts=None
464
    ):
465
        """Perform a single-fork to run a subprocess and write the child pid file.
466

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

492
    def pre_fork(self):
7✔
493
        """Pre-fork callback for subclasses."""
494

495
    def post_fork_child(self):
7✔
496
        """Pre-fork child callback for subclasses."""
497

498
    def post_fork_parent(self):
7✔
499
        """Post-fork parent callback for subclasses."""
500

501

502
class PantsDaemonProcessManager(ProcessManager, metaclass=ABCMeta):
7✔
503
    """An ABC for classes that interact with pantsd's metadata.
504

505
    This is extended by both a pantsd client handle, and by the server: the client reads process
506
    metadata, and the server writes it.
507
    """
508

509
    def __init__(self, bootstrap_options: Options, daemon_entrypoint: str):
7✔
510
        super().__init__(
3✔
511
            name="pantsd",
512
            metadata_base_dir=bootstrap_options.for_global_scope().pants_subprocessdir,
513
        )
514
        self._bootstrap_options = bootstrap_options
3✔
515
        self._daemon_entrypoint = daemon_entrypoint
3✔
516

517
    @property
7✔
518
    def options_fingerprint(self) -> str:
7✔
519
        """Returns the options fingerprint for the pantsd process.
520

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

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

535
    def needs_restart(self, option_fingerprint):
7✔
536
        """Overrides ProcessManager.needs_restart, to account for the case where pantsd is running
537
        but we want to shutdown after this run.
538

539
        :param option_fingerprint: A fingerprint of the global bootstrap options.
540
        :return: True if the daemon needs to restart.
541
        """
542
        return super().needs_restart(option_fingerprint)
×
543

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

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

558
        spawn_control_env_vars = " ".join(f"{k}={v}" for k, v in spawn_control_env.items())
×
559
        cmd_line = " ".join(cmd)
×
560
        logger.debug(f"pantsd command is: {spawn_control_env_vars} {cmd_line}")
×
561

562
        # TODO: Improve error handling on launch failures.
563
        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