Fix bug #80056: SPL directory iterators lose entries on 9p filesystems - #23461
Open
denkfabrik-li wants to merge 1 commit into
Open
Fix bug #80056: SPL directory iterators lose entries on 9p filesystems#23461denkfabrik-li wants to merge 1 commit into
denkfabrik-li wants to merge 1 commit into
Conversation
FilesystemIterator, RecursiveDirectoryIterator and DirectoryIterator read the first directory entry eagerly in their constructor. The implicit rewind() at the start of the first foreach then called rewinddir() on a stream whose position was already past the first entry. On filesystems that cannot seek a directory handle after a partial read - most notably 9p mounts as used by WSL2 and Docker Desktop on Windows - that seek is silently ignored and the entries already buffered by the C library are lost: an entire libc getdents buffer (about 21 entries with musl, about 343 with glibc) disappears from the iteration, while scandir(), glob() and plain readdir() see the full listing. Track whether the directory stream is still positioned at its first entry; rewinding is a no-op in that state. The constructor still opens the directory eagerly (an invalid path keeps throwing UnexpectedValueException) and still pre-reads the first entry, so the observable object state after construction is unchanged. A rewind after any entry has been consumed keeps performing a real rewinddir() as before.
denkfabrik-li
force-pushed
the
spl-directory-rewind-noop
branch
from
August 25, 2026 20:31
ad16a36 to
ffb35f9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FilesystemIterator,RecursiveDirectoryIteratorandDirectoryIteratorsilentlylose 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
getdentsbuffervanishes from the iteration: ~21 entries with musl (2 KiB buffer), ~343 with glibc
(32 KiB buffer).
scandir(),glob()and a plainreaddir()loop over the samedirectory 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()atthe start of the first
foreachthen callsrewinddir()on a stream whose positionis 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-
foreachpattern never seeks at all.History
files under Docker/WSL2) — closed "Not a bug": "this is unlikely to be a PHP issue".
closed "Not a bug": "it's a WSL bug", pointing to WSL2: Seek of directory entry by lseek does not work on v9fs microsoft/WSL#5074.
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
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.dirwhether the stream is still positioned at itsfirst (non-skipped) entry (
at_initial_entry). All four rewind paths(
DirectoryIterator::rewind(),FilesystemIterator::rewind(), and the two internalzend_object_iteratorrewind handlers) are consolidated into one helper,spl_filesystem_dir_rewind(), which skips therewinddir()+ re-read when nothinghas been consumed yet. Any actual read (
next(), internalmove_forward, theclone catch-up loop,
seek()) clears the flag, so a rewind after real consumptionstill performs a real
rewinddir()exactly as before.Behaviour / BC analysis
Unchanged:
UnexpectedValueExceptionfrom the constructor.$it->current(),getFilename(),key(),hasChildren()etc. right after construction behaveexactly as before (and now also work on 9p, since the pre-read is sequential).
rewind()after any entry has been consumed performs a realrewinddir()+re-read, as before (re-iterating an iterator,
seek()backwards, clone).SKIP_DOTS, …) on healthyfilesystems: byte-for-byte identical output; the full test suite passes.
Observable differences (deliberate):
rewind()no longer issuesrewinddir()+readdir(). For userland stream wrappers this meansdir_rewinddir()is no longer invoked at the start of the first iteration (onecallback 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.
rewinddir()to pick up directory modifications made afteropendir(). A file created between construction and the firstforeachwaspreviously 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, explicitrewind()afternext()) stillrequires a real
rewinddir()and therefore still misbehaves on 9p — same as auserland
rewinddir()call. This patch fixes the overwhelmingly commonconstruct-then-iterate-once pattern, which is what Composer/Laravel/PHPStan & co.
use.
Tests
ext/spl/tests/bug80056.phpt— simulates the broken filesystem with a userlandstream wrapper whose
dir_rewinddir()pretends to succeed without resetting theposition (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 semanticswith a counting wrapper: no
dir_rewinddir()on first iteration, exactly one onre-iteration, exactly one for
rewind()afternext(), none for repeatedrewind()without reads.Verification
Two-stage, with unpatched and patched CLI binaries built from the same master
checkout (
ext/spl/spl_directory.*diff applied incrementally in the samecontainer build):
(a) Test suite (ext4 inside the build containers):
Full
ext/splsuite with the patched CLI — identical results onDebian bookworm (glibc) and Alpine 3.22 (musl):
A second build with
--enable-phar --enable-zend-testadditionally runs theSPL-over-phar and stack-limit tests (gh14687, gh17225, gh15911, gh15672 — all
pass; 802 SPL tests passed total) plus the full
ext/pharsuite: 382 runnabletests, 0 failures. Relevant because
PharextendsRecursiveDirectoryIteratorand embeds
spl_filesystem_object; its in-memory directory stream keeps aworking seek, so behaviour is unchanged there.
Both new tests FAIL on unpatched master, each iterator losing its first entry
(
array(4)instead ofarray(5),a.txtmissing) — i.e. the regression testsdemonstrably catch the bug.
(b) Live 9p mount (Docker Desktop on Windows 11, WSL2 backend; a Windows
directory with 500
.phpfiles — plus 1 in a subdirectory for the recursivecase — mounted into the container;
stat -f -c %Treportsv9fs):scandir()glob()readdir()loopFilesystemIteratorRecursiveDirectoryIteratorDirectoryIteratorThe losses match the libc buffer sizes exactly: musl drops its first 2 KiB
getdentsbuffer (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):