• 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

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
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 function_exists;
24
use function inotify_add_watch;
25
use function inotify_init;
26
use function inotify_read;
27
use function inotify_rm_watch;
28
use function is_dir;
29
use function is_resource;
30
use function opendir;
31
use function readdir;
32
use function realpath;
33
use function rtrim;
34
use function stream_set_blocking;
35

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

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

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

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

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

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

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

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

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

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

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

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

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

173
        $this->callback = $onChange;
14✔
174
    }
175

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

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

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

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

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

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

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

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

223
            return;
1✔
224
        }
225

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

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

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

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

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

251
            return;
×
252
        }
253

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

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

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

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

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

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

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

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

2✔
304
            return; // Don't notify for self-deletion events
×
305
        }
UNCOV
306

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

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

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

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

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

1✔
329
        $real = realpath($trimmed);
330

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

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

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

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

×
347
        $basename = basename($path);
348

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

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

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

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

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

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

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

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

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

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

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

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

×
408
                $basename = basename($realChild);
409

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

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

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