• 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

10.76
/Service/DevWatchService.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 implode;
29
use function sleep;
30
use function sprintf;
31
use function time;
32
use function trim;
33
use function ucfirst;
34
use function usleep;
35

36
/**
37
 * Development watch service orchestrator for the Valksor build system.
38
 *
39
 * This service manages the complete development workflow, coordinating multiple
40
 * build services (Tailwind, Importmap, Hot Reload) in the proper sequence.
41
 * It handles:
42
 *
43
 * Service Orchestration:
44
 * - Initialization phase (binaries, dependency setup)
45
 * - SSE server startup for hot reload communication
46
 * - Multi-service startup with dependency resolution
47
 * - Background process lifecycle management
48
 *
49
 * Monitoring and Status:
50
 * - Process health monitoring with failure detection
51
 * - Interactive status display and progress reporting
52
 * - Graceful shutdown handling with signal management
53
 * - Error reporting and debugging information
54
 *
55
 * Environment Support:
56
 * - Interactive vs non-interactive execution modes
57
 * - IO injection for providers that need console access
58
 * - Configuration-based service discovery and filtering
59
 * - Cross-platform process management
60
 */
61
final class DevWatchService
62
{
63
    /**
64
     * Symfony console output interface for user interaction and status reporting.
65
     * Enables rich console output with sections, progress indicators, and formatted text.
66
     */
67
    private ?SymfonyStyle $io = null;
68

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

75
    /**
76
     * Process manager for tracking and coordinating background build services.
77
     * Handles startup, monitoring, and graceful shutdown of all child processes.
78
     */
79
    private ?ProcessManager $processManager = null;
80

81
    /**
82
     * Runtime flag indicating the service is active and should continue monitoring.
83
     * Set to false during shutdown to signal the monitoring loop to exit gracefully.
84
     */
85
    private bool $running = false;
86

87
    /**
88
     * Initialize the development watch service orchestrator.
89
     *
90
     * The service requires access to the application configuration and the provider
91
     * registry to discover and manage build services. These dependencies are injected
92
     * via Symfony's dependency injection container.
93
     *
94
     * @param ParameterBagInterface $parameterBag     Application configuration and parameters
95
     * @param ProviderRegistry      $providerRegistry Registry of available service providers
96
     */
97
    public function __construct(
98
        private readonly ParameterBagInterface $parameterBag,
99
        private readonly ProviderRegistry $providerRegistry,
100
        private readonly ?ConsoleCommandBuilder $commandBuilder = null,
101
    ) {
102
    }
18✔
103

104
    public function getParameterBag(): ParameterBagInterface
105
    {
106
        return $this->parameterBag;
11✔
107
    }
108

109
    public function getProviderRegistry(): ProviderRegistry
110
    {
111
        return $this->providerRegistry;
10✔
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;
5✔
124
    }
125

126
    /**
127
     * Start the development watch service orchestrator.
128
     *
129
     * This method executes the complete development workflow in phases:
130
     * 1. Process manager initialization for background service tracking
131
     * 2. Configuration validation and provider discovery
132
     * 3. Initialization phase (binary downloads, dependency setup)
133
     * 4. SSE server startup for hot reload communication
134
     * 5. Sequential provider startup with dependency resolution
135
     * 6. Continuous monitoring loop for process health
136
     *
137
     * The orchestration ensures proper service startup order and provides
138
     * comprehensive error handling and status reporting throughout the process.
139
     *
140
     * @return int Command exit code (Command::SUCCESS or Command::FAILURE)
141
     */
142
    public function start(): int
143
    {
144
        $this->io?->title('Development Watch Mode');
5✔
145

146
        // Initialize process manager for tracking background services
147
        // Handles process lifecycle, health monitoring, and graceful shutdown
148
        $this->processManager = new ProcessManager($this->io);
5✔
149

150
        // Register this watch service as root parent for restart tracking
151
        $this->processManager->setProcessParent('watch', null); // watch is root
5✔
152
        $this->processManager->setProcessArgs('watch', ['php', 'bin/console', 'valksor:watch']);
5✔
153

154
        // Get services configuration from ParameterBag
155
        // Contains service definitions, flags, and options for all build services
156
        $servicesConfig = $this->parameterBag->get('valksor.build.services');
5✔
157

158
        // Get all dev services (dev=true) with dependency resolution
159
        // Returns providers sorted by execution order and dependencies
160
        $devProviders = $this->providerRegistry->getProvidersByFlag($servicesConfig, 'dev');
5✔
161

162
        if (empty($devProviders)) {
5✔
163
            if ($this->isInteractive && $this->io) {
5✔
164
                $this->io->warning('No dev services are enabled in configuration.');
4✔
165
            }
166

167
            return Command::SUCCESS;
5✔
168
        }
169

170
        // Validate all configured providers exist
171
        $missingProviders = $this->providerRegistry->validateProviders($servicesConfig);
×
172

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

176
            return Command::FAILURE;
×
177
        }
178

179
        // Run init phase first
180
        $this->runInit();
×
181

182
        // Start SSE first before any providers (required for hot reload communication)
183
        if ($this->isInteractive && $this->io) {
×
184
            $this->io->text('Starting SSE server...');
×
185
        }
186
        $sseResult = $this->runSseCommand();
×
187

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

191
            return Command::FAILURE;
×
192
        }
193

194
        // Register SSE as child of watch service
195
        $this->processManager->setProcessParent('sse', 'watch');
×
196
        $this->processManager->setProcessArgs('sse', ['php', 'bin/console', 'valksor:sse']);
×
197

198
        if ($this->isInteractive && $this->io) {
×
199
            $this->io->success('✓ SSE server started and running');
×
200
            $this->io->newLine();
×
201
            $this->io->text(sprintf('Starting %d dev service(s)...', count($devProviders)));
×
202
            $this->io->newLine();
×
203
        }
204

205
        $runningServices = [];
×
206

207
        foreach ($devProviders as $name => $provider) {
×
208
            if ($this->isInteractive && $this->io) {
×
209
                $this->io->section(sprintf('Starting %s', ucfirst($name)));
×
210
            }
211

212
            $config = $servicesConfig[$name] ?? [];
×
213
            $options = $config['options'] ?? [];
×
214
            // Pass interactive mode to providers
215
            $options['interactive'] = $this->isInteractive;
×
216

217
            // Set IO on providers that support it
218
            $this->setProviderIo($provider, $this->io);
×
219

220
            try {
221
                if ($this->isInteractive && $this->io) {
×
222
                    $this->io->text(sprintf('[INITIALIZING] %s service...', ucfirst($name)));
×
223
                }
224

225
                // Start the provider process and get the process
226
                $process = $this->startProviderProcess($name, $provider, $options);
×
227

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

231
                    return Command::FAILURE;
×
232
                }
233

234
                // Track the process in our manager
235
                $this->processManager->addProcess($name, $process);
×
236
                $this->processManager->setProcessParent($name, 'watch'); // Set watch as parent
×
237

238
                // Set appropriate command arguments based on service name
239
                $commandArgs = match ($name) {
×
240
                    'hot_reload' => ['php', 'bin/console', 'valksor:hot-reload'],
×
241
                    'tailwind' => ['php', 'bin/console', 'valksor:tailwind', '--watch'],
×
242
                    'importmap' => ['php', 'bin/console', 'valksor:importmap', '--watch'],
×
243
                    default => ['php', 'bin/console', "valksor:{$name}"],
×
244
                };
×
245
                $this->processManager->setProcessArgs($name, $commandArgs);
×
246

247
                $runningServices[] = $name;
×
248

249
                if ($this->isInteractive && $this->io) {
×
250
                    $this->io->success(sprintf('[READY] %s service started successfully', ucfirst($name)));
×
251
                }
252
            } catch (Exception $e) {
×
253
                $this->io?->error(sprintf('Service "%s" failed: %s', $name, $e->getMessage()));
×
254

255
                return Command::FAILURE;
×
256
            }
257
        }
258

259
        if ($this->isInteractive && $this->io) {
×
260
            $this->io->newLine();
×
261
            $this->io->success(sprintf('✓ All %d services running successfully!', count($runningServices)));
×
262
            $this->io->text('Press Ctrl+C to stop all services');
×
263
            $this->io->newLine();
×
264

265
            // Show initial service status
266
            $this->processManager->displayStatus();
×
267
        }
268

269
        // Start the monitoring loop - this keeps the service alive
270
        $this->running = true;
×
271

272
        return $this->monitorServices();
×
273
    }
274

275
    public function stop(): void
276
    {
277
        $this->running = false;
1✔
278

279
        $this->processManager?->terminateAll();
1✔
280
    }
281

282
    /**
283
     * Monitor running services and maintain the orchestrator lifecycle.
284
     *
285
     * This method implements the main monitoring loop that:
286
     * - Continuously checks the health of all background processes
287
     * - Detects and reports service failures with detailed error information
288
     * - Provides periodic status updates in interactive mode
289
     * - Manages failed process cleanup and removal from tracking
290
     * - Maintains the service alive until shutdown signal is received
291
     *
292
     * The monitoring uses configurable intervals to balance responsiveness
293
     * with system resource usage. Failed services are logged but don't
294
     * immediately terminate the entire development environment.
295
     *
296
     * @return int Command exit code (always SUCCESS for monitoring loop)
297
     */
298
    private function monitorServices(): int
299
    {
300
        if ($this->isInteractive && $this->io) {
×
301
            $this->io->text('[MONITOR] Starting service monitoring loop...');
×
302
        }
303

304
        $checkInterval = 5; // Check every 5 seconds - balances responsiveness with resource usage
×
305
        $lastStatusTime = 0;
×
306
        $statusDisplayInterval = 30; // Show status every 30 seconds - prevents console spam
×
307

308
        while ($this->running) {
×
309
            // Check if all processes are still running
310
            if ($this->processManager && !$this->processManager->allProcessesRunning()) {
×
311
                $failedProcesses = $this->processManager->getFailedProcesses();
×
312

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

317
                        // Show error output if available
318
                        $errorOutput = $process->getErrorOutput();
×
319

320
                        if (!empty($errorOutput)) {
×
321
                            $this->io->text(sprintf('Error output: %s', trim($errorOutput)));
×
322
                        }
323
                    }
324
                }
325

326
                // Handle restart for failed processes
327
                foreach (array_keys($failedProcesses) as $name) {
×
328
                    $restartResult = $this->processManager->handleProcessFailure($name);
×
329

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

334
                        return Command::SUCCESS;
×
335
                    }
336
                    // Restart failed or gave up, remove from tracking and continue
337
                    $this->processManager->removeProcess($name);
×
338
                    $this->io?->warning('[RESTART] Restart failed, removing service from tracking');
×
339
                }
340

341
                if ($this->isInteractive && $this->io) {
×
342
                    $this->io->warning('[MONITOR] Some services have failed and could not be restarted. Press Ctrl+C to exit or continue monitoring...');
×
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');
×
377
        $initProviders = $this->providerRegistry->getProvidersByFlag($servicesConfig, 'init');
×
378

379
        if (empty($initProviders)) {
×
380
            return;
×
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
     * Start the SSE (Server-Sent Events) server for hot reload communication.
424
     *
425
     * This method launches the SSE server in the background, which is essential
426
     * for the hot reload service to communicate browser refresh signals.
427
     * The SSE server must be started before any build services that generate
428
     * output files (CSS, JS) that might trigger hot reload events.
429
     *
430
     * The startup process includes a sophisticated polling mechanism:
431
     * - Starts the process in non-blocking mode
432
     * - Polls process status at 250ms intervals for up to 3 seconds
433
     * - Allows 1 second for the server to stabilize before proceeding
434
     * - Detects early startup failures and port binding issues
435
     *
436
     * This approach handles race conditions where the SSE server needs time
437
     * to bind to the port and initialize before build services start sending signals.
438
     *
439
     * @return int Command exit code (SUCCESS if server started, FAILURE otherwise)
440
     */
441
    private function runSseCommand(): int
442
    {
443
        // Use ConsoleCommandBuilder if available, otherwise fall back to manual construction
444
        if ($this->commandBuilder) {
×
445
            $process = $this->commandBuilder->build('valksor:sse');
×
446
        } else {
447
            $process = new Process(['php', 'bin/console', 'valksor:sse']);
×
448
        }
449

450
        // Start SSE server in background (non-blocking mode)
451
        // This allows the orchestrator to continue with other service startups
452
        $process->start();
×
453

454
        // Poll-based startup verification to handle race conditions
455
        // The SSE server needs time to bind to port and initialize
456
        $maxWaitTime = 3; // 3 seconds max wait time for startup
×
457
        $waitInterval = 250000; // 250ms intervals - frequent but not aggressive
×
458
        $elapsedTime = 0;
×
459

460
        while ($elapsedTime < $maxWaitTime) {
×
461
            usleep($waitInterval);
×
462
            $elapsedTime += ($waitInterval / 1000000);
×
463

464
            // Check if process is still running and hasn't failed immediately
465
            if (!$process->isRunning()) {
×
466
                // Process stopped during startup - check if it was successful
467
                // This catches port conflicts, missing dependencies, etc.
468
                return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
×
469
            }
470

471
            // After 1 second, assume the SSE server is stable and ready
472
            // Most startup failures occur immediately, so 1s is sufficient
473
            if ($elapsedTime >= 1.0) {
×
474
                break; // Server should be stable by now, proceed with other services
×
475
            }
476
        }
477

478
        // Final verification - ensure the process is still running
479
        return $process->isRunning() ? Command::SUCCESS : Command::FAILURE;
×
480
    }
481

482
    /**
483
     * Set SymfonyStyle on provider objects that support it.
484
     */
485
    private function setProviderIo(
486
        object $provider,
487
        SymfonyStyle $io,
488
    ): void {
489
        if ($provider instanceof IoAwareInterface) {
×
490
            $provider->setIo($io);
×
491
        }
492
    }
493

494
    /**
495
     * Start a provider process and return the Process object for tracking.
496
     */
497
    private function startProviderProcess(
498
        string $name,
499
        object $provider,
500
        array $options,
501
    ): ?Process {
502
        $command = match ($name) {
×
503
            'hot_reload' => ['php', 'bin/console', 'valksor:hot-reload'],
×
504
            'tailwind' => ['php', 'bin/console', 'valksor:tailwind', '--watch'],
×
505
            'importmap' => ['php', 'bin/console', 'valksor:importmap', '--watch'],
×
506
            default => null,
×
507
        };
×
508

509
        if (null === $command) {
×
510
            return null;
×
511
        }
512

513
        // Create and start the process
514
        $process = new Process($command);
×
515
        $process->start();
×
516

517
        // Give process time to start
518
        usleep(500000); // 500ms
×
519

520
        // Check if it started successfully
521
        if (!$process->isRunning()) {
×
522
            $this->io?->error(sprintf('Process %s failed to start (exit code: %d)', $name, $process->getExitCode()));
×
523

524
            return null;
×
525
        }
526

527
        return $process;
×
528
    }
529
}
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