[php-src] PR #23461: Fix bug #80056: SPL directory iterators lose entries on 9p filesystems

From: Date: Tue, 25 Aug 2026 18:32:27 +0000
Subject: [php-src] PR #23461: Fix bug #80056: SPL directory iterators lose entries on 9p filesystems
Groups: php.git-pulls 
Request: Send a blank email to git-pulls+get-38139@lists.php.net to get a copy of this message
Pull Request: https://github.com/php/php-src/pull/23461 Author: denkfabrik-li ### Summary FilesystemIterator, RecursiveDirectoryIterator and DirectoryIterator silently lose directory entries on filesystems that cannot rewind a directory handle after a partial read — most notably 9p mounts, which is what WSL2 and Docker Desktop on Windows use to expose the Windows drive to Linux. An entire libc getdents buffer vanishes from the iteration: ~21 entries with musl (2 KiB buffer), ~343 with glibc (32 KiB buffer). scandir(), glob() and a plain readdir() loop over the same directory return the complete listing. The cause is PHP-specific behaviour, not just the kernel bug: the iterator constructors eagerly read the first directory entry, and the implicit rewind() at the start of the first foreach then calls rewinddir() on a stream whose position is already past that first entry. On a healthy filesystem this seek is redundant (it re-reads the entry the constructor already had); on 9p the seek is silently ignored server-side while the C library discards its read buffer — everything that was in that buffer is skipped. This PR makes the rewind a no-op while the stream is still positioned at its first entry, so the standard construct-then-foreach pattern never seeks at all. ### History - https://bugs.php.net/bug.php?id=80056 (Sep 2020, RecursiveDirectoryIterator misses files under Docker/WSL2) — closed "Not a bug": *"this is unlikely to be a PHP issue"*. - https://bugs.php.net/bug.php?id=80227 (Oct 2020, same on Alpine/Docker/WSL2) — closed "Not a bug": *"it's a WSL bug"*, pointing to microsoft/WSL#5074. - https://github.com/microsoft/WSL/issues/5074 — closed by the bot without a fix; still reproducible today (measurements below, Windows 11, current WSL2/Docker Desktop). Both reports were closed on the grounds that the underlying seek failure lives below PHP — which is true. But no code change was ever proposed back then, and the seek itself is avoidable: it is issued by PHP at a moment where it has nothing to do. Every other PHP directory API (scandir(), glob(), opendir()/readdir()) works on these mounts precisely because none of them seeks a partially-read handle. The failure mode is nasty in practice because it is silent, size-dependent (only directories with more entries than one libc buffer are affected) and environment-dependent (works on ext4, breaks on the very same code under Docker Desktop on Windows). Real-world sightings include Composer autoload scans, Laravel migration discovery and PHPStan/Larastan file collection: laravel/framework#61336, larastan/larastan#2538, projectsend/projectsend#1680. ### The mechanism ```text new FilesystemIterator($dir) # constructor opens the dir -> readdir() # ...and eagerly reads entry #1 # libc fills its getdents buffer foreach ($it as $f) # foreach starts with rewind() -> rewinddir() # seek to offset 0 # 9p: seek silently ignored, # libc buffer discarded anyway -> readdir(), readdir(), ... # continues AFTER the discarded buffer ``` With musl's 2 KiB buffer the first ~21 entries disappear; with glibc's 32 KiB buffer the first ~343. Directories at or below one buffer appear complete, which is why small test cases pass and production directories fail. ### The fix Track in spl_filesystem_object.u.dir whether the stream is still positioned at its first (non-skipped) entry (at_initial_entry). All four rewind paths (DirectoryIterator::rewind(), FilesystemIterator::rewind(), and the two internal zend_object_iterator rewind handlers) are consolidated into one helper, spl_filesystem_dir_rewind(), which skips the rewinddir() + re-read when nothing has been consumed yet. Any actual read (next(), internal move_forward, the clone catch-up loop, seek()) clears the flag, so a rewind after real consumption still performs a real rewinddir() exactly as before. ### Behaviour / BC analysis Unchanged: - The constructor still opens the directory eagerly — an invalid path still throws UnexpectedValueException from the constructor. - The constructor still pre-reads the first entry: $it->current(), getFilename(), key(), hasChildren() etc. right after construction behave exactly as before (and now also work on 9p, since the pre-read is sequential). - rewind() after any entry has been consumed performs a real rewinddir() + re-read, as before (re-iterating an iterator, seek() backwards, clone). - Result sets, ordering, keys, flags handling (SKIP_DOTS, …) on healthy filesystems: byte-for-byte identical output; the full test suite passes. Observable differences (deliberate): - While the stream is still on its first entry, rewind() no longer issues rewinddir() + readdir(). For userland stream wrappers this means dir_rewinddir() is no longer invoked at the start of the first iteration (one callback less; a wrapper that relied on being rewound before the first read was already broken, since the constructor pre-read has always happened before any rewind). One redundant syscall pair per iteration start is saved everywhere. - POSIX allows rewinddir() to pick up directory modifications made after opendir(). A file created between construction and the first foreach was previously *sometimes* visible (filesystem-dependent, POSIX explicitly leaves it unspecified); with the no-op rewind the iteration keeps the view the constructor started with. Code relying on that was already unreliable across filesystems. Known limitation (unchanged, inherent to broken seeks): re-iterating the *same* iterator object (second foreach, explicit rewind() after next()) still requires a real rewinddir() and therefore still misbehaves on 9p — same as a userland rewinddir() call. This patch fixes the overwhelmingly common construct-then-iterate-once pattern, which is what Composer/Laravel/PHPStan & co. use. ### Tests - ext/spl/tests/bug80056.phpt — simulates the broken filesystem with a userland stream wrapper whose dir_rewinddir() pretends to succeed without resetting the position (exactly the observable 9p behaviour). Fails on current master (each iterator loses its first entry), passes with the fix. Covers DirectoryIterator, FilesystemIterator, RecursiveDirectoryIterator + RecursiveIteratorIterator, and the "entry accessed before iteration" pattern. - ext/spl/tests/spl_dir_iterator_rewind_noop.phpt — pins the new seek semantics with a counting wrapper: no dir_rewinddir() on first iteration, exactly one on re-iteration, exactly one for rewind() after next(), none for repeated rewind() without reads. - Existing SPL suite: green (see verification below). ### Verification Two-stage, with unpatched and patched CLI binaries built from the *same* master checkout (ext/spl/spl_directory.* diff applied incrementally in the same container build): **(a) Test suite (ext4 inside the build containers):** Full ext/spl suite with the patched CLI — identical results on Debian bookworm (glibc) and Alpine 3.22 (musl): ```text Number of tests : 810 799 Tests skipped : 11 ( 1.4%) Tests failed : 0 ( 0.0%) Expected fail : 1 ( 0.1%) (pre-existing XFAIL, unrelated) Tests passed : 798 ( 98.5%) ``` A second build with --enable-phar --enable-zend-test additionally runs the SPL-over-phar and stack-limit tests (gh14687, gh17225, gh15911, gh15672 — all pass; 802 SPL tests passed total) plus the full ext/phar suite: 382 runnable tests, 0 failures. Relevant because Phar extends RecursiveDirectoryIterator and embeds spl_filesystem_object; its in-memory directory stream keeps a working seek, so behaviour is unchanged there. Both new tests FAIL on unpatched master, each iterator losing its first entry (array(4) instead of array(5), a.txt missing) — i.e. the regression tests demonstrably catch the bug. **(b) Live 9p mount (Docker Desktop on Windows 11, WSL2 backend; a Windows directory with 500 .php files — plus 1 in a subdirectory for the recursive case — mounted into the container; stat -f -c %T reports v9fs):** | API | musl unpatched | musl patched | glibc unpatched | glibc patched | |------------------------------|---------------:|-------------:|----------------:|--------------:| | scandir() | 500 | 500 | 500 | 500 | | glob() | 500 | 500 | 500 | 500 | | readdir() loop | 500 | 500 | 500 | 500 | | FilesystemIterator | **479** | 500 | **157** | 500 | | RecursiveDirectoryIterator | **480** | 501 | **158** | 501 | | DirectoryIterator | **479** | 500 | **157** | 500 | The losses match the libc buffer sizes exactly: musl drops its first 2 KiB getdents buffer (21 entries), glibc its first 32 KiB buffer (343 entries). One-liner reproducer for anyone with Docker Desktop on Windows (stock image, directory with a few hundred files): ```text > docker run --rm -v C:\some\big\dir:/mnt/d php:8.4-cli php -r \ "echo count(scandir('/mnt/d'))-2, ' vs ', iterator_count(new FilesystemIterator('/mnt/d'));" 501 vs 158 # PHP 8.4.24, verified 2026-08-25 ```

« previous php.git-pulls (#38139) next »