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

pantsbuild / pants / 26342152999

23 May 2026 07:59PM UTC coverage: 91.165% (-1.6%) from 92.792%
26342152999

push

github

web-flow
Run Linux ARM CI on Depot runners (#23363)

RunsOn is deprecating their v2 stack, and rather than migrate
to v3 we should use the resources kindly donated by Depot.

GitHub also now has Linux ARM runners, should we need them.

87305 of 95766 relevant lines covered (91.16%)

3.87 hits per line

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

76.39
/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
11✔
5

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

17
import psutil
11✔
18

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

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

30

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

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

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

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

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

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

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

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

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

93
    @staticmethod
11✔
94
    def _maybe_cast(item, caster):
11✔
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:
5✔
105
            return caster(item)
5✔
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
11✔
111
    def _deadline_until(
11✔
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()
5✔
138
        deadline = now + timeout
5✔
139
        info_deadline = now + info_interval
5✔
140
        rendered_ongoing = False
5✔
141
        while 1:
5✔
142
            if closure():
5✔
143
                if rendered_ongoing:
5✔
144
                    logger.info(completed_msg)
×
145
                return True
5✔
146

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

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

160
    @classmethod
11✔
161
    def _wait_for_file(
11✔
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():
5✔
173
            return os.path.exists(filename) and (not want_content or os.path.getsize(filename))
5✔
174

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

177
    @classmethod
11✔
178
    def _get_metadata_dir_by_name(cls, name: str, metadata_base_dir: str) -> str:
11✔
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)
5✔
184

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

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

192
    def read_metadata_by_name(self, metadata_key, caster=None):
11✔
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)
5✔
199
        try:
5✔
200
            metadata = read_file(file_path).strip()
5✔
201
            return self._maybe_cast(metadata, caster)
5✔
202
        except OSError:
5✔
203
            return None
5✔
204

205
    def write_metadata_by_name(self, metadata_key, metadata_value) -> None:
11✔
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
        """
211
        safe_mkdir(self._get_metadata_dir_by_name(self.name, self._metadata_base_dir))
×
212
        file_path = self._metadata_file_path(metadata_key)
×
213
        safe_file_dump(file_path, metadata_value)
×
214

215
    def await_metadata_by_name(
11✔
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)
5✔
229
        self._wait_for_file(file_path, ongoing_msg, completed_msg, timeout=timeout)
5✔
230
        return self.read_metadata_by_name(metadata_key, caster)
5✔
231

232
    def purge_metadata_by_name(self, name) -> None:
11✔
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)
5✔
238
        logger.debug(f"purging metadata directory: {meta_dir}")
5✔
239
        try:
5✔
240
            rm_rf(meta_dir)
5✔
241
        except OSError as e:
×
242
            raise ProcessManager.MetadataError(
×
243
                f"failed to purge metadata directory {meta_dir}: {e!r}"
244
            )
245

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

251
    @memoized_property
11✔
252
    def lifecycle_lock(self):
11✔
253
        """An identity-keyed inter-process lock for safeguarding lifecycle and other operations."""
254
        safe_mkdir(self._metadata_base_dir)
5✔
255
        return OwnerPrintingInterProcessFileLock(
5✔
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
11✔
264
    def fingerprint(self):
11✔
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
11✔
275
    def pid(self):
11✔
276
        """The running processes pid (or None)."""
277
        return self.read_metadata_by_name(self.PID_KEY, int)
5✔
278

279
    @property
11✔
280
    def process_name(self):
11✔
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)
5✔
283

284
    @property
11✔
285
    def socket(self):
11✔
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):
11✔
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):
11✔
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:
11✔
306
        """Wait up to a given timeout for a process to write pid metadata."""
307
        return cast(
5✔
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:
11✔
319
        """Wait up to a given timeout for a process to write socket info."""
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):
11✔
332
        """Write the current process's PID."""
333
        pid = os.getpid() if pid is None else pid
×
334
        self.write_metadata_by_name(self.PID_KEY, str(pid))
×
335

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

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

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

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

353
    def _as_process(self):
11✔
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
5✔
366
        if not pid:
5✔
367
            raise self.NotStarted()
5✔
368
        return psutil.Process(pid)
5✔
369

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

374
    def is_alive(self, extended_check=None):
11✔
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:
5✔
383
            process = self._as_process()
5✔
384
            return not (
5✔
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):
5✔
398
            # On some platforms, accessing attributes of a zombie'd Process results in NoSuchProcess.
399
            return False
5✔
400

401
    def purge_metadata(self, force=False):
11✔
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():
5✔
409
            raise ProcessManager.MetadataError("cannot purge metadata for a running process!")
×
410

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

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

418
    def terminate(self, signal_chain=KILL_CHAIN, kill_wait=KILL_WAIT_SEC, purge=True):
11✔
419
        """Ensure a process is terminated by sending a chain of kill signals (SIGTERM, SIGKILL)."""
420
        alive = self.is_alive()
5✔
421
        if alive:
5✔
422
            logger.debug(f"terminating {self._name}")
2✔
423
            for signal_type in signal_chain:
2✔
424
                pid = self.pid
2✔
425
                try:
2✔
426
                    logger.debug(f"sending signal {signal_type} to pid {pid}")
2✔
427
                    self._kill(signal_type)
2✔
428
                except OSError as e:
×
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.
434
                try:
2✔
435
                    if self._deadline_until(
2✔
436
                        self.is_dead,
437
                        f"{self._name} to exit",
438
                        f"{self._name} exited",
439
                        timeout=kill_wait,
440
                    ):
441
                        alive = False
2✔
442
                        logger.debug(f"successfully terminated pid {pid}")
2✔
443
                        break
2✔
444
                except self.Timeout:
×
445
                    # Loop to the next kill signal on timeout.
446
                    pass
×
447

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

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

456
    def daemon_spawn(
11✔
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
        """
466
        self.purge_metadata()
×
467
        self.pre_fork(**pre_fork_opts or {})
×
468
        pid = os.fork()
×
469
        if pid == 0:
×
470
            # fork's child execution
471
            try:
×
472
                os.setsid()
×
473
                os.chdir(self._buildroot)
×
474
                self.post_fork_child(**post_fork_child_opts or {})
×
475
            except Exception:
×
476
                logger.critical(traceback.format_exc())
×
477
            finally:
478
                os._exit(0)
×
479
        else:
480
            # fork's parent execution
481
            try:
×
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):
11✔
487
        """Pre-fork callback for subclasses."""
488

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

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

495

496
class PantsDaemonProcessManager(ProcessManager, metaclass=ABCMeta):
11✔
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):
11✔
504
        super().__init__(
5✔
505
            name="pantsd",
506
            metadata_base_dir=bootstrap_options.for_global_scope().pants_subprocessdir,
507
        )
508
        self._bootstrap_options = bootstrap_options
5✔
509
        self._daemon_entrypoint = daemon_entrypoint
5✔
510

511
    @property
11✔
512
    def options_fingerprint(self) -> str:
11✔
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):
11✔
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):
11✔
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

© 2026 Coveralls, Inc