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

valksor / php-dev-build / 21323318062

24 Jan 2026 11:21PM UTC coverage: 27.706% (-2.8%) from 30.503%
21323318062

push

github

k0d3r1s
wip

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

909 existing lines in 16 files now uncovered.

791 of 2855 relevant lines covered (27.71%)

0.96 hits per line

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

72.34
/Service/RecursiveInotifyWatcher.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 RuntimeException;
16
use Valksor\Bundle\Service\PathFilter;
17

18
use function array_key_exists;
19
use function array_keys;
20
use function basename;
21
use function closedir;
22
use function count;
23
use function fclose;
24
use function function_exists;
25
use function inotify_add_watch;
26
use function inotify_init;
27
use function inotify_read;
28
use function inotify_rm_watch;
29
use function is_dir;
30
use function is_resource;
31
use function opendir;
32
use function readdir;
33
use function realpath;
34
use function rtrim;
35
use function stream_set_blocking;
36

37
use const DIRECTORY_SEPARATOR;
38
use const IN_ATTRIB;
39
use const IN_CLOSE_WRITE;
40
use const IN_CREATE;
41
use const IN_DELETE;
42
use const IN_DELETE_SELF;
43
use const IN_IGNORED;
44
use const IN_ISDIR;
45
use const IN_MOVE;
46
use const IN_MOVE_SELF;
47
use const IN_MOVED_FROM;
48
use const IN_MOVED_TO;
49

50
/**
51
 * Recursive inotify-based file system watcher for hot reload functionality.
52
 *
53
 * This class provides efficient file system monitoring using Linux's inotify API.
54
 * It handles:
55
 * - Recursive directory watching with automatic new directory detection
56
 * - Efficient event-based file change notifications
57
 * - Automatic cleanup when directories are deleted
58
 * - Path filtering to ignore unwanted files and directories
59
 * - Retry mechanism for failed watch registrations
60
 * - Bidirectional mapping between paths and watch descriptors for efficient lookups
61
 *
62
 * The watcher is optimized for development scenarios where files change frequently
63
 * and performance is critical for responsive hot reload.
64
 */
65
final class RecursiveInotifyWatcher
66
{
67
    /**
68
     * Maximum number of watch descriptors to prevent OS limit exhaustion.
69
     * Most Linux systems have a default limit of 8192-65536 per user.
70
     * We use a conservative limit to ensure system stability.
71
     */
72
    private const int MAX_WATCH_DESCRIPTORS = 4096;
73

74
    /**
75
     * Inotify watch mask defining which file system events to monitor.
76
     *
77
     * Events watched:
78
     * - IN_ATTRIB: File metadata changes (permissions, ownership, etc.)
79
     * - IN_CLOSE_WRITE: File closed after being written to
80
     * - IN_CREATE: File/directory created in watched directory
81
     * - IN_DELETE: File/directory deleted from watched directory
82
     * - IN_DELETE_SELF: Watched file/directory was deleted
83
     * - IN_MOVE: Watched file/directory was moved
84
     * - IN_MOVE_SELF: Watched file/directory was moved
85
     * - IN_MOVED_FROM: File moved out of watched directory
86
     * - IN_MOVED_TO: File moved into watched directory
87
     *
88
     * Note: We don't watch IN_MODIFY to avoid getting events during file writes,
89
     * only when files are closed after writing (IN_CLOSE_WRITE).
90
     */
91
    private const int WATCH_MASK =
92
        IN_ATTRIB          // File attribute changes
93
        | IN_CLOSE_WRITE   // File written and closed
94
        | IN_CREATE        // File/directory created
95
        | IN_DELETE        // File/directory deleted
96
        | IN_DELETE_SELF   // Watched item deleted
97
        | IN_MOVE          // Watched item moved
98
        | IN_MOVE_SELF     // Watched item moved
99
        | IN_MOVED_FROM    // File moved from watched directory
100
        | IN_MOVED_TO;     // File moved to watched directory
101

102
    /**
103
     * Callback function invoked when a file change is detected.
104
     *
105
     * @var callable(string):void
106
     */
107
    private $callback;
108

109
    /**
110
     * Inotify instance resource for kernel communication.
111
     *
112
     * @var resource
113
     */
114
    private $inotify;
115

116
    /**
117
     * Mapping from file paths to inotify watch descriptors.
118
     * Enables quick lookup of existing watches for path checking.
119
     *
120
     * @var array<string,int>
121
     */
122
    private array $pathToWatchDescriptor = [];
123

124
    /**
125
     * Pending directory registrations that failed initially.
126
     * These are retried periodically to handle timing issues.
127
     *
128
     * @var array<string,bool>
129
     */
130
    private array $pendingRegistrations = [];
131

132
    /**
133
     * Set of registered root directories to prevent duplicate registrations.
134
     *
135
     * @var array<string,bool>
136
     */
137
    private array $registeredRoots = [];
138

139
    /**
140
     * Reverse mapping from inotify watch descriptors to file paths.
141
     * Used for efficient event processing when events reference descriptors.
142
     *
143
     * @var array<int,string>
144
     */
145
    private array $watchDescriptorToPath = [];
146

147
    /**
148
     * Initialize the inotify watcher with path filtering and change callback.
149
     *
150
     * @param PathFilter                  $filter   Filter for ignoring unwanted paths and directories
151
     * @param callable(string $path):void $onChange Callback invoked when file changes are detected
152
     *
153
     * @throws RuntimeException If inotify extension is not available or initialization fails
154
     */
155
    public function __construct(
156
        private readonly PathFilter $filter,
157
        callable $onChange,
158
    ) {
159
        // Verify inotify extension is available
160
        if (!function_exists('inotify_init')) {
14✔
UNCOV
161
            throw new RuntimeException('inotify extension is required but not available.');
×
162
        }
163

164
        // Initialize inotify instance for kernel communication
165
        $this->inotify = inotify_init();
14✔
166

167
        if (!is_resource($this->inotify)) {
14✔
UNCOV
168
            throw new RuntimeException('Failed to initialise inotify.');
×
169
        }
170

171
        // Set non-blocking mode to prevent the watcher from blocking the main process
172
        // This allows integration with event loops and stream_select()
173
        stream_set_blocking($this->inotify, false);
14✔
174

175
        $this->callback = $onChange;
14✔
176
    }
177

178
    /**
179
     * Destructor to ensure proper cleanup of inotify resources.
180
     *
181
     * This prevents resource leaks when the watcher is destroyed,
182
     * which is critical for long-running processes.
183
     */
184
    public function __destruct()
185
    {
186
        if (is_resource($this->inotify)) {
14✔
187
            // Clean up all watches before closing the inotify instance
188
            foreach ($this->watchDescriptorToPath as $descriptor => $path) {
14✔
189
                @inotify_rm_watch($this->inotify, $descriptor);
9✔
190
            }
191

192
            // Close the inotify instance to free file descriptors
193
            @fclose($this->inotify);
14✔
194
        }
195
    }
196

197
    public function addRoot(
198
        string $path,
199
    ): void {
200
        $real = $this->normalisePath($path);
10✔
201

202
        if (null === $real || isset($this->registeredRoots[$real])) {
10✔
203
            return;
2✔
204
        }
205

206
        $this->registeredRoots[$real] = true;
9✔
207
        $this->registerDirectoryRecursively($real);
9✔
208
    }
209

210
    public function getStream()
211
    {
212
        return $this->inotify;
4✔
213
    }
214

215
    public function poll(): void
216
    {
217
        // Read available inotify events from the kernel
218
        // Returns array of events or false if no events are available
219
        $events = inotify_read($this->inotify);
3✔
220

221
        if (false === $events || [] === $events) {
3✔
222
            // No events available - use this opportunity to retry failed registrations
223
            $this->retryPendingRegistrations();
1✔
224

225
            return;
1✔
226
        }
227

228
        // Process each inotify event
229
        foreach ($events as $event) {
2✔
230
            $this->handleEvent($event);
2✔
231
        }
232

233
        // Retry any pending directory registrations after processing events
234
        // New directories might have been created during event processing
235
        $this->retryPendingRegistrations();
2✔
236
    }
237

238
    private function addWatch(
239
        string $path,
240
    ): void {
241
        // Check if we've reached the maximum number of watch descriptors
242
        if (count($this->watchDescriptorToPath) >= self::MAX_WATCH_DESCRIPTORS) {
9✔
243
            // Note: Since this is a low-level service without direct IO access, we use
244
            // a simple approach to prevent OS limit exhaustion without logging
UNCOV
245
            return;
×
246
        }
247

248
        $descriptor = inotify_add_watch($this->inotify, $path, self::WATCH_MASK);
9✔
249

250
        if (false === $descriptor) {
9✔
251
            $this->pendingRegistrations[$path] = true;
×
252

UNCOV
253
            return;
×
254
        }
255

256
        $this->watchDescriptorToPath[$descriptor] = $path;
9✔
257
        $this->pathToWatchDescriptor[$path] = $descriptor;
9✔
258
    }
259

260
    /**
261
     * Handle a single inotify event, processing file system changes.
262
     *
263
     * This method implements the core event processing logic including:
264
     * - Event validation and path reconstruction
265
     * - Automatic directory registration for new subdirectories
266
     * - Watch cleanup when directories are deleted
267
     * - Path filtering and callback invocation
268
     *
269
     * @param array{wd:int,mask:int,name?:string} $event Inotify event data structure
270
     */
271
    private function handleEvent(
272
        array $event,
273
    ): void {
274
        $watchDescriptor = $event['wd'] ?? null;
2✔
275

276
        // Validate that we know about this watch descriptor
277
        // This can happen if the watch was removed but events are still pending
278
        if (!array_key_exists($watchDescriptor, $this->watchDescriptorToPath)) {
2✔
UNCOV
279
            return;
×
280
        }
281

282
        // Reconstruct the full path from watch descriptor and event data
283
        $basePath = $this->watchDescriptorToPath[$watchDescriptor];
2✔
284
        $name = $event['name'] ?? '';
2✔
285
        $fullPath = '' !== $name ? $basePath . DIRECTORY_SEPARATOR . $name : $basePath;
2✔
286

287
        // Handle IN_IGNORED events (watch was automatically removed by kernel)
288
        // This happens when the watched directory is deleted or the filesystem is unmounted
289
        if (($event['mask'] & IN_IGNORED) === IN_IGNORED) {
2✔
290
            $this->removeWatch($watchDescriptor, $basePath);
×
291

UNCOV
292
            return; // Don't notify for ignored events
×
293
        }
294

295
        // Handle directory creation events - automatically watch new subdirectories
296
        // This enables true recursive watching without manual rescanning
297
        if ((($event['mask'] & IN_ISDIR) === IN_ISDIR) && ($event['mask'] & (IN_CREATE | IN_MOVED_TO)) !== 0) {
2✔
UNCOV
298
            $this->registerDirectoryRecursively($fullPath);
×
299
        }
300

301
        // Handle watched directory deletion or movement events
302
        // Clean up our internal tracking when the watched item disappears
303
        if (($event['mask'] & (IN_DELETE_SELF | IN_MOVE_SELF)) !== 0) {
2✔
304
            $this->removeWatch($watchDescriptor, $basePath);
×
305

UNCOV
306
            return; // Don't notify for self-deletion events
×
307
        }
308

309
        // Apply path filtering to ignore unwanted files and directories
310
        if ($this->filter->shouldIgnorePath($fullPath)) {
2✔
UNCOV
311
            return;
×
312
        }
313

314
        // Invoke the user callback with the full path to the changed file/directory
315
        ($this->callback)($fullPath);
2✔
316
    }
317

318
    private function normalisePath(
319
        string $path,
320
    ): ?string {
321
        $trimmed = rtrim($path, DIRECTORY_SEPARATOR);
10✔
322

323
        if ('' === $trimmed) {
10✔
UNCOV
324
            return null;
×
325
        }
326

327
        if (!is_dir($trimmed)) {
10✔
328
            return null;
1✔
329
        }
330

331
        $real = realpath($trimmed);
9✔
332

333
        return false === $real ? null : $real;
9✔
334
    }
335

336
    private function registerDirectoryRecursively(
337
        string $path,
338
    ): void {
339
        $path = $this->normalisePath($path);
9✔
340

341
        if (null === $path) {
9✔
UNCOV
342
            return;
×
343
        }
344

345
        if (isset($this->pathToWatchDescriptor[$path])) {
9✔
UNCOV
346
            return;
×
347
        }
348

349
        $basename = basename($path);
9✔
350

351
        // Check both directory name and full path exclusion
352
        if ('' !== $basename && $this->filter->shouldIgnoreDirectory($basename)) {
9✔
UNCOV
353
            return;
×
354
        }
355

356
        if ($this->filter->shouldIgnorePath($path)) {
9✔
UNCOV
357
            return;
×
358
        }
359

360
        $this->addWatch($path);
9✔
361
        $this->scanChildren($path);
9✔
362
    }
363

364
    private function removeWatch(
365
        int $descriptor,
366
        string $path,
367
    ): void {
368
        // Clean up kernel resources by removing the inotify watch
369
        // This prevents file descriptor leaks that cause restart loops
UNCOV
370
        @inotify_rm_watch($this->inotify, $descriptor);
×
371

372
        // Clean up internal mappings
UNCOV
373
        unset($this->watchDescriptorToPath[$descriptor], $this->pathToWatchDescriptor[$path]);
×
374
    }
375

376
    private function retryPendingRegistrations(): void
377
    {
378
        if ([] === $this->pendingRegistrations) {
3✔
379
            return;
3✔
380
        }
381

382
        foreach (array_keys($this->pendingRegistrations) as $path) {
×
UNCOV
383
            unset($this->pendingRegistrations[$path]);
×
UNCOV
384
            $this->registerDirectoryRecursively($path);
×
385
        }
386
    }
387

388
    private function scanChildren(
389
        string $directory,
390
    ): void {
391
        $handle = opendir($directory);
9✔
392

393
        if (false === $handle) {
9✔
UNCOV
394
            return;
×
395
        }
396

397
        try {
398
            while (($entry = readdir($handle)) !== false) {
9✔
399
                if ('.' === $entry || '..' === $entry) {
9✔
400
                    continue;
9✔
401
                }
402

403
                $child = $directory . DIRECTORY_SEPARATOR . $entry;
1✔
404
                $realChild = $this->normalisePath($child);
1✔
405

406
                if (null === $realChild || !is_dir($realChild)) {
1✔
UNCOV
407
                    continue;
×
408
                }
409

410
                $basename = basename($realChild);
1✔
411

412
                // Check both directory name and full path exclusion
413
                if ('' !== $basename && $this->filter->shouldIgnoreDirectory($basename)) {
1✔
UNCOV
414
                    continue;
×
415
                }
416

417
                if ($this->filter->shouldIgnorePath($realChild)) {
1✔
UNCOV
418
                    continue;
×
419
                }
420

421
                if (!isset($this->pathToWatchDescriptor[$realChild])) {
1✔
422
                    $this->addWatch($realChild);
1✔
423
                    $this->scanChildren($realChild);
1✔
424
                }
425
            }
426
        } finally {
427
            closedir($handle);
9✔
428
        }
429
    }
430
}
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