• 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

2.94
/Service/HotReloadService.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\DependencyInjection\ParameterBag\ParameterBagInterface;
19
use Valksor\Component\Sse\Service\AbstractService;
20

21
use function array_key_exists;
22
use function array_keys;
23
use function closedir;
24
use function count;
25
use function extension_loaded;
26
use function file_put_contents;
27
use function is_dir;
28
use function is_writable;
29
use function json_encode;
30
use function ltrim;
31
use function max;
32
use function microtime;
33
use function opendir;
34
use function pathinfo;
35
use function preg_match;
36
use function preg_quote;
37
use function readdir;
38
use function sprintf;
39
use function str_ends_with;
40
use function str_replace;
41
use function stream_select;
42
use function strtolower;
43
use function usleep;
44

45
use const DIRECTORY_SEPARATOR;
46
use const PATHINFO_EXTENSION;
47

48
/**
49
 * Hot reload service for automatically triggering browser refreshes when files change.
50
 *
51
 * This service monitors file system changes and sends reload signals to connected browsers
52
 * via Server-Sent Events (SSE). It handles:
53
 * - File system monitoring with inotify
54
 * - Intelligent debouncing to prevent excessive reloads
55
 * - File transformation patterns (e.g., .tailwind.css → .css)
56
 * - Rate limiting and duplicate change detection
57
 * - Multi-app project structure support
58
 */
59
final class HotReloadService extends AbstractService
60
{
61
    /**
62
     * Deadline for when debounced changes should be flushed.
63
     * This prevents excessive reloads when multiple files change rapidly.
64
     */
65
    private float $debounceDeadline = 0.0;
66

67
    /**
68
     * File transformation patterns for mapping source files to output files.
69
     * Example: '*.tailwind.css' → ['output_pattern' => '{path}/{name}.css', 'debounce_delay' => 0.5].
70
     *
71
     * @var array<string,array<string,mixed>>
72
     */
73
    private array $fileTransformations = [];
74

75
    /**
76
     * Path filter for ignoring directories and files during file watching.
77
     */
78
    private PathFilter $filter;
79

80
    /**
81
     * Timestamp of the last content change that triggered a reload.
82
     * Used for rate limiting output file changes.
83
     */
84
    private float $lastContentChange = 0.0;
85

86
    /**
87
     * Registry of known output files that should trigger reloads.
88
     * These are the target files generated by transformations (e.g., .css files).
89
     *
90
     * @var array<string,bool>
91
     */
92
    private array $outputFiles = [];
93

94
    /**
95
     * Pending file changes waiting to be debounced and flushed.
96
     * Prevents duplicate changes and implements debouncing logic.
97
     *
98
     * @var array<string,bool>
99
     */
100
    private array $pendingChanges = [];
101

102
    public function __construct(
103
        ParameterBagInterface $bag,
104
    ) {
105
        parent::__construct($bag);
13✔
106
        $this->filter = PathFilter::createDefault();
13✔
107
    }
108

109
    public function reload(): void
110
    {
111
        // Hot reload service doesn't support manual reload
112
        $this->io->note('Hot reload service reload requested (no action needed)');
1✔
113
    }
114

115
    /**
116
     * @param array<string,mixed> $config Configuration from hot_reload config section
117
     */
118
    public function start(
119
        array $config = [],
120
    ): int {
121
        // SSE server communication now handled via direct service calls
122

123
        // Get hot reload configuration from service-based structure
124
        $hotReloadConfig = $this->parameterBag->get('valksor.build.services.hot_reload.options');
4✔
125

126
        // Validate configuration early to fail fast if setup is incorrect
127
        if (!$this->validateConfiguration($hotReloadConfig)) {
×
128
            return 1; // Return error code for invalid config
×
129
        }
130

131
        $watchDirs = $hotReloadConfig['watch_dirs'] ?? [];
×
132

133
        // Early return if no watch directories configured - nothing to monitor
134
        if (empty($watchDirs)) {
×
NEW
135
            $this->io->warning('No watch directories configured. Hot reload disabled.');
×
136

137
            return Command::SUCCESS;
×
138
        }
139

140
        // Extract configuration with sensible defaults
141
        $debounceDelay = $hotReloadConfig['debounce_delay'] ?? 0.3;           // Default 300ms debounce
×
142
        $extendedExtensions = $hotReloadConfig['extended_extensions'] ?? [];     // File extensions to watch
×
143
        $extendedSuffixes = $hotReloadConfig['extended_suffixes'] ?? ['.tailwind.css' => 0.5]; // Specific file suffixes
×
144
        $fileTransformations = $hotReloadConfig['file_transformations'] ?? [
×
145
            // Default transformation: Tailwind source files to CSS output
146
            '*.tailwind.css' => [
×
147
                'output_pattern' => '{path}/{name}.css',    // Convert .tailwind.css to .css
×
148
                'debounce_delay' => 0.5,                   // Longer debounce for compiled files
×
149
                'track_output' => true,                    // Monitor the output CSS file
×
150
            ],
×
151
        ];
152

153
        $this->fileTransformations = $fileTransformations;
×
154
        // Lazy load output files only when needed, not during startup
155
        $this->outputFiles = [];
×
156

157
        try {
158
            $watcher = new RecursiveInotifyWatcher($this->filter, function (string $path) use ($extendedSuffixes, $debounceDelay, $extendedExtensions): void {
×
159
                $this->handleFilesystemChange($path, $extendedSuffixes, $extendedExtensions, $debounceDelay);
×
160
            });
×
161

NEW
162
            $this->io->text('File watcher initialized successfully');
×
163
        } catch (RuntimeException $e) {
×
164
            if ($this->io) {
×
165
                $this->io->error(sprintf('Failed to initialize file watcher: %s', $e->getMessage()));
×
166
                $this->io->warning('Hot reload will continue without file watching (manual reload only)');
×
167
            }
168

169
            // Continue without watcher - will fall back to manual mode
170
            $watcher = null;
×
171
        } catch (Exception $e) {
×
NEW
172
            $this->io->error(sprintf('Unexpected error initializing watcher: %s', $e->getMessage()));
×
173

174
            // Continue without watcher
175
            $watcher = null;
×
176
        }
177

178
        $watchTargets = $this->collectWatchTargets($this->parameterBag->get('kernel.project_dir'), $watchDirs);
×
179

180
        if ([] === $watchTargets) {
×
NEW
181
            $this->io->warning('No watch targets found. Hot reload will remain idle.');
×
182
        } else {
183
            foreach ($watchTargets as $target) {
×
184
                $watcher->addRoot($target);
×
185
            }
186

NEW
187
            $this->io->note(sprintf('Watching %d directories for changes', count($watchTargets)));
×
188
        }
189

190
        $this->running = true;
×
191
        $this->lastContentChange = microtime(true);
×
192

NEW
193
        $this->io->success('Hot reload service started');
×
194

195
        // Add loop timeout protection to prevent infinite hangs if file system stops responding
196
        $loopCount = 0;
×
197
        $maxLoopsWithoutActivity = 6000; // 10 minutes at 100ms intervals (6000 * 0.1s = 600s)
×
198

199
        while ($this->running) {
×
200
            $loopCount++;
×
201

202
            // Timeout protection - exit if no activity for extended period
203
            // This prevents the service from running indefinitely when file watching fails
204
            if ($loopCount > $maxLoopsWithoutActivity && empty($this->pendingChanges)) {
×
NEW
205
                $this->io->warning('Hot reload service timeout - no activity for 10 minutes, exiting gracefully');
×
206

207
                break;
×
208
            }
209

210
            if ($watcher) {
×
211
                $read = [$watcher->getStream()];
×
212
                $write = null;
×
213
                $except = null;
×
214

215
                // Calculate dynamic timeout based on debouncing needs
216
                $timeout = $this->calculateSelectTimeout();
×
217
                $seconds = $timeout >= 1 ? (int) $timeout : 0;
×
218
                $microseconds = $timeout >= 1 ? (int) (($timeout - $seconds) * 1_000_000) : (int) max($timeout * 1_000_000, 50_000);
×
219
                // Minimum 50ms timeout to prevent busy waiting, maximum based on debounce deadline
220

221
                // Add error handling around stream_select
222
                try {
223
                    $ready = @stream_select($read, $write, $except, $seconds, $microseconds);
×
224

225
                    if (false === $ready) {
×
226
                        // stream_select failed, likely due to signal interruption
227
                        if ($this->io && 0 === $loopCount % 100) { // Log every 10 seconds
×
228
                            $this->io->text('Stream select interrupted, continuing...');
×
229
                        }
230
                        usleep(100000); // Wait 100ms before retry
×
231

232
                        continue;
×
233
                    }
234

235
                    if (!empty($read)) {
×
236
                        $watcher->poll();
×
237
                        // Reset loop counter on activity
238
                        $loopCount = 0;
×
239
                    }
240

241
                    $this->flushPendingReloads();
×
242
                } catch (Exception $e) {
×
NEW
243
                    $this->io->error(sprintf('Error in file watching loop: %s', $e->getMessage()));
×
244
                    // Continue running but log the error
245
                }
246
            } else {
247
                // No watcher available, just wait with timeout
248
                usleep(100000); // 100ms
×
249

250
                // Reset loop counter periodically to prevent false timeouts
251
                if (0 === $loopCount % 300) { // Every 30 seconds
×
252
                    $loopCount = 0;
×
253
                }
254
            }
255
        }
256

257
        return 0;
×
258
    }
259

260
    public function stop(): void
261
    {
262
        $this->running = false;
1✔
263
    }
264

265
    public static function getServiceName(): string
266
    {
267
        return 'hot-reload';
2✔
268
    }
269

270
    private function calculateSelectTimeout(): float
271
    {
272
        $now = microtime(true);
×
273

274
        if ($this->debounceDeadline > 0) {
×
275
            return max(0.05, $this->debounceDeadline - $now);
×
276
        }
277

278
        return 1.0;
×
279
    }
280

281
    /**
282
     * @return array<int,string>
283
     */
284
    private function collectWatchTargets(
285
        string $projectRoot,
286
        array $watchDirs,
287
    ): array {
288
        $targets = [];
×
289

290
        foreach ($watchDirs as $dir) {
×
291
            $fullPath = $projectRoot . DIRECTORY_SEPARATOR . ltrim($dir, '/');
×
292

293
            if (is_dir($fullPath)) {
×
294
                $targets[] = $fullPath;
×
295
            }
296
        }
297

298
        return $targets;
×
299
    }
300

301
    private function determineReloadDelay(
302
        string $path,
303
        array $extendedExtensions,
304
        array $extendedSuffixes,
305
        float $debounceDelay,
306
    ): float {
307
        // Lazy load output files discovery to avoid expensive directory scanning during startup
308
        if (empty($this->outputFiles)) {
×
309
            $this->outputFiles = $this->discoverOutputFiles($this->parameterBag->get('kernel.project_dir'), $this->fileTransformations);
×
310
        }
311

312
        // Priority 1: Check if this is a tracked output file (e.g., .css files from .tailwind.css)
313
        // Output files get special treatment with longer debounce delays to allow compilation to complete
314
        if (isset($this->outputFiles[$path])) {
×
315
            foreach ($this->fileTransformations as $pattern => $config) {
×
316
                if ($this->matchesPattern($path, $pattern)) {
×
317
                    return $config['debounce_delay'] ?? $debounceDelay;
×
318
                }
319
            }
320
        }
321

322
        // Priority 2: Check if this is a source file that matches transformation patterns
323
        // Source files get their configured debounce delay (typically longer for compiled files)
324
        foreach ($this->fileTransformations as $pattern => $config) {
×
325
            if ($this->matchesPattern($path, $pattern)) {
×
326
                return $config['debounce_delay'] ?? $debounceDelay;
×
327
            }
328
        }
329

330
        // Priority 3: Check extended suffixes (legacy support for specific file patterns)
331
        // Provides backward compatibility with older configuration formats
332
        foreach ($extendedSuffixes as $suffix => $delay) {
×
333
            if (str_ends_with($path, $suffix)) {
×
334
                return $delay;
×
335
            }
336
        }
337

338
        // Priority 4: Check file extensions for general file type handling
339
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
×
340

341
        if ('' !== $extension && array_key_exists($extension, $extendedExtensions)) {
×
342
            return $extendedExtensions[$extension];
×
343
        }
344

345
        // Priority 5: Use default debounce delay for all other files
346
        return $debounceDelay;
×
347
    }
348

349
    /**
350
     * @param array<string,array<string,mixed>> $transformations
351
     *
352
     * @return array<string,bool>
353
     */
354
    private function discoverOutputFiles(
355
        string $projectRoot,
356
        array $transformations,
357
    ): array {
358
        $outputs = [];
×
359

360
        foreach ($transformations as $pattern => $config) {
×
361
            if (!($config['track_output'] ?? true)) {
×
362
                continue;
×
363
            }
364

365
            $roots = $config['watch_dirs'] ?? [
×
366
                $projectRoot . $this->parameterBag->get('valksor.project.apps_dir'),
×
367
                $projectRoot . $this->parameterBag->get('valksor.project.infrastructure_dir'),
×
368
            ];
369

370
            foreach ($roots as $root) {
×
371
                $root = $projectRoot . DIRECTORY_SEPARATOR . ltrim($root, '/');
×
372

373
                if (!is_dir($root)) {
×
374
                    continue;
×
375
                }
376

377
                $this->visitSources($root, $pattern, $config['output_pattern'], $outputs);
×
378
            }
379
        }
380

381
        return $outputs;
×
382
    }
383

384
    private function flushPendingReloads(): void
385
    {
386
        if ([] === $this->pendingChanges || microtime(true) < $this->debounceDeadline) {
×
387
            return;
×
388
        }
389

390
        $files = array_keys($this->pendingChanges);
×
391
        $this->pendingChanges = [];
×
392
        $this->debounceDeadline = 0.0;
×
393

394
        // Write signal file for SSE service with error handling
395
        $signalFile = $this->parameterBag->get('kernel.project_dir') . '/var/run/valksor-reload.signal';
×
396
        $signalData = json_encode(['files' => $files, 'timestamp' => microtime(true)]);
×
397

398
        try {
399
            $result = file_put_contents($signalFile, $signalData);
×
400

401
            if (false === $result) {
×
402
                throw new RuntimeException('Failed to write signal file');
×
403
            }
404
        } catch (Exception $e) {
×
NEW
405
            $this->io->error(sprintf('Failed to write reload signal: %s', $e->getMessage()));
×
406

407
            return;
×
408
        }
409

NEW
410
        $this->io->success(sprintf('Reload signal sent for %d changed files', count($files)));
×
411
    }
412

413
    /**
414
     * Generate output file path from pattern.
415
     */
416
    private function generateOutputPath(
417
        string $projectRoot,
418
        string $relativeDir,
419
        string $filename,
420
        string $outputPattern,
421
    ): string {
422
        $placeholders = [
×
423
            '{path}' => $relativeDir,
×
424
            '{name}' => $filename,
×
425
        ];
×
426

427
        $outputPath = $outputPattern;
×
428

429
        foreach ($placeholders as $placeholder => $value) {
×
430
            $outputPath = str_replace($placeholder, $value, $outputPath);
×
431
        }
432

433
        return $projectRoot . DIRECTORY_SEPARATOR . $outputPath;
×
434
    }
435

436
    private function handleFilesystemChange(
437
        string $path,
438
        array $extendedSuffixes,
439
        array $extendedExtensions,
440
        float $debounceDelay,
441
    ): void {
442
        // Ignore files that match the path filter (e.g., .git, node_modules, vendor)
443
        if ($this->filter->shouldIgnorePath($path)) {
×
444
            return;
×
445
        }
446

447
        $now = microtime(true);
×
448
        $isOutputFile = isset($this->outputFiles[$path]);
×
449

450
        // Determine the appropriate debounce delay for this specific file type
451
        $fileDelay = $this->determineReloadDelay($path, $extendedExtensions, $extendedSuffixes, $debounceDelay);
×
452

453
        // Debug logging for troubleshooting file change handling
NEW
454
        $this->io->text(sprintf(
×
455
            'DEBUG: File %s | Output: %s | Delay: %.2fs',
×
456
            $path,
×
457
            $isOutputFile ? 'YES' : 'NO',
×
458
            $fileDelay,
×
459
        ));
×
460

461
        // Rate limiting for output files to prevent excessive reloads during compilation
462
        // Output files (like .css) may change rapidly during build processes
463
        if ($isOutputFile && ($now - $this->lastContentChange) < $fileDelay) {
×
NEW
464
            $this->io->text(sprintf(
×
465
                'DEBUG: Rate limiting %s (last change: %.2fs ago, limit: %.2fs)',
×
466
                $path,
×
467
                $now - $this->lastContentChange,
×
468
                $fileDelay,
×
469
            ));
×
470

471
            return; // Skip this change to prevent reload spam
×
472
        }
473

474
        // Duplicate change detection - prevent the same file from being queued multiple times
475
        if (isset($this->pendingChanges[$path])) {
×
NEW
476
            $this->io->text(sprintf('DEBUG: Skipping duplicate change for %s', $path));
×
477

478
            return; // Already queued, don't update timing
×
479
        }
480

481
        // Add this file to the pending changes queue
482
        $this->pendingChanges[$path] = true;
×
483

484
        // Update last content change timestamp for rate limiting
485
        // Only update if this is not an output file or if enough time has passed
486
        if (!$isOutputFile || ($now - $this->lastContentChange) >= $fileDelay) {
×
487
            $this->lastContentChange = $now;
×
488
        }
489

490
        // Calculate when this change should be flushed (debounce deadline)
491
        $desiredDeadline = $now + $fileDelay;
×
492

493
        // Extend the global debounce deadline if this file needs more time
494
        // This ensures all changes in a rapid sequence wait for the longest required delay
495
        if ($desiredDeadline > $this->debounceDeadline) {
×
496
            $this->debounceDeadline = $desiredDeadline;
×
497
        }
498

NEW
499
        $this->io->text('File changed: ' . $path);
×
500
    }
501

502
    /**
503
     * Check if a file path matches a glob pattern.
504
     *
505
     * Converts simple glob patterns (like *.tailwind.css) to regex for matching
506
     * against absolute file paths. This supports the transformation pattern system.
507
     *
508
     * Examples:
509
     * - "*.tailwind.css" matches all .tailwind.css files in any directory
510
     *
511
     * @param string $path    Absolute file path to check
512
     * @param string $pattern Glob pattern to match against
513
     *
514
     * @return bool True if the path matches the pattern
515
     */
516
    private function matchesPattern(
517
        string $path,
518
        string $pattern,
519
    ): bool {
520
        // Convert glob pattern to regex pattern
521
        // * becomes .* (match any characters)
522
        // ? becomes . (match any single character)
523
        $pattern = str_replace(['*', '?'], ['.*', '.'], $pattern);
×
524

525
        // Create full regex pattern that matches from project root
526
        $pattern = '#^' . preg_quote($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '#') . $pattern . '$#';
×
527

528
        return 1 === preg_match($pattern, $path);
×
529
    }
530

531
    /**
532
     * Validate hot reload configuration before starting.
533
     */
534
    private function validateConfiguration(
535
        array $config,
536
    ): bool {
537
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
×
538

539
        // Validate var/run directory exists and is writable
540
        $runDir = $projectRoot . '/var/run';
×
541
        $this->ensureDirectory($runDir);
×
542

543
        if (!is_writable($runDir)) {
×
NEW
544
            $this->io->error(sprintf('Run directory is not writable: %s', $runDir));
×
545

546
            return false;
×
547
        }
548

549
        // Validate watch directories exist
NEW
550
        foreach ($config['watch_dirs'] ?? [] as $dir) {
×
UNCOV
551
            $fullPath = $projectRoot . '/' . ltrim($dir, '/');
×
552

553
            if (!is_dir($fullPath)) {
×
NEW
554
                $this->io->warning(sprintf('Watch directory does not exist: %s', $fullPath));
×
555
            }
556
        }
557

558
        // Check if inotify extension is available
559
        if (!extension_loaded('inotify')) {
×
NEW
560
            $this->io->warning('PHP inotify extension is not available. File watching may not work optimally.');
×
561
        }
562

563
        return true;
×
564
    }
565

566
    /**
567
     * @param array<string,bool> $outputs
568
     */
569
    private function visitSources(
570
        string $path,
571
        string $pattern,
572
        string $outputPattern,
573
        array &$outputs,
574
    ): void {
575
        $handle = opendir($path);
×
576

577
        if (false === $handle) {
×
578
            return;
×
579
        }
580

581
        try {
582
            while (($entry = readdir($handle)) !== false) {
×
583
                if ('.' === $entry || '..' === $entry) {
×
584
                    continue;
×
585
                }
586

587
                $full = $path . DIRECTORY_SEPARATOR . $entry;
×
588

589
                if (is_dir($full)) {
×
590
                    if ($this->filter->shouldIgnoreDirectory($entry)) {
×
591
                        continue;
×
592
                    }
593
                    $this->visitSources($full, $pattern, $outputPattern, $outputs);
×
594

595
                    continue;
×
596
                }
597

598
                if (!$this->matchesPattern($full, $pattern)) {
×
599
                    continue;
×
600
                }
601

602
                // Generate output file path
603
                $relativePath = str_replace($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '', $full);
×
604
                $pathInfo = pathinfo($relativePath);
×
605

606
                $outputFile = $this->generateOutputPath(
×
607
                    $this->parameterBag->get('kernel.project_dir'),
×
608
                    $pathInfo['dirname'] ?? '',
×
609
                    $pathInfo['filename'],
×
610
                    $outputPattern,
×
611
                );
×
612

613
                $outputs[$outputFile] = true;
×
614
            }
615
        } finally {
616
            closedir($handle);
×
617
        }
618
    }
619
}
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