• 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

17.92
/Service/DevService.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 Exception;
16
use RuntimeException;
17
use Symfony\Component\Console\Command\Command;
18
use Symfony\Component\Console\Style\SymfonyStyle;
19
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
20
use Symfony\Component\Process\Process;
21
use ValksorDev\Build\Provider\IoAwareInterface;
22
use ValksorDev\Build\Provider\ProviderRegistry;
23
use ValksorDev\Build\Util\ConsoleCommandBuilder;
24

25
use function array_keys;
26
use function count;
27
use function date;
28
use function function_exists;
29
use function implode;
30
use function pcntl_async_signals;
31
use function pcntl_signal;
32
use function sleep;
33
use function sprintf;
34
use function time;
35
use function trim;
36
use function ucfirst;
37
use function usleep;
38

39
use const SIGINT;
40
use const SIGTERM;
41

42
/**
43
 * Lightweight development service for minimal development environment.
44
 *
45
 * This service provides a streamlined development experience by running only
46
 * the essential services needed for hot reload functionality, without the
47
 * overhead of full build processes (Tailwind compilation, Importmap processing).
48
 *
49
 * Lightweight Service Strategy:
50
 * - Runs only SSE server and hot reload service
51
 * - Excludes heavy build processes (Tailwind, Importmap) for faster startup
52
 * - Perfect for quick development sessions or lightweight projects
53
 * - Provides instant file watching and browser refresh capabilities
54
 *
55
 * Key Differences from DevWatchService:
56
 * - Faster startup time (fewer services to initialize)
57
 * - Lower resource usage (no compilation processes)
58
 * - Simplified monitoring (fewer processes to track)
59
 * - Focused on hot reload functionality only
60
 * - Assumes pre-compiled assets are already available
61
 *
62
 * Signal Handling:
63
 * - Registers graceful shutdown handlers for SIGINT (Ctrl+C) and SIGTERM
64
 * - Ensures clean process termination and resource cleanup
65
 */
66
final class DevService
67
{
68
    /**
69
     * Symfony console output interface for user interaction and status reporting.
70
     * Provides rich console output with sections, progress indicators, and formatted text.
71
     */
72
    private ?SymfonyStyle $io = null;
73

74
    /**
75
     * Flag indicating whether the service should provide interactive console output.
76
     * When false, runs silently in the background for automated/CI environments.
77
     */
78
    private bool $isInteractive = true;
79

80
    /**
81
     * Process manager for tracking and coordinating lightweight development services.
82
     * Handles startup, monitoring, and graceful shutdown of SSE and hot reload processes.
83
     */
84
    private ?ProcessManager $processManager = null;
85

86
    /**
87
     * Runtime flag indicating the service is active and should continue monitoring.
88
     * Set to false during shutdown to signal the monitoring loop to exit gracefully.
89
     */
90
    private bool $running = false;
91

92
    public function __construct(
93
        private readonly ParameterBagInterface $parameterBag,
94
        private readonly ProviderRegistry $providerRegistry,
95
        private readonly ?ConsoleCommandBuilder $commandBuilder = null,
96
    ) {
97
    }
16✔
98

99
    public function getParameterBag(): ParameterBagInterface
100
    {
101
        return $this->parameterBag;
10✔
102
    }
103

104
    public function getProviderRegistry(): ProviderRegistry
105
    {
106
        return $this->providerRegistry;
9✔
107
    }
108

109
    public function setInteractive(
110
        bool $isInteractive,
111
    ): void {
112
        $this->isInteractive = $isInteractive;
1✔
113
    }
114

115
    public function setIo(
116
        SymfonyStyle $io,
117
    ): void {
118
        $this->io = $io;
4✔
119
    }
120

121
    /**
122
     * Start the lightweight development service.
123
     *
124
     * This method implements a streamlined startup sequence that focuses on
125
     * essential services only, providing faster startup and lower resource usage:
126
     *
127
     * Startup Sequence:
128
     * 1. Process manager initialization and signal handler registration
129
     * 2. Initialization phase (binary downloads, dependency setup)
130
     * 3. Provider discovery and lightweight service filtering
131
     * 4. SSE server startup for hot reload communication
132
     * 5. Hot reload service startup
133
     * 6. Continuous monitoring loop
134
     *
135
     * Lightweight Filtering Logic:
136
     * - Discovers all dev-flagged services
137
     * - Filters to include only 'hot_reload' provider
138
     * - Excludes heavy build services (Tailwind, Importmap)
139
     * - Ensures fast startup and minimal resource consumption
140
     *
141
     * This approach is ideal for quick development sessions where build
142
     * processes can be run separately or aren't needed.
143
     *
144
     * @return int Command exit code (Command::SUCCESS or Command::FAILURE)
145
     */
146
    public function start(): int
147
    {
148
        $this->io?->title('Development Mode');
4✔
149

150
        // Initialize process manager for tracking background services
151
        $this->processManager = new ProcessManager($this->io);
4✔
152

153
        // Register this dev service as root parent for restart tracking
154
        $this->processManager->setProcessParent('dev', null); // dev is root
4✔
155
        $this->processManager->setProcessArgs('dev', ['php', 'bin/console', 'valksor:dev']);
4✔
156

157
        // Register signal handlers for graceful shutdown (SIGINT, SIGTERM)
158
        // This ensures clean process termination when user presses Ctrl+C
159
        if (function_exists('pcntl_signal')) {
4✔
160
            pcntl_async_signals(true);
4✔
161
            pcntl_signal(SIGINT, function (): void {
4✔
162
                if ($this->io) {
×
163
                    $this->io->newLine();
×
164
                    $this->io->warning('[INTERRUPT] Received Ctrl+C - shutting down gracefully...');
×
165
                }
166
                $this->processManager->terminateAll();
×
167
                $this->running = false;
×
168

169
                exit(0);
×
170
            });
4✔
171
            pcntl_signal(SIGTERM, function (): void {
4✔
172
                $this->io?->warning('[TERMINATE] Received termination signal - shutting down gracefully...');
×
173
                $this->processManager->terminateAll();
×
174
                $this->running = false;
×
175

176
                exit(0);
×
177
            });
4✔
178
        }
179

180
        // Run initialization phase (binary downloads, dependency setup)
181
        // This ensures all required tools and dependencies are available
182
        $this->runInit();
4✔
183

184
        // Get services configuration from ParameterBag
185
        // Contains service definitions, flags, and options for all build services
186
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
4✔
187

188
        // Get all dev services (dev=true) with dependency resolution
189
        $devProviders = $this->providerRegistry->getProvidersByFlag($servicesConfig, 'dev');
4✔
190

191
        // Filter for lightweight services only (exclude heavy build processes)
192
        // This is the key difference from DevWatchService - we only want hot reload
193
        $lightweightProviders = [];
4✔
194

195
        foreach ($devProviders as $name => $provider) {
4✔
196
            $config = $servicesConfig[$name] ?? [];
×
197
            $providerClass = $config['provider'] ?? $name;
×
198

199
            // Only include lightweight providers (hot_reload service)
200
            // Excludes Tailwind CSS compilation and Importmap processing
201
            if ('hot_reload' === $providerClass) {
×
202
                $lightweightProviders[$name] = $provider;
×
203
            }
204
        }
205

206
        if (empty($lightweightProviders)) {
4✔
207
            if ($this->isInteractive && $this->io) {
4✔
208
                $this->io->warning('No lightweight dev services are enabled in configuration.');
3✔
209
            }
210

211
            return Command::SUCCESS;
4✔
212
        }
213

214
        // Validate all configured providers exist
215
        $missingProviders = $this->providerRegistry->validateProviders($servicesConfig);
×
216

217
        if (!empty($missingProviders)) {
×
218
            $this->io?->error(sprintf('Missing providers for: %s', implode(', ', $missingProviders)));
×
219

220
            return Command::FAILURE;
×
221
        }
222

223
        // Start SSE first before any providers (required for hot reload communication)
224
        if ($this->isInteractive && $this->io) {
×
225
            $this->io->text('Starting SSE server...');
×
226
        }
227
        $sseResult = $this->runSseCommand();
×
228

229
        if (Command::SUCCESS !== $sseResult) {
×
230
            $this->io?->error('✗ SSE server failed to start');
×
231

232
            return Command::FAILURE;
×
233
        }
234

235
        // Register SSE as child of dev service
236
        $this->processManager->setProcessParent('sse', 'dev');
×
237
        $this->processManager->setProcessArgs('sse', ['php', 'bin/console', 'valksor:sse']);
×
238

239
        if ($this->isInteractive && $this->io) {
×
240
            $this->io->success('✓ SSE server started and running');
×
241
            $this->io->newLine();
×
242
            $this->io->text(sprintf('Running %d lightweight dev service(s)...', count($lightweightProviders)));
×
243
            $this->io->newLine();
×
244
        }
245

246
        $runningServices = [];
×
247

248
        foreach ($lightweightProviders as $name => $provider) {
×
249
            if ($this->isInteractive && $this->io) {
×
250
                $this->io->section(sprintf('Starting %s', ucfirst($name)));
×
251
            }
252

253
            $config = $servicesConfig[$name] ?? [];
×
254
            $options = $config['options'] ?? [];
×
255
            // Pass interactive mode to providers
256
            $options['interactive'] = $this->isInteractive;
×
257

258
            // Set IO on providers that support it
259
            $this->setProviderIo($provider, $this->io);
×
260

261
            try {
262
                if ($this->isInteractive && $this->io) {
×
263
                    $this->io->text(sprintf('[INITIALIZING] %s service...', ucfirst($name)));
×
264
                }
265

266
                // Start the provider service and get the process
267
                $process = $this->startProviderProcess($name, $provider, $options);
×
268

269
                if (null === $process) {
×
270
                    $this->io?->error(sprintf('Failed to start %s service', $name));
×
271

272
                    return Command::FAILURE;
×
273
                }
274

275
                // Track the process in our manager
276
                $this->processManager->addProcess($name, $process);
×
277
                $this->processManager->setProcessParent($name, 'dev'); // Set dev as parent
×
278
                $this->processManager->setProcessArgs($name, ['php', 'bin/console', 'valksor:hot-reload']);
×
279
                $runningServices[] = $name;
×
280

281
                if ($this->isInteractive && $this->io) {
×
282
                    $this->io->success(sprintf('[READY] %s service started successfully', ucfirst($name)));
×
283
                }
284
            } catch (Exception $e) {
×
285
                $this->io?->error(sprintf('Service "%s" failed: %s', $name, $e->getMessage()));
×
286

287
                return Command::FAILURE;
×
288
            }
289
        }
290

291
        if ($this->isInteractive && $this->io) {
×
292
            $this->io->newLine();
×
293
            $this->io->success(sprintf('✓ All %d services running successfully!', count($runningServices)));
×
294
            $this->io->text('Press Ctrl+C to stop all services');
×
295
            $this->io->newLine();
×
296

297
            // Show initial service status
298
            $this->processManager->displayStatus();
×
299
        }
300

301
        // Start the monitoring loop - this keeps the service alive
302
        $this->running = true;
×
303

304
        return $this->monitorServices();
×
305
    }
306

307
    public function stop(): void
308
    {
309
        $this->running = false;
1✔
310

311
        $this->processManager?->terminateAll();
1✔
312
    }
313

314
    public static function getServiceName(): string
315
    {
316
        return 'dev';
1✔
317
    }
318

319
    /**
320
     * Monitor running services and keep the service alive.
321
     */
322
    private function monitorServices(): int
323
    {
324
        if ($this->isInteractive && $this->io) {
×
325
            $this->io->text('[MONITOR] Starting service monitoring loop...');
×
326
        }
327

328
        $checkInterval = 5; // Check every 5 seconds
×
329
        $lastStatusTime = 0;
×
330
        $statusDisplayInterval = 30; // Show status every 30 seconds
×
331

332
        while ($this->running) {
×
333
            // Check if all processes are still running
334
            if ($this->processManager && !$this->processManager->allProcessesRunning()) {
×
335
                $failedProcesses = $this->processManager->getFailedProcesses();
×
336

337
                if ($this->isInteractive && $this->io) {
×
338
                    foreach ($failedProcesses as $name => $process) {
×
339
                        $this->io->error(sprintf('[FAILED] Service %s has stopped (exit code: %d)', $name, $process->getExitCode()));
×
340

341
                        // Show error output if available
342
                        $errorOutput = $process->getErrorOutput();
×
343

344
                        if (!empty($errorOutput)) {
×
345
                            $this->io->text(sprintf('Error output: %s', trim($errorOutput)));
×
346
                        }
347
                    }
348
                }
349

350
                // Handle restart for failed processes
351
                foreach (array_keys($failedProcesses) as $name) {
×
352
                    $restartResult = $this->processManager->handleProcessFailure($name);
×
353

354
                    if (Command::SUCCESS === $restartResult) {
×
355
                        // Restart was successful, exit and let restarted process take over
356
                        $this->io?->success('[RESTART] Parent process restarted successfully, exiting current process');
×
357

358
                        return Command::SUCCESS;
×
359
                    }
360
                    // Restart failed or gave up, remove from tracking and continue
361
                    $this->processManager->removeProcess($name);
×
362
                    $this->io?->warning('[RESTART] Restart failed, removing service from tracking');
×
363
                }
364

365
                if ($this->isInteractive && $this->io) {
×
366
                    $this->io->warning('[MONITOR] Some services have failed and could not be restarted. Press Ctrl+C to exit or continue monitoring...');
×
367
                }
368
            }
369

370
            // Periodic status display
371
            $currentTime = time();
×
372

373
            if ($this->isInteractive && $this->io && ($currentTime - $lastStatusTime) >= $statusDisplayInterval) {
×
374
                $this->io->newLine();
×
375
                $this->io->text(sprintf(
×
376
                    '[STATUS] %s - Monitoring %d active services',
×
377
                    date('H:i:s'),
×
378
                    $this->processManager ? $this->processManager->count() : 0,
×
379
                ));
×
380

381
                if ($this->processManager && $this->processManager->hasProcesses()) {
×
382
                    $this->processManager->displayStatus();
×
383
                }
384

385
                $lastStatusTime = $currentTime;
×
386
            }
387

388
            // Sleep for the check interval
389
            sleep($checkInterval);
×
390
        }
391

392
        return Command::SUCCESS;
×
393
    }
394

395
    /**
396
     * Run init phase - always runs first for all commands.
397
     */
398
    private function runInit(): void
399
    {
400
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
4✔
401
        $initProviders = $this->providerRegistry->getProvidersByFlag($servicesConfig, 'init');
4✔
402

403
        if (empty($initProviders)) {
4✔
404
            return;
4✔
405
        }
406

407
        $this->io?->section('Running initialization tasks...');
×
408

409
        // Binaries always run first
410
        if (isset($initProviders['binaries'])) {
×
411
            $this->io?->text('Ensuring binaries are available...');
×
412
            $this->runProvider('binaries', $initProviders['binaries'], []);
×
413
            unset($initProviders['binaries']);
×
414
        }
415

416
        // Run remaining init providers
417
        foreach ($initProviders as $name => $provider) {
×
418
            $config = $servicesConfig[$name] ?? [];
×
419
            $options = $config['options'] ?? [];
×
420
            $this->runProvider($name, $provider, $options);
×
421
        }
422

423
        $this->io?->success('Initialization completed');
×
424
    }
425

426
    /**
427
     * Run a single provider with error handling.
428
     */
429
    private function runProvider(
430
        string $name,
431
        object $provider,
432
        array $options,
433
    ): void {
434
        try {
435
            $provider->init($options);
×
436
        } catch (Exception $e) {
×
437
            // In development, warn but continue; in production, fail
438
            if ('prod' === ($_ENV['APP_ENV'] ?? 'dev')) {
×
439
                throw new RuntimeException("Provider '$name' failed: " . $e->getMessage(), 0, $e);
×
440
            }
441
            // Warning - continue but this could be problematic in non-interactive mode
442
            // TODO: Consider passing SymfonyStyle instance for proper warning display
443
        }
444
    }
445

446
    /**
447
     * Get SSE command for integration.
448
     */
449
    private function runSseCommand(): int
450
    {
451
        // Use ConsoleCommandBuilder if available, otherwise fall back to manual construction
452
        if ($this->commandBuilder) {
×
453
            $process = $this->commandBuilder->build('valksor:sse');
×
454
        } else {
455
            $process = new Process(['php', 'bin/console', 'valksor:sse']);
×
456
        }
457

458
        // Start SSE server in background (non-blocking)
459
        $process->start();
×
460

461
        // Give SSE server more time to start and bind to port
462
        $maxWaitTime = 3; // 3 seconds max wait time
×
463
        $waitInterval = 250000; // 250ms intervals
×
464
        $elapsedTime = 0;
×
465

466
        while ($elapsedTime < $maxWaitTime) {
×
467
            usleep($waitInterval);
×
468
            $elapsedTime += ($waitInterval / 1000000);
×
469

470
            // Check if process is still running and hasn't failed
471
            if (!$process->isRunning()) {
×
472
                // Process stopped - check if it was successful
473
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
474
            }
475

476
            // After 1 second, check if we can verify the server is actually stable
477
            if ($elapsedTime >= 1.0) {
×
478
                // The SSE server should be stable by now, proceed
479
                break;
×
480
            }
481
        }
482

483
        // Final check - is the process still running successfully?
484
        return $process->isRunning() ? Command::SUCCESS : Command::FAILURE;
×
485
    }
486

487
    /**
488
     * Set SymfonyStyle on provider objects that support it.
489
     */
490
    private function setProviderIo(
491
        object $provider,
492
        SymfonyStyle $io,
493
    ): void {
494
        if ($provider instanceof IoAwareInterface) {
×
495
            $provider->setIo($io);
×
496
        }
497
    }
498

499
    /**
500
     * Start a lightweight provider process for background execution.
501
     *
502
     * This method creates and starts processes for the limited set of services
503
     * supported by the lightweight development mode. Unlike DevWatchService,
504
     * this only supports the hot_reload service since we intentionally exclude
505
     * heavy build processes.
506
     *
507
     * Supported Services in Lightweight Mode:
508
     * - hot_reload: File watching and browser refresh functionality
509
     *
510
     * Excluded Services (handled by DevWatchService):
511
     * - tailwind: CSS compilation and processing
512
     * - importmap: JavaScript module processing
513
     *
514
     * The process startup includes verification to ensure the service
515
     * started successfully before adding it to the process manager.
516
     *
517
     * @param string $name     Service name (e.g., 'hot_reload')
518
     * @param object $provider Service provider instance
519
     * @param array  $options  Service-specific configuration options
520
     *
521
     * @return Process|null Process object for tracking, or null if startup failed
522
     */
523
    private function startProviderProcess(
524
        string $name,
525
        object $provider,
526
        array $options,
527
    ): ?Process {
528
        // Command mapping for lightweight services only
529
        // Intentionally excludes heavy build services (tailwind, importmap)
530
        $command = match ($name) {
×
531
            'hot_reload' => ['php', 'bin/console', 'valksor:hot-reload'],
×
532
            default => null, // Unsupported service in lightweight mode
×
533
        };
×
534

535
        if (null === $command) {
×
536
            return null;
×
537
        }
538

539
        // Create and start the process for background execution
540
        $process = new Process($command);
×
541
        $process->start();
×
542

543
        // Allow time for process initialization and startup verification
544
        // 500ms provides sufficient time for the hot reload service to initialize
545
        usleep(500000); // 500ms
×
546

547
        // Verify that the process started successfully and is running
548
        if (!$process->isRunning()) {
×
549
            $this->io?->error(sprintf('Process %s failed to start (exit code: %d)', $name, $process->getExitCode()));
×
550

551
            return null;
×
552
        }
553

554
        return $process;
×
555
    }
556
}
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