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

valksor / php-dev-build / 19384258487

15 Nov 2025 04:07AM UTC coverage: 19.747% (+2.5%) from 17.283%
19384258487

push

github

k0d3r1s
prettier

16 of 30 new or added lines in 4 files covered. (53.33%)

516 existing lines in 7 files now uncovered.

484 of 2451 relevant lines covered (19.75%)

1.03 hits per line

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

32.14
/Service/ProcessManager.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace ValksorDev\Build\Service;
14

15
use JetBrains\PhpStorm\NoReturn;
16
use Symfony\Component\Console\Command\Command;
17
use Symfony\Component\Console\Style\SymfonyStyle;
18
use Symfony\Component\Process\Exception\ProcessTimedOutException;
19
use Symfony\Component\Process\Process;
20
use ValksorDev\Build\Util\ConsoleCommandBuilder;
21

22
use function array_filter;
23
use function array_slice;
24
use function count;
25
use function function_exists;
26
use function pcntl_async_signals;
27
use function pcntl_signal;
28
use function sprintf;
29
use function time;
30
use function ucfirst;
31
use function usleep;
32

33
use const SIGINT;
34
use const SIGTERM;
35

36
/**
37
 * Process lifecycle manager for the Valksor build system.
38
 *
39
 * This class manages background processes used in watch mode, providing:
40
 * - Process tracking and health monitoring
41
 * - Graceful shutdown handling with signal management
42
 * - Process status reporting and failure detection
43
 * - Interactive vs non-interactive process execution modes
44
 * - Multi-process coordination and cleanup strategies
45
 *
46
 * The manager ensures all build services (Tailwind, Importmap, Hot Reload) can run
47
 * simultaneously while providing proper cleanup and error handling.
48
 */
49
final class ProcessManager
50
{
51
    private const FAILURE_WINDOW_SECONDS = 30;
52

53
    /**
54
     * Configuration for restart logic.
55
     */
56
    private const MAX_FAILURES_IN_WINDOW = 5;
57

58
    /**
59
     * Maximum number of lines to keep in output buffers per service.
60
     * Prevents memory exhaustion from long-running services.
61
     */
62
    private const int MAX_OUTPUT_LINES = 100;
63
    private const SUCCESS_RESET_SECONDS = 3600; // 1 hour
64

65
    /**
66
     * Error output buffers for capturing service stderr output.
67
     * Maps service names to arrays of recent error lines with timestamps.
68
     *
69
     * @var array<string,array<string>>
70
     */
71
    private array $errorBuffers = [];
72

73
    /**
74
     * Track failure timestamps for restart logic.
75
     * Maps process names to arrays of failure timestamps.
76
     *
77
     * @var array<string,array<int>>
78
     */
79
    private array $failureHistory = [];
80

81
    /**
82
     * Output buffers for capturing service stdout output.
83
     * Maps service names to arrays of recent output lines with timestamps.
84
     *
85
     * @var array<string,array<string>>
86
     */
87
    private array $outputBuffers = [];
88

89
    /**
90
     * Store original command arguments for restart functionality.
91
     * Maps process names to their command arguments.
92
     *
93
     * @var array<string,array<string>>
94
     */
95
    private array $processArgs = [];
96

97
    /**
98
     * Mapping of service names to process identifiers.
99
     * Used for logging and process identification.
100
     *
101
     * @var array<string,string>
102
     */
103
    private array $processNames = [];
104

105
    /**
106
     * Track parent-child relationships between processes.
107
     * Maps child process names to parent process names.
108
     *
109
     * @var array<string,string|null>
110
     */
111
    private array $processParents = [];
112

113
    /**
114
     * Registry of managed background processes.
115
     * Maps service names to Symfony Process instances for lifecycle management.
116
     *
117
     * @var array<string,Process>
118
     */
119
    private array $processes = [];
120

121
    /**
122
     * Flag indicating whether shutdown has been initiated.
123
     * Prevents duplicate shutdown operations and signals.
124
     */
125
    private bool $shutdown = false;
126

127
    public function __construct(
128
        private readonly ?SymfonyStyle $io = null,
129
        private readonly ?ConsoleCommandBuilder $commandBuilder = null,
130
    ) {
131
        // Register signal handlers for graceful shutdown
132
        if (function_exists('pcntl_signal')) {
22✔
133
            pcntl_async_signals(true);
22✔
134
            pcntl_signal(SIGINT, [$this, 'handleSignal']);
22✔
135
            pcntl_signal(SIGTERM, [$this, 'handleSignal']);
22✔
136
        }
137
    }
138

139
    /**
140
     * Add a process to track.
141
     *
142
     * @param string  $name    The service name (e.g., 'hot_reload', 'tailwind')
143
     * @param Process $process The background process
144
     */
145
    public function addProcess(
146
        string $name,
147
        Process $process,
148
    ): void {
149
        $this->processes[$name] = $process;
11✔
150
        $this->processNames[$name] = $name;
11✔
151

152
        // Initialize output buffers for this service
153
        $this->outputBuffers[$name] = [];
11✔
154
        $this->errorBuffers[$name] = [];
11✔
155

156
        $this->io?->text(sprintf('[TRACKING] Now monitoring %s process (PID: %d)', $name, $process->getPid()));
11✔
157
    }
158

159
    /**
160
     * Check if all processes are still running.
161
     */
162
    public function allProcessesRunning(): bool
163
    {
164
        foreach ($this->processes as $name => $process) {
2✔
165
            if (!$process->isRunning()) {
2✔
166
                $this->io?->warning(sprintf('[FAILED] Process %s has stopped (exit code: %d)', $name, $process->getExitCode()));
1✔
167

168
                return false;
1✔
169
            }
170
        }
171

172
        return true;
1✔
173
    }
174

175
    /**
176
     * Clear failure history for a process after successful run.
177
     */
178
    public function clearFailureHistory(
179
        string $processName,
180
    ): void {
181
        unset($this->failureHistory[$processName]);
×
182
        $this->io?->text(sprintf('[CLEAN] Cleared failure history for %s', $processName));
×
183
    }
184

185
    /**
186
     * Clear output buffers for a service.
187
     *
188
     * @param string $serviceName The service name
189
     */
190
    public function clearOutputBuffer(
191
        string $serviceName,
192
    ): void {
UNCOV
193
        $this->outputBuffers[$serviceName] = [];
×
194
        $this->errorBuffers[$serviceName] = [];
×
195

UNCOV
196
        $this->io?->text(sprintf('[CLEANUP] Cleared output buffer for %s', $serviceName));
×
197
    }
198

199
    /**
200
     * Get count of tracked processes.
201
     */
202
    public function count(): int
203
    {
204
        return count($this->processes);
4✔
205
    }
206

207
    /**
208
     * Display current status of all processes.
209
     */
210
    public function displayStatus(): void
211
    {
212
        if (!$this->io) {
1✔
213
            return;
1✔
214
        }
215

216
        // First, capture any new output from running processes
UNCOV
217
        $this->captureProcessOutput();
×
218

UNCOV
219
        $statuses = $this->getProcessStatuses();
×
220

UNCOV
221
        $this->io->section('Service Status');
×
222

UNCOV
223
        foreach ($statuses as $name => $status) {
×
UNCOV
224
            $statusIcon = $status['running'] ? '✓' : '✗';
×
UNCOV
225
            $statusText = $status['running'] ? 'Running' : 'Stopped';
×
UNCOV
226
            $pidInfo = $status['pid'] ? sprintf(' (PID: %d)', $status['pid']) : '';
×
227

UNCOV
228
            $this->io->text(sprintf(
×
UNCOV
229
                '%s %s: %s%s',
×
UNCOV
230
                $statusIcon,
×
UNCOV
231
                ucfirst($name),
×
UNCOV
232
                $statusText,
×
UNCOV
233
                $pidInfo,
×
UNCOV
234
            ));
×
235

236
            // Display recent output for this service
UNCOV
237
            $this->displayServiceOutput($name);
×
238
        }
239

UNCOV
240
        $this->io->newLine();
×
241
    }
242

243
    /**
244
     * Execute a single process with support for interactive and non-interactive modes.
245
     *
246
     * This method handles different execution scenarios:
247
     * - Interactive mode: Starts process and allows it to run continuously (for watch services)
248
     * - Non-interactive mode: Runs process to completion and returns exit code
249
     * - Timeout handling: Manages processes that are expected to run indefinitely
250
     *
251
     * @param array  $arguments     Command arguments to pass to the console
252
     * @param bool   $isInteractive Whether to run in interactive (background) mode
253
     * @param string $serviceName   Name of the service for logging and user feedback
254
     *
255
     * @return int Command exit code (Command::SUCCESS or Command::FAILURE)
256
     */
257
    public function executeProcess(
258
        array $arguments,
259
        bool $isInteractive,
260
        string $serviceName = 'Service',
261
    ): int {
262
        // The first argument is the command name (e.g., 'valksor:tailwind')
263
        $commandName = $arguments[0];
1✔
264
        $commandArgs = array_slice($arguments, 1);
1✔
265

266
        $process = $this->commandBuilder?->build($commandName, [])
1✔
267
            ?? new Process(['php', 'bin/console', ...$arguments]);
1✔
268

269
        if ($isInteractive) {
1✔
270
            // Interactive mode - start process and let it run in background
271
            // Used for watch services that should continue running
272
            try {
273
                // Enable output capture for interactive processes
274
                $process->start();
1✔
275

276
                // Give the process time to initialize and start monitoring
277
                usleep(500000); // 500ms for startup
1✔
278

279
                if ($process->isRunning()) {
1✔
280
                    // Process started successfully and is running in background
281
                    // This is the expected behavior for watch services
282
                    echo sprintf("[RUNNING] %s started and monitoring files for changes\n", $serviceName);
1✔
283

284
                    return Command::SUCCESS;
1✔
285
                }
286

287
                // Process finished quickly (likely an error or quick operation)
288
                // Check if the execution was successful
UNCOV
289
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
290
            } catch (ProcessTimedOutException) {
×
291
                // Timeout exception occurs when the process runs longer than expected
292
                // This is normal for watch services - they are designed to run continuously
UNCOV
293
                if ($process->isRunning()) {
×
294
                    // Process is still running despite timeout - let it continue
UNCOV
295
                    return Command::SUCCESS;
×
296
                }
297

298
                // Process stopped during or after the timeout
299
                // Check if it completed successfully before stopping
UNCOV
300
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
301
            }
302
        } else {
303
            // Non-interactive mode - run process to completion without output
304
            // Used for one-time operations like build commands
UNCOV
305
            $process->run();
×
306

UNCOV
307
            return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
308
        }
309
    }
310

311
    /**
312
     * Get failed processes.
313
     *
314
     * @return array<string,Process>
315
     */
316
    public function getFailedProcesses(): array
317
    {
318
        $failed = [];
1✔
319

320
        foreach ($this->processes as $name => $process) {
1✔
321
            if (!$process->isRunning() && !$process->isSuccessful()) {
1✔
322
                $failed[$name] = $process;
1✔
323
            }
324
        }
325

326
        return $failed;
1✔
327
    }
328

329
    /**
330
     * Get the command arguments for a process.
331
     *
332
     * @return array<string>
333
     */
334
    public function getProcessArgs(
335
        string $processName,
336
    ): array {
UNCOV
337
        return $this->processArgs[$processName] ?? [];
×
338
    }
339

340
    /**
341
     * Get the parent process for a given process.
342
     */
343
    public function getProcessParent(
344
        string $processName,
345
    ): ?string {
UNCOV
346
        return $this->processParents[$processName] ?? null;
×
347
    }
348

349
    /**
350
     * Get status of all tracked processes.
351
     *
352
     * @return array<string,array{running:bool,exit_code:int|null,pid:int|null}>
353
     */
354
    public function getProcessStatuses(): array
355
    {
356
        $statuses = [];
1✔
357

358
        foreach ($this->processes as $name => $process) {
1✔
359
            $statuses[$name] = [
1✔
360
                'running' => $process->isRunning(),
1✔
361
                'exit_code' => $process->getExitCode(),
1✔
362
                'pid' => $process->getPid(),
1✔
363
            ];
1✔
364
        }
365

366
        return $statuses;
1✔
367
    }
368

369
    /**
370
     * Get recent output for a service.
371
     *
372
     * @param string $serviceName The service name
373
     * @param int    $limit       Maximum number of lines to return
374
     *
375
     * @return array<string>
376
     */
377
    public function getRecentOutput(
378
        string $serviceName,
379
        int $limit = 10,
380
    ): array {
UNCOV
381
        $output = $this->outputBuffers[$serviceName] ?? [];
×
UNCOV
382
        $errors = $this->errorBuffers[$serviceName] ?? [];
×
383

384
        // Combine and get most recent lines
385
        $allOutput = array_merge($errors, $output);
×
386

387
        return array_slice($allOutput, -$limit);
×
388
    }
389

390
    /**
391
     * Get the root parent process by traversing up the hierarchy.
392
     */
393
    public function getRootParent(
394
        string $processName,
395
    ): ?string {
396
        $current = $processName;
×
UNCOV
397
        $visited = [];
×
398

UNCOV
399
        while (null !== $current && !isset($visited[$current])) {
×
400
            $visited[$current] = true;
×
UNCOV
401
            $parent = $this->processParents[$current] ?? null;
×
402

UNCOV
403
            if (null === $parent) {
×
UNCOV
404
                return $current; // This is the root
×
405
            }
UNCOV
406
            $current = $parent;
×
407
        }
408

UNCOV
409
        return null; // Circular reference detected
×
410
    }
411

412
    /**
413
     * Handle process failure by restarting the appropriate command.
414
     *
415
     * @param string $failedProcessName The name of the failed process
416
     *
417
     * @return int Command exit code
418
     */
419
    public function handleProcessFailure(
420
        string $failedProcessName,
421
    ): int {
UNCOV
422
        $this->recordFailure($failedProcessName);
×
423

424
        // Determine which process to restart (parent or self)
UNCOV
425
        $processToRestart = $this->getRootParent($failedProcessName) ?? $failedProcessName;
×
426

UNCOV
427
        $this->io?->warning(sprintf('[FAILURE] Process %s failed, restarting %s...', $failedProcessName, $processToRestart));
×
428

UNCOV
429
        return $this->restartProcess($processToRestart);
×
430
    }
431

432
    /**
433
     * Handle shutdown signals for graceful process termination.
434
     *
435
     * This method is called when SIGINT (Ctrl+C) or SIGTERM signals are received.
436
     * It coordinates the shutdown of all managed processes to ensure clean
437
     * termination and proper resource cleanup.
438
     *
439
     * @param int $signal The signal number (SIGINT or SIGTERM)
440
     *
441
     * @return void This method terminates the process
442
     */
443
    #[NoReturn]
444
    public function handleSignal(
445
        int $signal,
446
    ): void {
447
        // Prevent multiple shutdown attempts
UNCOV
448
        $this->shutdown = true;
×
449

450
        switch ($signal) {
451
            case SIGINT:
452
                // User pressed Ctrl+C - provide clear feedback
UNCOV
453
                if ($this->io) {
×
UNCOV
454
                    $this->io->newLine();
×
UNCOV
455
                    $this->io->warning('[INTERRUPT] Received Ctrl+C - shutting down gracefully...');
×
456
                }
457

UNCOV
458
                break;
×
459

460
            case SIGTERM:
461
                // System or process manager requested termination
UNCOV
462
                $this->io?->warning('[TERMINATE] Received termination signal - shutting down gracefully...');
×
463

UNCOV
464
                break;
×
465
        }
466

467
        // Terminate all managed processes before exiting
468
        $this->terminateAll();
×
469

470
        exit(0);
×
471
    }
472

473
    /**
474
     * Check if any process has failed.
475
     */
476
    public function hasFailedProcesses(): bool
477
    {
478
        foreach ($this->processes as $process) {
1✔
479
            if (!$process->isRunning() && !$process->isSuccessful()) {
1✔
480
                return true;
1✔
481
            }
482
        }
483

UNCOV
484
        return false;
×
485
    }
486

487
    /**
488
     * Check if any processes are being tracked.
489
     */
490
    public function hasProcesses(): bool
491
    {
492
        return !empty($this->processes);
2✔
493
    }
494

495
    /**
496
     * Check if shutdown has been initiated.
497
     */
498
    public function isShutdown(): bool
499
    {
500
        return $this->shutdown;
1✔
501
    }
502

503
    /**
504
     * Record a failure for a process with timestamp.
505
     */
506
    public function recordFailure(
507
        string $processName,
508
    ): void {
UNCOV
509
        $this->failureHistory[$processName][] = time();
×
UNCOV
510
        $this->io?->warning(sprintf('[FAILURE] Recorded failure for %s (total: %d)', $processName, count($this->failureHistory[$processName])));
×
511
    }
512

513
    /**
514
     * Remove a stopped process from tracking.
515
     */
516
    public function removeProcess(
517
        string $name,
518
    ): void {
519
        if (isset($this->processes[$name])) {
1✔
520
            unset(
1✔
521
                $this->processes[$name],
1✔
522
                $this->processNames[$name],
1✔
523
                $this->outputBuffers[$name],
1✔
524
                $this->errorBuffers[$name],
1✔
525
            );
1✔
526

527
            $this->io?->text(sprintf('[CLEANUP] Removed %s from tracking', $name));
1✔
528
        }
529
    }
530

531
    /**
532
     * Restart a process using its stored arguments.
533
     *
534
     * @param string $processName The process to restart
535
     *
536
     * @return int Command exit code
537
     */
538
    public function restartProcess(
539
        string $processName,
540
    ): int {
541
        $args = $this->getProcessArgs($processName);
×
542

UNCOV
543
        if (empty($args)) {
×
544
            $this->io?->error(sprintf('[RESTART] No arguments stored for %s, cannot restart', $processName));
×
545

UNCOV
546
            return Command::FAILURE;
×
547
        }
548

UNCOV
549
        if (!$this->shouldRestartProcess($processName)) {
×
550
            $this->terminateAll();
×
551

UNCOV
552
            exit(Command::FAILURE);
×
553
        }
554

555
        // Add exponential backoff delay based on failure count
556
        $failureCount = count($this->failureHistory[$processName] ?? []);
×
UNCOV
557
        $backoffDelay = min(5 * 2 ** ($failureCount - 1), 30); // Max 30 seconds
×
558

UNCOV
559
        if ($failureCount > 0) {
×
UNCOV
560
            $this->io?->text(sprintf('[RESTART] Waiting %d seconds before restart (failure #%d)', $backoffDelay, $failureCount));
×
UNCOV
561
            usleep($backoffDelay * 1000000); // Convert to microseconds
×
562
        }
563

UNCOV
564
        $this->io?->warning(sprintf('[RESTART] Restarting process %s...', $processName));
×
565

566
        // Only terminate the failed process, not all processes
UNCOV
567
        $this->terminateProcess($processName);
×
568

569
        // Start the new process
UNCOV
570
        $process = new Process($args);
×
UNCOV
571
        $process->start();
×
572

573
        // Give it time to initialize
UNCOV
574
        usleep(500000);
×
575

UNCOV
576
        if ($process->isRunning()) {
×
577
            $this->io?->success(sprintf('[RESTART] Successfully restarted %s', $processName));
×
578

UNCOV
579
            return Command::SUCCESS;
×
580
        }
UNCOV
581
        $this->recordFailure($processName);
×
UNCOV
582
        $this->io?->error(sprintf('[RESTART] Failed to restart %s (exit code: %d)', $processName, $process->getExitCode()));
×
583

584
        // If restart failed, try again or give up
UNCOV
585
        return $this->restartProcess($processName);
×
586
    }
587

588
    /**
589
     * Store the original command arguments for a process.
590
     *
591
     * @param string        $processName The process name
592
     * @param array<string> $args        The command arguments
593
     */
594
    public function setProcessArgs(
595
        string $processName,
596
        array $args,
597
    ): void {
598
        $this->processArgs[$processName] = $args;
9✔
599
        $this->io?->text(sprintf('[ARGS] Stored arguments for %s: %s', $processName, implode(' ', $args)));
9✔
600
    }
601

602
    /**
603
     * Set the parent process for a given process.
604
     *
605
     * @param string      $processName The child process name
606
     * @param string|null $parentName  The parent process name, or null if it's a root process
607
     */
608
    public function setProcessParent(
609
        string $processName,
610
        ?string $parentName = null,
611
    ): void {
612
        $this->processParents[$processName] = $parentName;
9✔
613
        $this->io?->text(sprintf('[PARENT] Set %s as parent of %s', $parentName ?? 'root', $processName));
9✔
614
    }
615

616
    /**
617
     * Check if a process should be restarted based on failure history.
618
     */
619
    public function shouldRestartProcess(
620
        string $processName,
621
    ): bool {
UNCOV
622
        $now = time();
×
UNCOV
623
        $failures = $this->failureHistory[$processName] ?? [];
×
624

625
        // Remove old failures outside the window
UNCOV
626
        $recentFailures = array_filter($failures, fn ($timestamp) => $now - $timestamp <= self::FAILURE_WINDOW_SECONDS);
×
627

628
        // Update failure history with only recent failures
UNCOV
629
        $this->failureHistory[$processName] = $recentFailures;
×
630

631
        // Check if we have too many recent failures
UNCOV
632
        if (count($recentFailures) >= self::MAX_FAILURES_IN_WINDOW) {
×
UNCOV
633
            $this->io?->error(sprintf('[GIVE_UP] Too many failures for %s (%d in %d seconds), giving up', $processName, count($recentFailures), self::FAILURE_WINDOW_SECONDS));
×
634

UNCOV
635
            return false;
×
636
        }
637

UNCOV
638
        return true;
×
639
    }
640

641
    /**
642
     * Terminate all tracked processes using a graceful shutdown strategy.
643
     *
644
     * This method implements a two-phase termination approach:
645
     * 1. Graceful termination (SIGTERM) with 2-second timeout
646
     * 2. Force kill (SIGKILL) for stubborn processes
647
     *
648
     * This ensures that processes have time to clean up resources and
649
     * complete any in-progress operations before being forcefully stopped.
650
     */
651
    public function terminateAll(): void
652
    {
653
        $this->io?->text('[SHUTDOWN] Terminating all background processes...');
1✔
654

655
        // Phase 1: Graceful termination using SIGTERM (signal 15)
656
        // This allows processes to clean up and exit cleanly
657
        foreach ($this->processes as $name => $process) {
1✔
658
            if ($process->isRunning()) {
1✔
UNCOV
659
                $this->io?->text(sprintf('[STOPPING] Terminating %s process (PID: %d)', $name, $process->getPid()));
×
UNCOV
660
                $process->stop(2); // Send SIGTERM with 2-second timeout for graceful shutdown
×
661
            }
662
        }
663

664
        // Phase 2: Force kill any remaining processes using SIGKILL (signal 9)
665
        // This immediately terminates processes that didn't respond to SIGTERM
666
        foreach ($this->processes as $name => $process) {
1✔
667
            if ($process->isRunning()) {
1✔
UNCOV
668
                $this->io?->warning(sprintf('[FORCE-KILL] Forcefully killing %s process', $name));
×
UNCOV
669
                $process->signal(9); // SIGKILL - cannot be caught or ignored
×
670
            }
671
        }
672

673
        $this->io?->success('[SHUTDOWN] All processes terminated');
1✔
674
    }
675

676
    /**
677
     * Terminate a specific process gracefully.
678
     *
679
     * @param string $processName The name of the process to terminate
680
     */
681
    public function terminateProcess(
682
        string $processName,
683
    ): void {
UNCOV
684
        if (!isset($this->processes[$processName])) {
×
UNCOV
685
            $this->io?->warning(sprintf('[STOPPING] Process %s not found in registry', $processName));
×
686

UNCOV
687
            return;
×
688
        }
689

UNCOV
690
        $process = $this->processes[$processName];
×
691

UNCOV
692
        if (!$process->isRunning()) {
×
UNCOV
693
            $this->io?->text(sprintf('[STOPPING] Process %s is not running', $processName));
×
694

UNCOV
695
            return;
×
696
        }
697

UNCOV
698
        $this->io?->text(sprintf('[STOPPING] Terminating %s process (PID: %d)', $processName, $process->getPid()));
×
699

700
        // Phase 1: Graceful termination using SIGTERM (signal 15)
UNCOV
701
        $process->stop(2); // Send SIGTERM with 2-second timeout for graceful shutdown
×
702

703
        // Phase 2: Force kill if still running using SIGKILL (signal 9)
UNCOV
704
        if ($process->isRunning()) {
×
UNCOV
705
            $this->io?->warning(sprintf('[FORCE-KILL] Forcefully killing %s process', $processName));
×
UNCOV
706
            $process->signal(9); // SIGKILL - cannot be caught or ignored
×
707
        } else {
UNCOV
708
            $this->io?->success(sprintf('[STOPPING] Successfully terminated %s process', $processName));
×
709
        }
710
    }
711

712
    /**
713
     * Add output line to the appropriate buffer with rotation.
714
     *
715
     * @param string $serviceName The service name
716
     * @param string $output      The output line(s)
717
     * @param bool   $isError     Whether this is error output
718
     */
719
    private function addOutputToBuffer(
720
        string $serviceName,
721
        string $output,
722
        bool $isError,
723
    ): void {
UNCOV
724
        if ($isError) {
×
UNCOV
725
            $buffer = &$this->errorBuffers[$serviceName];
×
726
        } else {
UNCOV
727
            $buffer = &$this->outputBuffers[$serviceName];
×
728
        }
729

730
        // Split output into lines and add with timestamps
UNCOV
731
        $lines = explode("\n", trim($output));
×
UNCOV
732
        $timestamp = date('H:i:s');
×
733

UNCOV
734
        foreach ($lines as $line) {
×
UNCOV
735
            if ('' === $line) {
×
UNCOV
736
                continue;
×
737
            }
738

UNCOV
739
            $buffer[] = sprintf('[%s] %s', $timestamp, $line);
×
740

741
            // Rotate buffer if it exceeds maximum size
UNCOV
742
            if (count($buffer) > self::MAX_OUTPUT_LINES) {
×
UNCOV
743
                array_shift($buffer); // Remove oldest line
×
744
            }
745
        }
746
    }
747

748
    /**
749
     * Capture and buffer output from all running processes.
750
     * This method reads any new output from processes and stores it in buffers.
751
     */
752
    private function captureProcessOutput(): void
753
    {
UNCOV
754
        foreach ($this->processes as $name => $process) {
×
UNCOV
755
            if (!$process->isRunning()) {
×
UNCOV
756
                continue;
×
757
            }
758

759
            // Capture stdout output
UNCOV
760
            $output = $process->getIncrementalOutput();
×
761

UNCOV
762
            if ('' !== $output) {
×
UNCOV
763
                $this->addOutputToBuffer($name, $output, false);
×
764
            }
765

766
            // Capture stderr output
UNCOV
767
            $errorOutput = $process->getIncrementalErrorOutput();
×
768

UNCOV
769
            if ('' !== $errorOutput) {
×
UNCOV
770
                $this->addOutputToBuffer($name, $errorOutput, true);
×
771
            }
772
        }
773
    }
774

775
    /**
776
     * Display recent output for a specific service.
777
     *
778
     * @param string $serviceName The service name
779
     */
780
    private function displayServiceOutput(
781
        string $serviceName,
782
    ): void {
UNCOV
783
        $output = $this->outputBuffers[$serviceName] ?? [];
×
UNCOV
784
        $errors = $this->errorBuffers[$serviceName] ?? [];
×
785

786
        // Display error output first (more important)
UNCOV
787
        foreach ($errors as $errorLine) {
×
UNCOV
788
            $this->io?->text(sprintf('  <error>[%s] %s</error>', strtoupper($serviceName), $errorLine));
×
789
        }
790

791
        // Display regular output
UNCOV
792
        foreach ($output as $outputLine) {
×
UNCOV
793
            $this->io?->text(sprintf('  [%s] %s', strtoupper($serviceName), $outputLine));
×
794
        }
795
    }
796
}
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