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

valksor / php-dev-build / 19113736544

05 Nov 2025 11:28AM UTC coverage: 18.191% (+0.06%) from 18.133%
19113736544

push

github

k0d3r1s
code cleanup

5 of 27 new or added lines in 7 files covered. (18.52%)

1 existing line in 1 file now uncovered.

372 of 2045 relevant lines covered (18.19%)

0.97 hits per line

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

18.24
/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

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

38
use const SIGINT;
39
use const SIGTERM;
40

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

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

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

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

91
    public function __construct(
92
        private readonly ParameterBagInterface $parameterBag,
93
        private readonly ProviderRegistry $providerRegistry,
94
    ) {
95
    }
16✔
96

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

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

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

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

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

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

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

163
                exit(0);
×
164
            });
4✔
165
            pcntl_signal(SIGTERM, function (): void {
4✔
166
                $this->io?->warning('[TERMINATE] Received termination signal - shutting down gracefully...');
×
167
                $this->processManager->terminateAll();
×
168
                $this->running = false;
×
169

170
                exit(0);
×
171
            });
4✔
172
        }
173

174
        // Run initialization phase (binary downloads, dependency setup)
175
        // This ensures all required tools and dependencies are available
176
        $this->runInit();
4✔
177

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

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

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

189
        foreach ($devProviders as $name => $provider) {
4✔
190
            $config = $servicesConfig[$name] ?? [];
×
191
            $providerClass = $config['provider'] ?? $name;
×
192

193
            // Only include lightweight providers (hot_reload service)
194
            // Excludes Tailwind CSS compilation and Importmap processing
NEW
195
            if ('hot_reload' === $providerClass) {
×
196
                $lightweightProviders[$name] = $provider;
×
197
            }
198
        }
199

200
        if (empty($lightweightProviders)) {
4✔
201
            if ($this->isInteractive && $this->io) {
4✔
202
                $this->io->warning('No lightweight dev services are enabled in configuration.');
3✔
203
            }
204

205
            return Command::SUCCESS;
4✔
206
        }
207

208
        // Validate all configured providers exist
209
        $missingProviders = $this->providerRegistry->validateProviders($servicesConfig);
×
210

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

214
            return Command::FAILURE;
×
215
        }
216

217
        // Start SSE first before any providers (required for hot reload communication)
218
        if ($this->isInteractive && $this->io) {
×
219
            $this->io->text('Starting SSE server...');
×
220
        }
221
        $sseResult = $this->runSseCommand();
×
222

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

226
            return Command::FAILURE;
×
227
        }
228

229
        if ($this->isInteractive && $this->io) {
×
230
            $this->io->success('✓ SSE server started and running');
×
231
            $this->io->newLine();
×
232
            $this->io->text(sprintf('Running %d lightweight dev service(s)...', count($lightweightProviders)));
×
233
            $this->io->newLine();
×
234
        }
235

236
        $runningServices = [];
×
237

238
        foreach ($lightweightProviders as $name => $provider) {
×
239
            if ($this->isInteractive && $this->io) {
×
240
                $this->io->section(sprintf('Starting %s', ucfirst($name)));
×
241
            }
242

243
            $config = $servicesConfig[$name] ?? [];
×
244
            $options = $config['options'] ?? [];
×
245
            // Pass interactive mode to providers
246
            $options['interactive'] = $this->isInteractive;
×
247

248
            // Set IO on providers that support it
249
            $this->setProviderIo($provider, $this->io);
×
250

251
            try {
252
                if ($this->isInteractive && $this->io) {
×
253
                    $this->io->text(sprintf('[INITIALIZING] %s service...', ucfirst($name)));
×
254
                }
255

256
                // Start the provider service and get the process
257
                $process = $this->startProviderProcess($name, $provider, $options);
×
258

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

262
                    return Command::FAILURE;
×
263
                }
264

265
                // Track the process in our manager
266
                $this->processManager->addProcess($name, $process);
×
267
                $runningServices[] = $name;
×
268

269
                if ($this->isInteractive && $this->io) {
×
270
                    $this->io->success(sprintf('[READY] %s service started successfully', ucfirst($name)));
×
271
                }
272
            } catch (Exception $e) {
×
273
                $this->io?->error(sprintf('Service "%s" failed: %s', $name, $e->getMessage()));
×
274

275
                return Command::FAILURE;
×
276
            }
277
        }
278

279
        if ($this->isInteractive && $this->io) {
×
280
            $this->io->newLine();
×
281
            $this->io->success(sprintf('✓ All %d services running successfully!', count($runningServices)));
×
282
            $this->io->text('Press Ctrl+C to stop all services');
×
283
            $this->io->newLine();
×
284

285
            // Show initial service status
286
            $this->processManager->displayStatus();
×
287
        }
288

289
        // Start the monitoring loop - this keeps the service alive
290
        $this->running = true;
×
291

292
        return $this->monitorServices();
×
293
    }
294

295
    public function stop(): void
296
    {
297
        $this->running = false;
1✔
298

299
        $this->processManager?->terminateAll();
1✔
300
    }
301

302
    public static function getServiceName(): string
303
    {
304
        return 'dev';
1✔
305
    }
306

307
    /**
308
     * Monitor running services and keep the service alive.
309
     */
310
    private function monitorServices(): int
311
    {
312
        if ($this->isInteractive && $this->io) {
×
313
            $this->io->text('[MONITOR] Starting service monitoring loop...');
×
314
        }
315

316
        $checkInterval = 5; // Check every 5 seconds
×
317
        $lastStatusTime = 0;
×
318
        $statusDisplayInterval = 30; // Show status every 30 seconds
×
319

320
        while ($this->running) {
×
321
            // Check if all processes are still running
322
            if ($this->processManager && !$this->processManager->allProcessesRunning()) {
×
323
                $failedProcesses = $this->processManager->getFailedProcesses();
×
324

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

329
                        // Show error output if available
330
                        $errorOutput = $process->getErrorOutput();
×
331

332
                        if (!empty($errorOutput)) {
×
333
                            $this->io->text(sprintf('Error output: %s', trim($errorOutput)));
×
334
                        }
335
                    }
336

337
                    $this->io->warning('[MONITOR] Some services have failed. Press Ctrl+C to exit or continue monitoring...');
×
338
                }
339

340
                // Remove failed processes from tracking
341
                foreach (array_keys($failedProcesses) as $name) {
×
342
                    $this->processManager->removeProcess($name);
×
343
                }
344
            }
345

346
            // Periodic status display
347
            $currentTime = time();
×
348

349
            if ($this->isInteractive && $this->io && ($currentTime - $lastStatusTime) >= $statusDisplayInterval) {
×
350
                $this->io->newLine();
×
351
                $this->io->text(sprintf(
×
352
                    '[STATUS] %s - Monitoring %d active services',
×
353
                    date('H:i:s'),
×
354
                    $this->processManager ? $this->processManager->count() : 0,
×
355
                ));
×
356

357
                if ($this->processManager && $this->processManager->hasProcesses()) {
×
358
                    $this->processManager->displayStatus();
×
359
                }
360

361
                $lastStatusTime = $currentTime;
×
362
            }
363

364
            // Sleep for the check interval
365
            sleep($checkInterval);
×
366
        }
367

368
        return Command::SUCCESS;
×
369
    }
370

371
    /**
372
     * Run init phase - always runs first for all commands.
373
     */
374
    private function runInit(): void
375
    {
376
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
4✔
377
        $initProviders = $this->providerRegistry->getProvidersByFlag($servicesConfig, 'init');
4✔
378

379
        if (empty($initProviders)) {
4✔
380
            return;
4✔
381
        }
382

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

385
        // Binaries always run first
386
        if (isset($initProviders['binaries'])) {
×
387
            $this->io?->text('Ensuring binaries are available...');
×
388
            $this->runProvider('binaries', $initProviders['binaries'], []);
×
389
            unset($initProviders['binaries']);
×
390
        }
391

392
        // Run remaining init providers
393
        foreach ($initProviders as $name => $provider) {
×
394
            $config = $servicesConfig[$name] ?? [];
×
395
            $options = $config['options'] ?? [];
×
396
            $this->runProvider($name, $provider, $options);
×
397
        }
398

399
        $this->io?->success('Initialization completed');
×
400
    }
401

402
    /**
403
     * Run a single provider with error handling.
404
     */
405
    private function runProvider(
406
        string $name,
407
        object $provider,
408
        array $options,
409
    ): void {
410
        try {
411
            $provider->init($options);
×
412
        } catch (Exception $e) {
×
413
            // In development, warn but continue; in production, fail
414
            if ('prod' === ($_ENV['APP_ENV'] ?? 'dev')) {
×
415
                throw new RuntimeException("Provider '$name' failed: " . $e->getMessage(), 0, $e);
×
416
            }
417
            // Warning - continue but this could be problematic in non-interactive mode
418
            // TODO: Consider passing SymfonyStyle instance for proper warning display
419
        }
420
    }
421

422
    /**
423
     * Get SSE command for integration.
424
     */
425
    private function runSseCommand(): int
426
    {
427
        $process = new Process(['php', 'bin/console', 'valksor:sse']);
×
428

429
        // Start SSE server in background (non-blocking)
430
        $process->start();
×
431

432
        // Give SSE server more time to start and bind to port
433
        $maxWaitTime = 3; // 3 seconds max wait time
×
434
        $waitInterval = 250000; // 250ms intervals
×
435
        $elapsedTime = 0;
×
436

437
        while ($elapsedTime < $maxWaitTime) {
×
438
            usleep($waitInterval);
×
439
            $elapsedTime += ($waitInterval / 1000000);
×
440

441
            // Check if process is still running and hasn't failed
442
            if (!$process->isRunning()) {
×
443
                // Process stopped - check if it was successful
444
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
445
            }
446

447
            // After 1 second, check if we can verify the server is actually stable
448
            if ($elapsedTime >= 1.0) {
×
449
                // The SSE server should be stable by now, proceed
450
                break;
×
451
            }
452
        }
453

454
        // Final check - is the process still running successfully?
455
        return $process->isRunning() ? Command::SUCCESS : Command::FAILURE;
×
456
    }
457

458
    /**
459
     * Set SymfonyStyle on provider objects that support it.
460
     */
461
    private function setProviderIo(
462
        object $provider,
463
        SymfonyStyle $io,
464
    ): void {
465
        if ($provider instanceof IoAwareInterface) {
×
466
            $provider->setIo($io);
×
467
        }
468
    }
469

470
    /**
471
     * Start a lightweight provider process for background execution.
472
     *
473
     * This method creates and starts processes for the limited set of services
474
     * supported by the lightweight development mode. Unlike DevWatchService,
475
     * this only supports the hot_reload service since we intentionally exclude
476
     * heavy build processes.
477
     *
478
     * Supported Services in Lightweight Mode:
479
     * - hot_reload: File watching and browser refresh functionality
480
     *
481
     * Excluded Services (handled by DevWatchService):
482
     * - tailwind: CSS compilation and processing
483
     * - importmap: JavaScript module processing
484
     *
485
     * The process startup includes verification to ensure the service
486
     * started successfully before adding it to the process manager.
487
     *
488
     * @param string $name     Service name (e.g., 'hot_reload')
489
     * @param object $provider Service provider instance
490
     * @param array  $options  Service-specific configuration options
491
     *
492
     * @return Process|null Process object for tracking, or null if startup failed
493
     */
494
    private function startProviderProcess(
495
        string $name,
496
        object $provider,
497
        array $options,
498
    ): ?Process {
499
        // Command mapping for lightweight services only
500
        // Intentionally excludes heavy build services (tailwind, importmap)
501
        $command = match ($name) {
×
502
            'hot_reload' => ['php', 'bin/console', 'valksor:hot-reload'],
×
503
            default => null, // Unsupported service in lightweight mode
×
504
        };
×
505

506
        if (null === $command) {
×
507
            return null;
×
508
        }
509

510
        // Create and start the process for background execution
511
        $process = new Process($command);
×
512
        $process->start();
×
513

514
        // Allow time for process initialization and startup verification
515
        // 500ms provides sufficient time for the hot reload service to initialize
516
        usleep(500000); // 500ms
×
517

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

522
            return null;
×
523
        }
524

525
        return $process;
×
526
    }
527
}
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