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

valksor / php-dev-build / 19705532601

26 Nov 2025 01:33PM UTC coverage: 30.503% (+2.6%) from 27.943%
19705532601

push

github

k0d3r1s
generic binary provider

131 of 243 new or added lines in 7 files covered. (53.91%)

135 existing lines in 7 files now uncovered.

783 of 2567 relevant lines covered (30.5%)

1.15 hits per line

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

30.73
/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 int FAILURE_WINDOW_SECONDS = 30;
52

53
    /**
54
     * Configuration for restart logic.
55
     */
56
    private const int 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
    // 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')) {
24✔
133
            pcntl_async_signals(true);
24✔
134
            pcntl_signal(SIGINT, [$this, 'handleSignal']);
24✔
135
            pcntl_signal(SIGTERM, [$this, 'handleSignal']);
24✔
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 {
193
        $this->outputBuffers[$serviceName] = [];
×
194
        $this->errorBuffers[$serviceName] = [];
×
195

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
217
        $this->captureProcessOutput();
×
218

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

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

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

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

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

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
        $process = new Process(['php', 'bin/console', ...$arguments]);
1✔
263

264
        if ($isInteractive) {
1✔
265
            // Interactive mode - start process and let it run in background
266
            // Used for watch services that should continue running
267
            try {
268
                // Enable output capture for interactive processes
269
                $process->start();
1✔
270

271
                // Give the process time to initialize and start monitoring
272
                usleep(500000); // 500ms for startup
1✔
273

274
                if ($process->isRunning()) {
1✔
275
                    // Process started successfully and is running in background
276
                    // This is the expected behavior for watch services
UNCOV
277
                    echo sprintf("[RUNNING] %s started and monitoring files for changes\n", $serviceName);
×
278

UNCOV
279
                    return Command::SUCCESS;
×
280
                }
281

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

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

UNCOV
302
            return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
303
        }
304
    }
305

306
    /**
307
     * Get failed processes.
308
     *
309
     * @return array<string,Process>
310
     */
311
    public function getFailedProcesses(): array
312
    {
313
        $failed = [];
1✔
314

315
        foreach ($this->processes as $name => $process) {
1✔
316
            if (!$process->isRunning() && !$process->isSuccessful()) {
1✔
317
                $failed[$name] = $process;
1✔
318
            }
319
        }
320

321
        return $failed;
1✔
322
    }
323

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

335
    /**
336
     * Get the parent process for a given process.
337
     */
338
    public function getProcessParent(
339
        string $processName,
340
    ): ?string {
UNCOV
341
        return $this->processParents[$processName] ?? null;
×
342
    }
343

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

353
        foreach ($this->processes as $name => $process) {
1✔
354
            $statuses[$name] = [
1✔
355
                'running' => $process->isRunning(),
1✔
356
                'exit_code' => $process->getExitCode(),
1✔
357
                'pid' => $process->getPid(),
1✔
358
            ];
1✔
359
        }
360

361
        return $statuses;
1✔
362
    }
363

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

379
        // Combine and get most recent lines
UNCOV
380
        $allOutput = array_merge($errors, $output);
×
381

382
        return array_slice($allOutput, -$limit);
×
383
    }
384

385
    /**
386
     * Get the root parent process by traversing up the hierarchy.
387
     */
388
    public function getRootParent(
389
        string $processName,
390
    ): ?string {
UNCOV
391
        $current = $processName;
×
UNCOV
392
        $visited = [];
×
393

UNCOV
394
        while (null !== $current && !isset($visited[$current])) {
×
UNCOV
395
            $visited[$current] = true;
×
396
            $parent = $this->processParents[$current] ?? null;
×
397

UNCOV
398
            if (null === $parent) {
×
399
                return $current; // This is the root
×
400
            }
401
            $current = $parent;
×
402
        }
403

404
        return null; // Circular reference detected
×
405
    }
406

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

419
        // Determine which process to restart (parent or self)
UNCOV
420
        $processToRestart = $this->getRootParent($failedProcessName) ?? $failedProcessName;
×
421

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

UNCOV
424
        return $this->restartProcess($processToRestart);
×
425
    }
426

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

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

453
                break;
×
454

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

UNCOV
459
                break;
×
460
        }
461

462
        // Terminate all managed processes before exiting
UNCOV
463
        $this->terminateAll();
×
464

UNCOV
465
        exit(0);
×
466
    }
467

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

UNCOV
479
        return false;
×
480
    }
481

482
    /**
483
     * Check if any processes are being tracked.
484
     */
485
    public function hasProcesses(): bool
486
    {
487
        return !empty($this->processes);
2✔
488
    }
489

490
    /**
491
     * Check if shutdown has been initiated.
492
     */
493
    public function isShutdown(): bool
494
    {
495
        return $this->shutdown;
1✔
496
    }
497

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

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

522
            $this->io?->text(sprintf('[CLEANUP] Removed %s from tracking', $name));
1✔
523
        }
524
    }
525

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

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

541
            return Command::FAILURE;
×
542
        }
543

544
        if (!$this->shouldRestartProcess($processName)) {
×
UNCOV
545
            $this->terminateAll();
×
546

UNCOV
547
            exit(Command::FAILURE);
×
548
        }
549

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

UNCOV
554
        if ($failureCount > 0) {
×
UNCOV
555
            $this->io?->text(sprintf('[RESTART] Waiting %d seconds before restart (failure #%d)', $backoffDelay, $failureCount));
×
556
            usleep($backoffDelay * 1000000); // Convert to microseconds
×
557
        }
558

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

561
        // Only terminate the failed process, not all processes
UNCOV
562
        $this->terminateProcess($processName);
×
563

564
        // Start the new process
UNCOV
565
        $process = new Process($args);
×
UNCOV
566
        $process->start();
×
567

568
        // Give it time to initialize
UNCOV
569
        usleep(500000);
×
570

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

574
            return Command::SUCCESS;
×
575
        }
576
        $this->recordFailure($processName);
×
577
        $this->io?->error(sprintf('[RESTART] Failed to restart %s (exit code: %d)', $processName, $process->getExitCode()));
×
578

579
        // If restart failed, try again or give up
UNCOV
580
        return $this->restartProcess($processName);
×
581
    }
582

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

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

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

620
        // Remove old failures outside the window
UNCOV
621
        $recentFailures = array_filter($failures, static fn ($timestamp) => $now - $timestamp <= self::FAILURE_WINDOW_SECONDS);
×
622

623
        // Update failure history with only recent failures
UNCOV
624
        $this->failureHistory[$processName] = $recentFailures;
×
625

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

UNCOV
630
            return false;
×
631
        }
632

633
        return true;
×
634
    }
635

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

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

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

668
        $this->io?->success('[SHUTDOWN] All processes terminated');
1✔
669
    }
670

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

UNCOV
682
            return;
×
683
        }
684

685
        $process = $this->processes[$processName];
×
686

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

690
            return;
×
691
        }
692

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

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

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

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

725
        // Split output into lines and add with timestamps
UNCOV
726
        $lines = explode("\n", trim($output));
×
727
        $timestamp = date('H:i:s');
×
728

UNCOV
729
        foreach ($lines as $line) {
×
UNCOV
730
            if ('' === $line) {
×
731
                continue;
×
732
            }
733

734
            $buffer[] = sprintf('[%s] %s', $timestamp, $line);
×
735

736
            // Rotate buffer if it exceeds maximum size
UNCOV
737
            if (count($buffer) > self::MAX_OUTPUT_LINES) {
×
UNCOV
738
                array_shift($buffer); // Remove oldest line
×
739
            }
740
        }
741
    }
742

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

754
            // Capture stdout output
755
            $output = $process->getIncrementalOutput();
×
756

UNCOV
757
            if ('' !== $output) {
×
UNCOV
758
                $this->addOutputToBuffer($name, $output, false);
×
759
            }
760

761
            // Capture stderr output
762
            $errorOutput = $process->getIncrementalErrorOutput();
×
763

UNCOV
764
            if ('' !== $errorOutput) {
×
UNCOV
765
                $this->addOutputToBuffer($name, $errorOutput, true);
×
766
            }
767
        }
768
    }
769

770
    /**
771
     * Display recent output for a specific service.
772
     *
773
     * @param string $serviceName The service name
774
     */
775
    private function displayServiceOutput(
776
        string $serviceName,
777
    ): void {
UNCOV
778
        $output = $this->outputBuffers[$serviceName] ?? [];
×
779

780
        // Display error output first (more important)
UNCOV
781
        foreach ($this->errorBuffers[$serviceName] ?? [] as $errorLine) {
×
UNCOV
782
            $this->io?->text(sprintf('  <error>[%s] %s</error>', strtoupper($serviceName), $errorLine));
×
783
        }
784

785
        // Display regular output
UNCOV
786
        foreach ($output as $outputLine) {
×
787
            $this->io?->text(sprintf('  [%s] %s', strtoupper($serviceName), $outputLine));
×
788
        }
789
    }
790
}
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