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

valksor / php-dev-build / 19202038340

09 Nov 2025 02:25AM UTC coverage: 17.283% (-0.9%) from 18.191%
19202038340

push

github

k0d3r1s
update documentation

383 of 2216 relevant lines covered (17.28%)

0.92 hits per line

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

41.18
/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
    private const SUCCESS_RESET_SECONDS = 3600; // 1 hour
58

59
    /**
60
     * Track failure timestamps for restart logic.
61
     * Maps process names to arrays of failure timestamps.
62
     *
63
     * @var array<string,array<int>>
64
     */
65
    private array $failureHistory = [];
66

67
    /**
68
     * Store original command arguments for restart functionality.
69
     * Maps process names to their command arguments.
70
     *
71
     * @var array<string,array<string>>
72
     */
73
    private array $processArgs = [];
74

75
    /**
76
     * Mapping of service names to process identifiers.
77
     * Used for logging and process identification.
78
     *
79
     * @var array<string,string>
80
     */
81
    private array $processNames = [];
82

83
    /**
84
     * Track parent-child relationships between processes.
85
     * Maps child process names to parent process names.
86
     *
87
     * @var array<string,string|null>
88
     */
89
    private array $processParents = [];
90

91
    /**
92
     * Registry of managed background processes.
93
     * Maps service names to Symfony Process instances for lifecycle management.
94
     *
95
     * @var array<string,Process>
96
     */
97
    private array $processes = [];
98

99
    /**
100
     * Flag indicating whether shutdown has been initiated.
101
     * Prevents duplicate shutdown operations and signals.
102
     */
103
    private bool $shutdown = false;
104

105
    public function __construct(
106
        private readonly ?SymfonyStyle $io = null,
107
        private readonly ?ConsoleCommandBuilder $commandBuilder = null,
108
    ) {
109
        // Register signal handlers for graceful shutdown
110
        if (function_exists('pcntl_signal')) {
22✔
111
            pcntl_async_signals(true);
22✔
112
            pcntl_signal(SIGINT, [$this, 'handleSignal']);
22✔
113
            pcntl_signal(SIGTERM, [$this, 'handleSignal']);
22✔
114
        }
115
    }
116

117
    /**
118
     * Add a process to track.
119
     *
120
     * @param string  $name    The service name (e.g., 'hot_reload', 'tailwind')
121
     * @param Process $process The background process
122
     */
123
    public function addProcess(
124
        string $name,
125
        Process $process,
126
    ): void {
127
        $this->processes[$name] = $process;
11✔
128
        $this->processNames[$name] = $name;
11✔
129

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

133
    /**
134
     * Check if all processes are still running.
135
     */
136
    public function allProcessesRunning(): bool
137
    {
138
        foreach ($this->processes as $name => $process) {
2✔
139
            if (!$process->isRunning()) {
2✔
140
                $this->io?->warning(sprintf('[FAILED] Process %s has stopped (exit code: %d)', $name, $process->getExitCode()));
1✔
141

142
                return false;
1✔
143
            }
144
        }
145

146
        return true;
1✔
147
    }
148

149
    /**
150
     * Clear failure history for a process after successful run.
151
     */
152
    public function clearFailureHistory(
153
        string $processName,
154
    ): void {
155
        unset($this->failureHistory[$processName]);
×
156
        $this->io?->text(sprintf('[CLEAN] Cleared failure history for %s', $processName));
×
157
    }
158

159
    /**
160
     * Get count of tracked processes.
161
     */
162
    public function count(): int
163
    {
164
        return count($this->processes);
4✔
165
    }
166

167
    /**
168
     * Display current status of all processes.
169
     */
170
    public function displayStatus(): void
171
    {
172
        if (!$this->io) {
1✔
173
            return;
1✔
174
        }
175

176
        $statuses = $this->getProcessStatuses();
×
177

178
        $this->io->section('Service Status');
×
179

180
        foreach ($statuses as $name => $status) {
×
181
            $statusIcon = $status['running'] ? '✓' : '✗';
×
182
            $statusText = $status['running'] ? 'Running' : 'Stopped';
×
183
            $pidInfo = $status['pid'] ? sprintf(' (PID: %d)', $status['pid']) : '';
×
184

185
            $this->io->text(sprintf(
×
186
                '%s %s: %s%s',
×
187
                $statusIcon,
×
188
                ucfirst($name),
×
189
                $statusText,
×
190
                $pidInfo,
×
191
            ));
×
192
        }
193

194
        $this->io->newLine();
×
195
    }
196

197
    /**
198
     * Execute a single process with support for interactive and non-interactive modes.
199
     *
200
     * This method handles different execution scenarios:
201
     * - Interactive mode: Starts process and allows it to run continuously (for watch services)
202
     * - Non-interactive mode: Runs process to completion and returns exit code
203
     * - Timeout handling: Manages processes that are expected to run indefinitely
204
     *
205
     * @param array  $arguments     Command arguments to pass to the console
206
     * @param bool   $isInteractive Whether to run in interactive (background) mode
207
     * @param string $serviceName   Name of the service for logging and user feedback
208
     *
209
     * @return int Command exit code (Command::SUCCESS or Command::FAILURE)
210
     */
211
    public function executeProcess(
212
        array $arguments,
213
        bool $isInteractive,
214
        string $serviceName = 'Service',
215
    ): int {
216
        // The first argument is the command name (e.g., 'valksor:tailwind')
217
        $commandName = $arguments[0];
1✔
218
        $commandArgs = array_slice($arguments, 1);
1✔
219

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

223
        if ($isInteractive) {
1✔
224
            // Interactive mode - start process and let it run in background
225
            // Used for watch services that should continue running
226
            try {
227
                $process->start();
1✔
228

229
                // Give the process time to initialize and start monitoring
230
                usleep(500000); // 500ms for startup
1✔
231

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

237
                    return Command::SUCCESS;
1✔
238
                }
239

240
                // Process finished quickly (likely an error or quick operation)
241
                // Check if the execution was successful
242
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
243
            } catch (ProcessTimedOutException) {
×
244
                // Timeout exception occurs when the process runs longer than expected
245
                // This is normal for watch services - they are designed to run continuously
246
                if ($process->isRunning()) {
×
247
                    // Process is still running despite timeout - let it continue
248
                    return Command::SUCCESS;
×
249
                }
250

251
                // Process stopped during or after the timeout
252
                // Check if it completed successfully before stopping
253
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
254
            }
255
        } else {
256
            // Non-interactive mode - run process to completion without output
257
            // Used for one-time operations like build commands
258
            $process->run();
×
259

260
            return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
261
        }
262
    }
263

264
    /**
265
     * Get failed processes.
266
     *
267
     * @return array<string,Process>
268
     */
269
    public function getFailedProcesses(): array
270
    {
271
        $failed = [];
1✔
272

273
        foreach ($this->processes as $name => $process) {
1✔
274
            if (!$process->isRunning() && !$process->isSuccessful()) {
1✔
275
                $failed[$name] = $process;
1✔
276
            }
277
        }
278

279
        return $failed;
1✔
280
    }
281

282
    /**
283
     * Get the command arguments for a process.
284
     *
285
     * @return array<string>
286
     */
287
    public function getProcessArgs(
288
        string $processName,
289
    ): array {
290
        return $this->processArgs[$processName] ?? [];
×
291
    }
292

293
    /**
294
     * Get the parent process for a given process.
295
     */
296
    public function getProcessParent(
297
        string $processName,
298
    ): ?string {
299
        return $this->processParents[$processName] ?? null;
×
300
    }
301

302
    /**
303
     * Get status of all tracked processes.
304
     *
305
     * @return array<string,array{running:bool,exit_code:int|null,pid:int|null}>
306
     */
307
    public function getProcessStatuses(): array
308
    {
309
        $statuses = [];
1✔
310

311
        foreach ($this->processes as $name => $process) {
1✔
312
            $statuses[$name] = [
1✔
313
                'running' => $process->isRunning(),
1✔
314
                'exit_code' => $process->getExitCode(),
1✔
315
                'pid' => $process->getPid(),
1✔
316
            ];
1✔
317
        }
318

319
        return $statuses;
1✔
320
    }
321

322
    /**
323
     * Get the root parent process by traversing up the hierarchy.
324
     */
325
    public function getRootParent(
326
        string $processName,
327
    ): ?string {
328
        $current = $processName;
×
329
        $visited = [];
×
330

331
        while (null !== $current && !isset($visited[$current])) {
×
332
            $visited[$current] = true;
×
333
            $parent = $this->processParents[$current] ?? null;
×
334

335
            if (null === $parent) {
×
336
                return $current; // This is the root
×
337
            }
338
            $current = $parent;
×
339
        }
340

341
        return null; // Circular reference detected
×
342
    }
343

344
    /**
345
     * Handle process failure by restarting the appropriate command.
346
     *
347
     * @param string $failedProcessName The name of the failed process
348
     *
349
     * @return int Command exit code
350
     */
351
    public function handleProcessFailure(
352
        string $failedProcessName,
353
    ): int {
354
        $this->recordFailure($failedProcessName);
×
355

356
        // Determine which process to restart (parent or self)
357
        $processToRestart = $this->getRootParent($failedProcessName) ?? $failedProcessName;
×
358

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

361
        return $this->restartProcess($processToRestart);
×
362
    }
363

364
    /**
365
     * Handle shutdown signals for graceful process termination.
366
     *
367
     * This method is called when SIGINT (Ctrl+C) or SIGTERM signals are received.
368
     * It coordinates the shutdown of all managed processes to ensure clean
369
     * termination and proper resource cleanup.
370
     *
371
     * @param int $signal The signal number (SIGINT or SIGTERM)
372
     *
373
     * @return void This method terminates the process
374
     */
375
    #[NoReturn]
376
    public function handleSignal(
377
        int $signal,
378
    ): void {
379
        // Prevent multiple shutdown attempts
380
        $this->shutdown = true;
×
381

382
        switch ($signal) {
383
            case SIGINT:
384
                // User pressed Ctrl+C - provide clear feedback
385
                if ($this->io) {
×
386
                    $this->io->newLine();
×
387
                    $this->io->warning('[INTERRUPT] Received Ctrl+C - shutting down gracefully...');
×
388
                }
389

390
                break;
×
391

392
            case SIGTERM:
393
                // System or process manager requested termination
394
                $this->io?->warning('[TERMINATE] Received termination signal - shutting down gracefully...');
×
395

396
                break;
×
397
        }
398

399
        // Terminate all managed processes before exiting
400
        $this->terminateAll();
×
401

402
        exit(0);
×
403
    }
404

405
    /**
406
     * Check if any process has failed.
407
     */
408
    public function hasFailedProcesses(): bool
409
    {
410
        foreach ($this->processes as $process) {
1✔
411
            if (!$process->isRunning() && !$process->isSuccessful()) {
1✔
412
                return true;
1✔
413
            }
414
        }
415

416
        return false;
×
417
    }
418

419
    /**
420
     * Check if any processes are being tracked.
421
     */
422
    public function hasProcesses(): bool
423
    {
424
        return !empty($this->processes);
2✔
425
    }
426

427
    /**
428
     * Check if shutdown has been initiated.
429
     */
430
    public function isShutdown(): bool
431
    {
432
        return $this->shutdown;
1✔
433
    }
434

435
    /**
436
     * Record a failure for a process with timestamp.
437
     */
438
    public function recordFailure(
439
        string $processName,
440
    ): void {
441
        $this->failureHistory[$processName][] = time();
×
442
        $this->io?->warning(sprintf('[FAILURE] Recorded failure for %s (total: %d)', $processName, count($this->failureHistory[$processName])));
×
443
    }
444

445
    /**
446
     * Remove a stopped process from tracking.
447
     */
448
    public function removeProcess(
449
        string $name,
450
    ): void {
451
        if (isset($this->processes[$name])) {
1✔
452
            unset($this->processes[$name], $this->processNames[$name]);
1✔
453

454
            $this->io?->text(sprintf('[CLEANUP] Removed %s from tracking', $name));
1✔
455
        }
456
    }
457

458
    /**
459
     * Restart a process using its stored arguments.
460
     *
461
     * @param string $processName The process to restart
462
     *
463
     * @return int Command exit code
464
     */
465
    public function restartProcess(
466
        string $processName,
467
    ): int {
468
        $args = $this->getProcessArgs($processName);
×
469

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

473
            return Command::FAILURE;
×
474
        }
475

476
        if (!$this->shouldRestartProcess($processName)) {
×
477
            $this->terminateAll();
×
478

479
            exit(Command::FAILURE);
×
480
        }
481

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

484
        // Terminate all current processes before restart
485
        $this->terminateAll();
×
486

487
        // Start the new process
488
        $process = new Process($args);
×
489
        $process->start();
×
490

491
        // Give it time to initialize
492
        usleep(500000);
×
493

494
        if ($process->isRunning()) {
×
495
            $this->io?->success(sprintf('[RESTART] Successfully restarted %s', $processName));
×
496

497
            return Command::SUCCESS;
×
498
        }
499
        $this->recordFailure($processName);
×
500
        $this->io?->error(sprintf('[RESTART] Failed to restart %s (exit code: %d)', $processName, $process->getExitCode()));
×
501

502
        // If restart failed, try again or give up
503
        return $this->restartProcess($processName);
×
504
    }
505

506
    /**
507
     * Store the original command arguments for a process.
508
     *
509
     * @param string        $processName The process name
510
     * @param array<string> $args        The command arguments
511
     */
512
    public function setProcessArgs(
513
        string $processName,
514
        array $args,
515
    ): void {
516
        $this->processArgs[$processName] = $args;
9✔
517
        $this->io?->text(sprintf('[ARGS] Stored arguments for %s: %s', $processName, implode(' ', $args)));
9✔
518
    }
519

520
    /**
521
     * Set the parent process for a given process.
522
     *
523
     * @param string      $processName The child process name
524
     * @param string|null $parentName  The parent process name, or null if it's a root process
525
     */
526
    public function setProcessParent(
527
        string $processName,
528
        ?string $parentName = null,
529
    ): void {
530
        $this->processParents[$processName] = $parentName;
9✔
531
        $this->io?->text(sprintf('[PARENT] Set %s as parent of %s', $parentName ?? 'root', $processName));
9✔
532
    }
533

534
    /**
535
     * Check if a process should be restarted based on failure history.
536
     */
537
    public function shouldRestartProcess(
538
        string $processName,
539
    ): bool {
540
        $now = time();
×
541
        $failures = $this->failureHistory[$processName] ?? [];
×
542

543
        // Remove old failures outside the window
544
        $recentFailures = array_filter($failures, fn ($timestamp) => $now - $timestamp <= self::FAILURE_WINDOW_SECONDS);
×
545

546
        // Update failure history with only recent failures
547
        $this->failureHistory[$processName] = $recentFailures;
×
548

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

553
            return false;
×
554
        }
555

556
        return true;
×
557
    }
558

559
    /**
560
     * Terminate all tracked processes using a graceful shutdown strategy.
561
     *
562
     * This method implements a two-phase termination approach:
563
     * 1. Graceful termination (SIGTERM) with 2-second timeout
564
     * 2. Force kill (SIGKILL) for stubborn processes
565
     *
566
     * This ensures that processes have time to clean up resources and
567
     * complete any in-progress operations before being forcefully stopped.
568
     */
569
    public function terminateAll(): void
570
    {
571
        $this->io?->text('[SHUTDOWN] Terminating all background processes...');
1✔
572

573
        // Phase 1: Graceful termination using SIGTERM (signal 15)
574
        // This allows processes to clean up and exit cleanly
575
        foreach ($this->processes as $name => $process) {
1✔
576
            if ($process->isRunning()) {
1✔
577
                $this->io?->text(sprintf('[STOPPING] Terminating %s process (PID: %d)', $name, $process->getPid()));
×
578
                $process->stop(2); // Send SIGTERM with 2-second timeout for graceful shutdown
×
579
            }
580
        }
581

582
        // Phase 2: Force kill any remaining processes using SIGKILL (signal 9)
583
        // This immediately terminates processes that didn't respond to SIGTERM
584
        foreach ($this->processes as $name => $process) {
1✔
585
            if ($process->isRunning()) {
1✔
586
                $this->io?->warning(sprintf('[FORCE-KILL] Forcefully killing %s process', $name));
×
587
                $process->signal(9); // SIGKILL - cannot be caught or ignored
×
588
            }
589
        }
590

591
        $this->io?->success('[SHUTDOWN] All processes terminated');
1✔
592
    }
593
}
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