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

TAKETODAY / today-infrastructure / 18154768944

01 Oct 2025 07:26AM UTC coverage: 81.882% (-0.005%) from 81.887%
18154768944

push

github

web-flow
Merge pull request #290 from TAKETODAY/dev/jspecify

jspecify

59788 of 78013 branches covered (76.64%)

Branch coverage included in aggregate %.

141239 of 167496 relevant lines covered (84.32%)

3.6 hits per line

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

72.94
today-context/src/main/java/infra/scheduling/concurrent/ExecutorConfigurationSupport.java
1
/*
2
 * Copyright 2017 - 2025 the original author or authors.
3
 *
4
 * This program is free software: you can redistribute it and/or modify
5
 * it under the terms of the GNU General Public License as published by
6
 * the Free Software Foundation, either version 3 of the License, or
7
 * (at your option) any later version.
8
 *
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 * GNU General Public License for more details.
13
 *
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see [https://www.gnu.org/licenses/]
16
 */
17

18
package infra.scheduling.concurrent;
19

20
import org.jspecify.annotations.Nullable;
21

22
import java.util.concurrent.ExecutorService;
23
import java.util.concurrent.Future;
24
import java.util.concurrent.RejectedExecutionHandler;
25
import java.util.concurrent.RunnableFuture;
26
import java.util.concurrent.ThreadFactory;
27
import java.util.concurrent.ThreadPoolExecutor;
28
import java.util.concurrent.TimeUnit;
29

30
import infra.beans.factory.BeanNameAware;
31
import infra.beans.factory.DisposableBean;
32
import infra.beans.factory.InitializingBean;
33
import infra.context.ApplicationContext;
34
import infra.context.ApplicationContextAware;
35
import infra.context.ApplicationListener;
36
import infra.context.Lifecycle;
37
import infra.context.SmartLifecycle;
38
import infra.context.event.ContextClosedEvent;
39
import infra.context.support.DefaultLifecycleProcessor;
40
import infra.core.task.SimpleAsyncTaskExecutor;
41
import infra.core.task.VirtualThreadTaskExecutor;
42
import infra.jndi.JndiLocatorDelegate;
43
import infra.logging.Logger;
44
import infra.logging.LoggerFactory;
45
import infra.util.CustomizableThreadCreator;
46

47
/**
48
 * Base class for setting up a {@link java.util.concurrent.ExecutorService}
49
 * (typically a {@link java.util.concurrent.ThreadPoolExecutor} or
50
 * {@link java.util.concurrent.ScheduledThreadPoolExecutor}).
51
 *
52
 * <p>Defines common configuration settings and common lifecycle handling,
53
 * inheriting thread customization options (name, priority, etc) from
54
 * {@link CustomizableThreadCreator}.
55
 *
56
 * @author Juergen Hoeller
57
 * @author <a href="https://github.com/TAKETODAY">Harry Yang</a>
58
 * @see java.util.concurrent.ExecutorService
59
 * @see java.util.concurrent.Executors
60
 * @see java.util.concurrent.ThreadPoolExecutor
61
 * @see java.util.concurrent.ScheduledThreadPoolExecutor
62
 * @since 4.0
63
 */
64
@SuppressWarnings("serial")
65
public abstract class ExecutorConfigurationSupport extends CustomizableThreadFactory implements BeanNameAware,
2✔
66
        ApplicationContextAware, InitializingBean, DisposableBean, SmartLifecycle, ApplicationListener<ContextClosedEvent> {
67

68
  /**
69
   * The default phase for an executor {@link SmartLifecycle}: {@code Integer.MAX_VALUE / 2}.
70
   * <p>This is different from the default phase {@code Integer.MAX_VALUE} associated with
71
   * other {@link SmartLifecycle} implementations, putting the typically auto-started
72
   * executor/scheduler beans into an earlier startup phase and a later shutdown phase while
73
   * still leaving room for regular {@link Lifecycle} components with the common phase 0.
74
   *
75
   * @see #getPhase()
76
   * @see SmartLifecycle#DEFAULT_PHASE
77
   * @see DefaultLifecycleProcessor#setTimeoutPerShutdownPhase
78
   */
79
  public static final int DEFAULT_PHASE = Integer.MAX_VALUE / 2;
80

81
  protected final Logger logger = LoggerFactory.getLogger(getClass());
5✔
82

83
  private ThreadFactory threadFactory = this;
3✔
84

85
  private boolean threadNamePrefixSet = false;
3✔
86

87
  private RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
5✔
88

89
  private boolean acceptTasksAfterContextClose = false;
3✔
90

91
  private boolean waitForTasksToCompleteOnShutdown = false;
3✔
92

93
  private long awaitTerminationMillis = 0;
3✔
94

95
  private int phase = DEFAULT_PHASE;
3✔
96

97
  @Nullable
98
  private String beanName;
99

100
  @Nullable
101
  private ApplicationContext applicationContext;
102

103
  @Nullable
104
  private ExecutorService executor;
105

106
  @Nullable
107
  private ExecutorLifecycleDelegate lifecycleDelegate;
108

109
  private volatile boolean lateShutdown;
110

111
  private boolean virtualThreads = false;
4✔
112

113
  /**
114
   * Specify whether to use virtual threads instead of platform threads.
115
   * This is off by default, setting up a traditional platform thread pool.
116
   * <p>Set this flag to {@code true} on Java 21 or higher for a tightly
117
   * managed thread pool setup with virtual threads. In contrast to
118
   * {@link SimpleAsyncTaskExecutor}, this is integrated with Infra
119
   * lifecycle management for stopping and restarting execution threads,
120
   * including an early stop signal for a graceful shutdown arrangement.
121
   * <p>Specify either this or {@link #setThreadFactory}, not both.
122
   *
123
   * @see #setThreadFactory
124
   * @see VirtualThreadTaskExecutor#getVirtualThreadFactory()
125
   * @see SimpleAsyncTaskExecutor#setVirtualThreads
126
   * @since 5.0
127
   */
128
  public void setVirtualThreads(boolean virtualThreads) {
129
    this.virtualThreads = virtualThreads;
×
130
    this.threadFactory = this;
×
131
  }
×
132

133
  /**
134
   * Set the ThreadFactory to use for the ExecutorService's thread pool.
135
   * Default is the underlying ExecutorService's default thread factory.
136
   * <p>In a Jakarta EE or other managed environment with JSR-236 support,
137
   * consider specifying a JNDI-located ManagedThreadFactory: by default,
138
   * to be found at "java:comp/DefaultManagedThreadFactory".
139
   * Use the "jee:jndi-lookup" namespace element in XML or the programmatic
140
   * {@link JndiLocatorDelegate} for convenient lookup.
141
   * Alternatively, consider using {@link DefaultManagedAwareThreadFactory}
142
   * with its fallback to local threads in case of no managed thread factory found.
143
   *
144
   * @see java.util.concurrent.Executors#defaultThreadFactory()
145
   * @see jakarta.enterprise.concurrent.ManagedThreadFactory
146
   * @see DefaultManagedAwareThreadFactory
147
   */
148
  public void setThreadFactory(@Nullable ThreadFactory threadFactory) {
149
    this.threadFactory = (threadFactory != null ? threadFactory : this);
7✔
150
    this.virtualThreads = false;
3✔
151
  }
1✔
152

153
  @Override
154
  public void setThreadNamePrefix(@Nullable String threadNamePrefix) {
155
    super.setThreadNamePrefix(threadNamePrefix);
3✔
156
    this.threadNamePrefixSet = true;
3✔
157
  }
1✔
158

159
  /**
160
   * Set the RejectedExecutionHandler to use for the ExecutorService.
161
   * Default is the ExecutorService's default abort policy.
162
   *
163
   * @see java.util.concurrent.ThreadPoolExecutor.AbortPolicy
164
   */
165
  public void setRejectedExecutionHandler(@Nullable RejectedExecutionHandler rejectedExecutionHandler) {
166
    this.rejectedExecutionHandler =
1✔
167
            (rejectedExecutionHandler != null ? rejectedExecutionHandler : new ThreadPoolExecutor.AbortPolicy());
8✔
168
  }
1✔
169

170
  /**
171
   * Set whether to accept further tasks after the application context close phase
172
   * has begun.
173
   * <p>The default is {@code false}, triggering an early soft shutdown of
174
   * the executor and therefore rejecting any further task submissions. Switch this
175
   * to {@code true} in order to let other components submit tasks even during their
176
   * own stop and destruction callbacks, at the expense of a longer shutdown phase.
177
   * The executor will not go through a coordinated lifecycle stop phase then
178
   * but rather only stop tasks on its own shutdown.
179
   * <p>{@code acceptTasksAfterContextClose=true} like behavior also follows from
180
   * {@link #setWaitForTasksToCompleteOnShutdown "waitForTasksToCompleteOnShutdown"}
181
   * which effectively is a specific variant of this flag, replacing the early soft
182
   * shutdown in the concurrent managed stop phase with a serial soft shutdown in
183
   * the executor's destruction step, with individual awaiting according to the
184
   * {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
185
   * <p>This flag will only have effect when the executor is running in a Infra
186
   * application context and able to receive the {@link ContextClosedEvent}. Also,
187
   * note that {@link ThreadPoolTaskExecutor} effectively accepts tasks after context
188
   * close by default, in combination with a coordinated lifecycle stop, unless
189
   * {@link ThreadPoolTaskExecutor#setStrictEarlyShutdown "strictEarlyShutdown"}
190
   * has been specified.
191
   *
192
   * @see infra.context.ConfigurableApplicationContext#close()
193
   * @see DisposableBean#destroy()
194
   * @see #shutdown()
195
   * @see #setAwaitTerminationSeconds
196
   */
197
  public void setAcceptTasksAfterContextClose(boolean acceptTasksAfterContextClose) {
198
    this.acceptTasksAfterContextClose = acceptTasksAfterContextClose;
3✔
199
  }
1✔
200

201
  /**
202
   * Set whether to wait for scheduled tasks to complete on shutdown,
203
   * not interrupting running tasks and executing all tasks in the queue.
204
   * <p>The default is {@code false}, with a coordinated lifecycle stop first
205
   * (unless {@link #setAcceptTasksAfterContextClose "acceptTasksAfterContextClose"}
206
   * has been set) and then an immediate shutdown through interrupting ongoing
207
   * tasks and clearing the queue. Switch this flag to {@code true} if you
208
   * prefer fully completed tasks at the expense of a longer shutdown phase.
209
   * The executor will not go through a coordinated lifecycle stop phase then
210
   * but rather only stop and wait for task completion on its own shutdown.
211
   * <p>Note that Infra container shutdown continues while ongoing tasks
212
   * are being completed. If you want this executor to block and wait for the
213
   * termination of tasks before the rest of the container continues to shut
214
   * down - e.g. in order to keep up other resources that your tasks may need -,
215
   * set the {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"}
216
   * property instead of or in addition to this property.
217
   *
218
   * @see java.util.concurrent.ExecutorService#shutdown()
219
   * @see java.util.concurrent.ExecutorService#shutdownNow()
220
   * @see #shutdown()
221
   * @see #setAwaitTerminationSeconds
222
   */
223
  public void setWaitForTasksToCompleteOnShutdown(boolean waitForJobsToCompleteOnShutdown) {
224
    this.waitForTasksToCompleteOnShutdown = waitForJobsToCompleteOnShutdown;
3✔
225
  }
1✔
226

227
  /**
228
   * Set the maximum number of seconds that this executor is supposed to block
229
   * on shutdown in order to wait for remaining tasks to complete their execution
230
   * before the rest of the container continues to shut down. This is particularly
231
   * useful if your remaining tasks are likely to need access to other resources
232
   * that are also managed by the container.
233
   * <p>By default, this executor won't wait for the termination of tasks at all.
234
   * It will either shut down immediately, interrupting ongoing tasks and clearing
235
   * the remaining task queue - or, if the
236
   * {@link #setWaitForTasksToCompleteOnShutdown "waitForTasksToCompleteOnShutdown"}
237
   * flag has been set to {@code true}, it will continue to fully execute all
238
   * ongoing tasks as well as all remaining tasks in the queue, in parallel to
239
   * the rest of the container shutting down.
240
   * <p>In either case, if you specify an await-termination period using this property,
241
   * this executor will wait for the given time (max) for the termination of tasks.
242
   * As a rule of thumb, specify a significantly higher timeout here if you set
243
   * "waitForTasksToCompleteOnShutdown" to {@code true} at the same time,
244
   * since all remaining tasks in the queue will still get executed - in contrast
245
   * to the default shutdown behavior where it's just about waiting for currently
246
   * executing tasks that aren't reacting to thread interruption.
247
   *
248
   * @see #setAwaitTerminationMillis
249
   * @see java.util.concurrent.ExecutorService#shutdown()
250
   * @see java.util.concurrent.ExecutorService#awaitTermination
251
   */
252
  public void setAwaitTerminationSeconds(int awaitTerminationSeconds) {
253
    this.awaitTerminationMillis = awaitTerminationSeconds * 1000L;
6✔
254
  }
1✔
255

256
  /**
257
   * Variant of {@link #setAwaitTerminationSeconds} with millisecond precision.
258
   *
259
   * @see #setAwaitTerminationSeconds
260
   */
261
  public void setAwaitTerminationMillis(long awaitTerminationMillis) {
262
    this.awaitTerminationMillis = awaitTerminationMillis;
3✔
263
  }
1✔
264

265
  /**
266
   * Specify the lifecycle phase for pausing and resuming this executor.
267
   * The default is {@link #DEFAULT_PHASE}.
268
   *
269
   * @see SmartLifecycle#getPhase()
270
   */
271
  public void setPhase(int phase) {
272
    this.phase = phase;
×
273
  }
×
274

275
  /**
276
   * Return the lifecycle phase for pausing and resuming this executor.
277
   *
278
   * @see #setPhase
279
   */
280
  @Override
281
  public int getPhase() {
282
    return this.phase;
3✔
283
  }
284

285
  @Override
286
  public void setBeanName(String name) {
287
    this.beanName = name;
3✔
288
  }
1✔
289

290
  @Override
291
  public void setApplicationContext(ApplicationContext applicationContext) {
292
    this.applicationContext = applicationContext;
3✔
293
  }
1✔
294

295
  /**
296
   * Calls {@code initialize()} after the container applied all property values.
297
   *
298
   * @see #initialize()
299
   */
300
  @Override
301
  public void afterPropertiesSet() {
302
    initialize();
2✔
303
  }
1✔
304

305
  /**
306
   * Set up the ExecutorService.
307
   */
308
  public void initialize() {
309
    if (logger.isDebugEnabled()) {
4!
310
      logger.debug("Initializing ExecutorService{}", (this.beanName != null ? " '" + this.beanName + "'" : ""));
×
311
    }
312
    if (!this.threadNamePrefixSet && this.beanName != null) {
6✔
313
      setThreadNamePrefix(this.beanName + "-");
5✔
314
    }
315
    ThreadFactory factory = this.virtualThreads ?
3!
316
            new VirtualThreadTaskExecutor(getThreadNamePrefix()).getVirtualThreadFactory() : this.threadFactory;
3✔
317
    this.executor = initializeExecutor(factory, this.rejectedExecutionHandler);
7✔
318
    this.lifecycleDelegate = new ExecutorLifecycleDelegate(this.executor);
7✔
319
  }
1✔
320

321
  /**
322
   * Create the target {@link java.util.concurrent.ExecutorService} instance.
323
   * Called by {@code afterPropertiesSet}.
324
   *
325
   * @param threadFactory the ThreadFactory to use
326
   * @param rejectedHandler the RejectedExecutionHandler to use
327
   * @return a new ExecutorService instance
328
   * @see #afterPropertiesSet()
329
   */
330
  protected abstract ExecutorService initializeExecutor(
331
          ThreadFactory threadFactory, RejectedExecutionHandler rejectedHandler);
332

333
  /**
334
   * Calls {@code shutdown} when the BeanFactory destroys the executor instance.
335
   *
336
   * @see #shutdown()
337
   */
338
  @Override
339
  public void destroy() {
340
    shutdown();
2✔
341
  }
1✔
342

343
  /**
344
   * Initiate a shutdown on the underlying ExecutorService,
345
   * rejecting further task submissions.
346
   * <p>The executor will not accept further tasks and will prevent further
347
   * scheduling of periodic tasks, letting existing tasks complete still.
348
   * This step is non-blocking and can be applied as an early shutdown signal
349
   * before following up with a full {@link #shutdown()} call later on.
350
   * <p>Automatically called for early shutdown signals on
351
   * {@link #onApplicationEvent(ContextClosedEvent) context close}.
352
   * Can be manually called as well, in particular outside a container.
353
   *
354
   * @see #shutdown()
355
   * @see java.util.concurrent.ExecutorService#shutdown()
356
   */
357
  public void initiateShutdown() {
358
    if (this.executor != null) {
3!
359
      this.executor.shutdown();
3✔
360
    }
361
  }
1✔
362

363
  /**
364
   * Perform a full shutdown on the underlying ExecutorService,
365
   * according to the corresponding configuration settings.
366
   * <p>This step potentially blocks for the configured termination period,
367
   * waiting for remaining tasks to complete. For an early shutdown signal
368
   * to not accept further tasks, call {@link #initiateShutdown()} first.
369
   *
370
   * @see #setWaitForTasksToCompleteOnShutdown
371
   * @see #setAwaitTerminationMillis
372
   * @see java.util.concurrent.ExecutorService#shutdown()
373
   * @see java.util.concurrent.ExecutorService#shutdownNow()
374
   * @see java.util.concurrent.ExecutorService#awaitTermination
375
   */
376
  public void shutdown() {
377
    if (logger.isDebugEnabled()) {
4!
378
      logger.debug("Shutting down ExecutorService{}", (this.beanName != null ? " '" + this.beanName + "'" : ""));
×
379
    }
380
    if (this.executor != null) {
3!
381
      if (this.waitForTasksToCompleteOnShutdown) {
3✔
382
        this.executor.shutdown();
4✔
383
      }
384
      else {
385
        for (Runnable remainingTask : this.executor.shutdownNow()) {
12✔
386
          cancelRemainingTask(remainingTask);
3✔
387
        }
1✔
388
      }
389
      awaitTerminationIfNecessary(this.executor);
4✔
390
    }
391
  }
1✔
392

393
  /**
394
   * Cancel the given remaining task which never commended execution,
395
   * as returned from {@link ExecutorService#shutdownNow()}.
396
   *
397
   * @param task the task to cancel (typically a {@link RunnableFuture})
398
   * @see #shutdown()
399
   * @see RunnableFuture#cancel(boolean)
400
   */
401
  protected void cancelRemainingTask(Runnable task) {
402
    if (task instanceof Future<?> future) {
6✔
403
      future.cancel(true);
4✔
404
    }
405
  }
1✔
406

407
  /**
408
   * Wait for the executor to terminate, according to the value of the
409
   * {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
410
   */
411
  private void awaitTerminationIfNecessary(ExecutorService executor) {
412
    if (this.awaitTerminationMillis > 0) {
5✔
413
      try {
414
        if (!executor.awaitTermination(this.awaitTerminationMillis, TimeUnit.MILLISECONDS)) {
6!
415
          if (logger.isWarnEnabled()) {
×
416
            logger.warn("Timed out while waiting for executor{} to terminate",
×
417
                    this.beanName != null ? " '" + this.beanName + "'" : "");
×
418
          }
419
        }
420
      }
421
      catch (InterruptedException ex) {
×
422
        if (logger.isWarnEnabled()) {
×
423
          logger.warn("Interrupted while waiting for executor{} to terminate",
×
424
                  this.beanName != null ? " '" + this.beanName + "'" : "");
×
425
        }
426
        Thread.currentThread().interrupt();
×
427
      }
1✔
428
    }
429
  }
1✔
430

431
  /**
432
   * Resume this executor if paused before (otherwise a no-op).
433
   */
434
  @Override
435
  public void start() {
436
    if (lifecycleDelegate != null) {
3!
437
      lifecycleDelegate.start();
3✔
438
    }
439
  }
1✔
440

441
  /**
442
   * Pause this executor, not waiting for tasks to complete.
443
   */
444
  @Override
445
  public void stop() {
446
    if (this.lifecycleDelegate != null && !this.lateShutdown) {
×
447
      this.lifecycleDelegate.stop();
×
448
    }
449
  }
×
450

451
  /**
452
   * Pause this executor, triggering the given callback
453
   * once all currently executing tasks have completed.
454
   */
455
  @Override
456
  public void stop(Runnable callback) {
457
    if (this.lifecycleDelegate != null && !this.lateShutdown) {
6!
458
      this.lifecycleDelegate.stop(callback);
5✔
459
    }
460
    else {
461
      callback.run();
2✔
462
    }
463
  }
1✔
464

465
  /**
466
   * Check whether this executor is not paused and has not been shut down either.
467
   *
468
   * @see #start()
469
   * @see #stop()
470
   */
471
  @Override
472
  public boolean isRunning() {
473
    return lifecycleDelegate != null && lifecycleDelegate.isRunning();
11!
474
  }
475

476
  /**
477
   * A before-execute callback for framework subclasses to delegate to
478
   * (for start/stop handling), and possibly also for custom subclasses
479
   * to extend (making sure to call this implementation as well).
480
   *
481
   * @param thread the thread to run the task
482
   * @param task the task to be executed
483
   * @see ThreadPoolExecutor#beforeExecute(Thread, Runnable)
484
   */
485
  protected void beforeExecute(Thread thread, Runnable task) {
486
    if (lifecycleDelegate != null) {
3!
487
      lifecycleDelegate.beforeExecute(thread);
4✔
488
    }
489
  }
1✔
490

491
  /**
492
   * An after-execute callback for framework subclasses to delegate to
493
   * (for start/stop handling), and possibly also for custom subclasses
494
   * to extend (making sure to call this implementation as well).
495
   *
496
   * @param task the task that has been executed
497
   * @param ex the exception thrown during execution, if any
498
   * @see ThreadPoolExecutor#afterExecute(Runnable, Throwable)
499
   */
500
  protected void afterExecute(Runnable task, @Nullable Throwable ex) {
501
    if (lifecycleDelegate != null) {
3!
502
      lifecycleDelegate.afterExecute();
3✔
503
    }
504
  }
1✔
505

506
  /**
507
   * {@link ContextClosedEvent} handler for initiating an early shutdown.
508
   *
509
   * @see #initiateShutdown()
510
   */
511
  @Override
512
  public void onApplicationEvent(ContextClosedEvent event) {
513
    if (event.getApplicationContext() == this.applicationContext) {
5✔
514
      if (this.acceptTasksAfterContextClose || this.waitForTasksToCompleteOnShutdown) {
6✔
515
        // Late shutdown without early stop lifecycle.
516
        this.lateShutdown = true;
4✔
517
      }
518
      else {
519
        if (this.lifecycleDelegate != null) {
3!
520
          this.lifecycleDelegate.markShutdown();
3✔
521
        }
522
        initiateEarlyShutdown();
2✔
523
      }
524
    }
525
  }
1✔
526

527
  /**
528
   * Early shutdown signal: do not trigger further tasks, let existing tasks complete
529
   * before hitting the actual destruction step in the {@link #shutdown()} method.
530
   * This goes along with a {@link #stop(Runnable) coordinated lifecycle stop phase}.
531
   * <p>Called from {@link #onApplicationEvent(ContextClosedEvent)} if no
532
   * indications for a late shutdown have been determined, that is, if the
533
   * {@link #setAcceptTasksAfterContextClose "acceptTasksAfterContextClose} and
534
   * {@link #setWaitForTasksToCompleteOnShutdown "waitForTasksToCompleteOnShutdown"}
535
   * flags have not been set.
536
   * <p>The default implementation calls {@link #initiateShutdown()}.
537
   *
538
   * @see #initiateShutdown()
539
   */
540
  protected void initiateEarlyShutdown() {
541
    initiateShutdown();
2✔
542
  }
1✔
543

544
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc