@@ -164,4 +164,60 @@ public function testMoveNestedJail(): void {
164164 $ jail3 ->moveFromStorage ($ jail2 , 'file.txt ' , 'file.txt ' );
165165 $ this ->assertTrue ($ this ->instance ->file_exists ('target/file.txt ' ));
166166 }
167+
168+ /**
169+ * A path that changes from file to directory in another process leaves a stale
170+ * "not a directory" entry behind in the realpath cache of this process. Opening a
171+ * file below such a path then fails with ENOENT for up to realpath_cache_ttl
172+ * seconds, even though stat() reports the file as present.
173+ *
174+ * The type change has to happen out of process on purpose: PHP drops its own
175+ * realpath cache entry when it is the one calling unlink() and mkdir(), so doing
176+ * it here would leave nothing stale to recover from and the test would pass either
177+ * way. This is what made the file drop integration suite fail intermittently, one
178+ * php -S worker having cached the path while another one turned it into a folder.
179+ */
180+ public static function dataStaleRealpathCache (): array {
181+ return [
182+ // Invalidating only the direct parent passes the first case and fails the
183+ // second, so both depths have to stay covered.
184+ 'stale direct parent ' => ['staleName ' => 'folder ' , 'filePath ' => 'folder/a.txt ' ],
185+ 'stale grandparent ' => ['staleName ' => 'nickname ' , 'filePath ' => 'nickname/folder/a.txt ' ],
186+ ];
187+ }
188+
189+ #[\PHPUnit \Framework \Attributes \DataProvider('dataStaleRealpathCache ' )]
190+ public function testFopenRecoversFromStaleRealpathCache (string $ staleName , string $ filePath ): void {
191+ if (!function_exists ('exec ' )) {
192+ $ this ->markTestSkipped ('exec() is required to change the path type out of process ' );
193+ }
194+
195+ $ stalePath = $ this ->tmpDir . $ staleName ;
196+ $ file = $ this ->tmpDir . $ filePath ;
197+
198+ // Reading it while it is still a file is what puts the "not a directory" entry
199+ // into the realpath cache, so the handle is opened only for that side effect
200+ $ this ->instance ->file_put_contents ($ staleName , 'its a file ' );
201+ $ handle = $ this ->instance ->fopen ($ staleName , 'r ' );
202+ $ this ->assertIsResource ($ handle );
203+ fclose ($ handle );
204+
205+ if ((realpath_cache_get ()[$ stalePath ]['is_dir ' ] ?? null ) !== false ) {
206+ $ this ->markTestSkipped ('the realpath cache of this setup does not hold the directory flag ' );
207+ }
208+
209+ exec (
210+ 'rm -f ' . escapeshellarg ($ stalePath )
211+ . ' && mkdir -p ' . escapeshellarg (dirname ($ file ))
212+ . ' && printf abc > ' . escapeshellarg ($ file ),
213+ $ output ,
214+ $ status
215+ );
216+ $ this ->assertSame (0 , $ status , 'failed to replace the file with a directory ' );
217+
218+ $ handle = $ this ->instance ->fopen ($ filePath , 'r ' );
219+ $ this ->assertIsResource ($ handle , 'fopen() has to recover from the stale realpath cache entry ' );
220+ $ this ->assertSame ('abc ' , stream_get_contents ($ handle ));
221+ fclose ($ handle );
222+ }
167223}
0 commit comments