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

valksor / php-dev-build / 19384258487

15 Nov 2025 04:07AM UTC coverage: 19.747% (+2.5%) from 17.283%
19384258487

push

github

k0d3r1s
prettier

16 of 30 new or added lines in 4 files covered. (53.33%)

516 existing lines in 7 files now uncovered.

484 of 2451 relevant lines covered (19.75%)

1.03 hits per line

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

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

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

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

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

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

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

106
    /**
107
     * Inotify instance resource for kernel communication.
108
     *
109
     * @var resource
110
     */
111
    private $inotify;
112

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

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

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

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

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

161
        // Initialize inotify instance for kernel communication
162
        $this->inotify = inotify_init();
11✔
163

164
        if (!is_resource($this->inotify)) {
11✔
UNCOV
165
            throw new RuntimeException('Failed to initialise inotify.');
×
166
        }
167

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

172
        $this->callback = $onChange;
11✔
173
    }
174

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

189
            // Close the inotify instance to free file descriptors
190
            @fclose($this->inotify);
11✔
191
        }
192
    }
193

194
    public function addRoot(
195
        string $path,
196
    ): void {
197
        $real = $this->normalisePath($path);
10✔
198

199
        if (null === $real || isset($this->registeredRoots[$real])) {
10✔
200
            return;
2✔
201
        }
202

203
        $this->registeredRoots[$real] = true;
9✔
204
        $this->registerDirectoryRecursively($real);
9✔
205
    }
206

207
    public function getStream()
208
    {
209
        return $this->inotify;
1✔
210
    }
211

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

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

222
            return;
1✔
223
        }
224

225
        // Process each inotify event
226
        foreach ($events as $event) {
2✔
227
            $this->handleEvent($event);
2✔
228
        }
229

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

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

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

247
        if (false === $descriptor) {
9✔
UNCOV
248
            $this->pendingRegistrations[$path] = true;
×
249

UNCOV
250
            return;
×
251
        }
252

253
        $this->watchDescriptorToPath[$descriptor] = $path;
9✔
254
        $this->pathToWatchDescriptor[$path] = $descriptor;
9✔
255
    }
256

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

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

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

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

UNCOV
289
            return; // Don't notify for ignored events
×
290
        }
291

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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