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

valksor / php-dev-build / 19634179404

24 Nov 2025 12:21PM UTC coverage: 27.943% (+8.2%) from 19.747%
19634179404

push

github

k0d3r1s
add valksor-dev snapshot

1 of 13 new or added lines in 2 files covered. (7.69%)

101 existing lines in 4 files now uncovered.

667 of 2387 relevant lines covered (27.94%)

1.08 hits per line

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

18.39
/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;
11✔
102
    }
103

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

109
    public function isRunning(): bool
110
    {
111
        return $this->running;
1✔
112
    }
113

114
    public function setInteractive(
115
        bool $isInteractive,
116
    ): void {
117
        $this->isInteractive = $isInteractive;
1✔
118
    }
119

120
    public function setIo(
121
        SymfonyStyle $io,
122
    ): void {
123
        $this->io = $io;
4✔
124
    }
125

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

155
        // Initialize process manager for tracking background services
156
        $this->processManager = new ProcessManager($this->io);
4✔
157

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

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

174
                exit(0);
×
175
            });
4✔
176
            pcntl_signal(SIGTERM, function (): void {
4✔
UNCOV
177
                $this->io?->warning('[TERMINATE] Received termination signal - shutting down gracefully...');
×
UNCOV
178
                $this->processManager->terminateAll();
×
UNCOV
179
                $this->running = false;
×
180

UNCOV
181
                exit(0);
×
182
            });
4✔
183
        }
184

185
        // Run initialization phase (binary downloads, dependency setup)
186
        // This ensures all required tools and dependencies are available
187
        $this->runInit();
4✔
188

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

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

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

200
        foreach ($devProviders as $name => $provider) {
4✔
201
            $config = $servicesConfig[$name] ?? [];
×
202
            $providerClass = $config['provider'] ?? $name;
×
203

204
            // Only include lightweight providers (hot_reload service)
205
            // Excludes Tailwind CSS compilation and Importmap processing
UNCOV
206
            if ('hot_reload' === $providerClass) {
×
UNCOV
207
                $lightweightProviders[$name] = $provider;
×
208
            }
209
        }
210

211
        if (empty($lightweightProviders)) {
4✔
212
            if ($this->isInteractive && $this->io) {
4✔
213
                $this->io->warning('No lightweight dev services are enabled in configuration.');
3✔
214
            }
215

216
            return Command::SUCCESS;
4✔
217
        }
218

219
        // Validate all configured providers exist
220
        $missingProviders = $this->providerRegistry->validateProviders($servicesConfig);
×
221

UNCOV
222
        if (!empty($missingProviders)) {
×
UNCOV
223
            $this->io?->error(sprintf('Missing providers for: %s', implode(', ', $missingProviders)));
×
224

225
            return Command::FAILURE;
×
226
        }
227

228
        // Start SSE first before any providers (required for hot reload communication)
229
        if ($this->isInteractive && $this->io) {
×
230
            $this->io->text('Starting SSE server...');
×
231
        }
232
        $sseResult = $this->runSseCommand();
×
233

UNCOV
234
        if (Command::SUCCESS !== $sseResult) {
×
UNCOV
235
            $this->io?->error('✗ SSE server failed to start');
×
236

237
            return Command::FAILURE;
×
238
        }
239

240
        // Register SSE as child of dev service
241
        $this->processManager->setProcessParent('sse', 'dev');
×
242
        $this->processManager->setProcessArgs('sse', ['php', 'bin/console', 'valksor:sse']);
×
243

UNCOV
244
        if ($this->isInteractive && $this->io) {
×
UNCOV
245
            $this->io->success('✓ SSE server started and running');
×
246
            $this->io->newLine();
×
UNCOV
247
            $this->io->text(sprintf('Running %d lightweight dev service(s)...', count($lightweightProviders)));
×
248
            $this->io->newLine();
×
249
        }
250

UNCOV
251
        $runningServices = [];
×
252

253
        foreach ($lightweightProviders as $name => $provider) {
×
254
            if ($this->isInteractive && $this->io) {
×
UNCOV
255
                $this->io->section(sprintf('Starting %s', ucfirst($name)));
×
256
            }
257

UNCOV
258
            $config = $servicesConfig[$name] ?? [];
×
259
            $options = $config['options'] ?? [];
×
260
            // Pass interactive mode to providers
UNCOV
261
            $options['interactive'] = $this->isInteractive;
×
262

263
            // Set IO on providers that support it
UNCOV
264
            $this->setProviderIo($provider, $this->io);
×
265

266
            try {
267
                if ($this->isInteractive && $this->io) {
×
UNCOV
268
                    $this->io->text(sprintf('[INITIALIZING] %s service...', ucfirst($name)));
×
269
                }
270

271
                // Start the provider service and get the process
272
                $process = $this->startProviderProcess($name, $provider, $options);
×
273

UNCOV
274
                if (null === $process) {
×
UNCOV
275
                    $this->io?->error(sprintf('Failed to start %s service', $name));
×
276

277
                    return Command::FAILURE;
×
278
                }
279

280
                // Track the process in our manager
281
                $this->processManager->addProcess($name, $process);
×
282
                $this->processManager->setProcessParent($name, 'dev'); // Set dev as parent
×
UNCOV
283
                $this->processManager->setProcessArgs($name, ['php', 'bin/console', 'valksor:hot-reload']);
×
284
                $runningServices[] = $name;
×
285

UNCOV
286
                if ($this->isInteractive && $this->io) {
×
287
                    $this->io->success(sprintf('[READY] %s service started successfully', ucfirst($name)));
×
288
                }
UNCOV
289
            } catch (Exception $e) {
×
UNCOV
290
                $this->io?->error(sprintf('Service "%s" failed: %s', $name, $e->getMessage()));
×
291

292
                return Command::FAILURE;
×
293
            }
294
        }
295

UNCOV
296
        if ($this->isInteractive && $this->io) {
×
UNCOV
297
            $this->io->newLine();
×
298
            $this->io->success(sprintf('✓ All %d services running successfully!', count($runningServices)));
×
UNCOV
299
            $this->io->text('Press Ctrl+C to stop all services');
×
UNCOV
300
            $this->io->newLine();
×
301

302
            // Show initial service status
UNCOV
303
            $this->processManager->displayStatus();
×
304
        }
305

306
        // Start the monitoring loop - this keeps the service alive
UNCOV
307
        $this->running = true;
×
308

UNCOV
309
        return $this->monitorServices();
×
310
    }
311

312
    public function stop(): void
313
    {
314
        $this->running = false;
1✔
315

316
        $this->processManager?->terminateAll();
1✔
317
    }
318

319
    public static function getServiceName(): string
320
    {
321
        return 'dev';
1✔
322
    }
323

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

UNCOV
333
        $checkInterval = 5; // Check every 5 seconds
×
334
        $lastStatusTime = 0;
×
335
        $statusDisplayInterval = 30; // Show status every 30 seconds
×
336

337
        while ($this->running) {
×
338
            // Check if all processes are still running
339
            if ($this->processManager && !$this->processManager->allProcessesRunning()) {
×
UNCOV
340
                $failedProcesses = $this->processManager->getFailedProcesses();
×
341

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

346
                        // Show error output if available
UNCOV
347
                        $errorOutput = $process->getErrorOutput();
×
348

UNCOV
349
                        if (!empty($errorOutput)) {
×
UNCOV
350
                            $this->io->text(sprintf('Error output: %s', trim($errorOutput)));
×
351
                        }
352
                    }
353
                }
354

355
                // Handle restart for failed processes
356
                foreach (array_keys($failedProcesses) as $name) {
×
UNCOV
357
                    $restartResult = $this->processManager->handleProcessFailure($name);
×
358

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

UNCOV
363
                        return Command::SUCCESS;
×
364
                    }
365
                    // Restart failed or gave up, remove from tracking and continue
366
                    $this->processManager->removeProcess($name);
×
UNCOV
367
                    $this->io?->warning('[RESTART] Restart failed, removing service from tracking');
×
368
                }
369

UNCOV
370
                if ($this->isInteractive && $this->io) {
×
371
                    $this->io->warning('[MONITOR] Some services have failed and could not be restarted. Press Ctrl+C to exit or continue monitoring...');
×
372
                }
373
            }
374

375
            // Periodic status display
376
            $currentTime = time();
×
377

378
            if ($this->isInteractive && $this->io && ($currentTime - $lastStatusTime) >= $statusDisplayInterval) {
×
379
                $this->io->newLine();
×
UNCOV
380
                $this->io->text(sprintf(
×
381
                    '[STATUS] %s - Monitoring %d active services',
×
382
                    date('H:i:s'),
×
UNCOV
383
                    $this->processManager ? $this->processManager->count() : 0,
×
UNCOV
384
                ));
×
385

UNCOV
386
                if ($this->processManager && $this->processManager->hasProcesses()) {
×
UNCOV
387
                    $this->processManager->displayStatus();
×
388
                }
389

UNCOV
390
                $lastStatusTime = $currentTime;
×
391
            }
392

393
            // Sleep for the check interval
UNCOV
394
            sleep($checkInterval);
×
395
        }
396

UNCOV
397
        return Command::SUCCESS;
×
398
    }
399

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

408
        if (empty($initProviders)) {
4✔
409
            return;
4✔
410
        }
411

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

414
        // Binaries always run first
UNCOV
415
        if (isset($initProviders['binaries'])) {
×
UNCOV
416
            $this->io?->text('Ensuring binaries are available...');
×
417
            $this->runProvider('binaries', $initProviders['binaries'], []);
×
418
            unset($initProviders['binaries']);
×
419
        }
420

421
        // Run remaining init providers
UNCOV
422
        foreach ($initProviders as $name => $provider) {
×
423
            $config = $servicesConfig[$name] ?? [];
×
UNCOV
424
            $options = $config['options'] ?? [];
×
UNCOV
425
            $this->runProvider($name, $provider, $options);
×
426
        }
427

UNCOV
428
        $this->io?->success('Initialization completed');
×
429
    }
430

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

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

463
        // Start SSE server in background (non-blocking)
464
        $process->start();
×
465

466
        // Give SSE server more time to start and bind to port
467
        $maxWaitTime = 3; // 3 seconds max wait time
×
468
        $waitInterval = 250000; // 250ms intervals
×
UNCOV
469
        $elapsedTime = 0;
×
470

471
        while ($elapsedTime < $maxWaitTime) {
×
UNCOV
472
            usleep($waitInterval);
×
473
            $elapsedTime += ($waitInterval / 1000000);
×
474

475
            // Check if process is still running and hasn't failed
UNCOV
476
            if (!$process->isRunning()) {
×
477
                // Process stopped - check if it was successful
UNCOV
478
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
479
            }
480

481
            // After 1 second, check if we can verify the server is actually stable
UNCOV
482
            if ($elapsedTime >= 1.0) {
×
483
                // The SSE server should be stable by now, proceed
484
                break;
×
485
            }
486
        }
487

488
        // Final check - is the process still running successfully?
UNCOV
489
        return $process->isRunning() ? Command::SUCCESS : Command::FAILURE;
×
490
    }
491

492
    /**
493
     * Set SymfonyStyle on provider objects that support it.
494
     */
495
    private function setProviderIo(
496
        object $provider,
497
        SymfonyStyle $io,
498
    ): void {
UNCOV
499
        if ($provider instanceof IoAwareInterface) {
×
UNCOV
500
            $provider->setIo($io);
×
501
        }
502
    }
503

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

540
        if (null === $command) {
×
541
            return null;
×
542
        }
543

544
        // Create and start the process for background execution
545
        $process = new Process($command);
×
UNCOV
546
        $process->start();
×
547

548
        // Allow time for process initialization and startup verification
549
        // 500ms provides sufficient time for the hot reload service to initialize
UNCOV
550
        usleep(500000); // 500ms
×
551

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

UNCOV
556
            return null;
×
557
        }
558

UNCOV
559
        return $process;
×
560
    }
561
}
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