php / php/php-src

file_exists() returns true for a deleted file in a long-lived FPM worker (PHP 8.5.2, open_basedir enabled) — stat()/is_file()/scandir() disagree

Open
#23,544 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Bug Status: Needs Triage
Dominant language
C
Stars
40.4k
Forks
8.1k
Avg merge
2d 13h
Merged PRs (30d)
96

Description

Description

In a long-lived PHP-FPM worker, after a file has been created, required and then unlink()ed (the WordPress .maintenance cycle), file_exists() keeps returning true for it, while is_file(), stat(), realpath(), fopen() and scandir() all report the file as gone. clearstatcache(true, $path) has no effect; the stale answer survives until the worker process exits. Creating the file again (even empty) clears the state.

The trigger in production is WordPress' updater: it writes ABSPATH/.maintenance, core requires it on every request in wp_is_maintenance_mode(), and the updater unlink()s it when done. Afterwards, some workers still answer file_exists()true, WordPress does require '.maintenance' and dies with Failed opening required '.../.maintenance'.

The following code, run over HTTP in an affected worker while the file is absent on disk:

<?php
header('Content-Type: text/plain');
$m = __DIR__ . '/.maintenance';
clearstatcache(true, $m);
var_dump(file_exists($m));
var_dump(is_file($m));
var_dump(@stat($m));
var_dump(realpath($m));
var_dump(@fopen($m, 'r'));
var_dump(in_array('.maintenance', scandir(__DIR__), true));
stream_wrapper_restore('file');
var_dump(file_exists($m));
echo shell_exec('test -e ' . escapeshellarg($m) . ' && echo EXISTS || echo MISSING');

Resulted in this output:

bool(true)
bool(false)
bool(false)
bool(false)
bool(false)
bool(false)
bool(true)
MISSING

But I expected this output instead:

bool(false)
bool(false)
bool(false)
bool(false)
bool(false)
bool(false)
bool(false)
MISSING

The shell_exec() child inherits uid, cwd and mount namespace from the worker and confirms the file does not exist, so the kernel is consistent and the true originates inside the PHP process.

Ruled out
  • Filesystem/caching layers: FPM worker and an SSH shell are on the same host (gethostname()), same mount namespace (/proc/self/ns/mnt identical), docroot on a local ext4 mount. From the shell, os.access(path, F_OK) and os.path.exists(path) both return False.
  • OPcache disabled.
  • Custom stream wrappers / auto_prepend_file: only built-in wrappers registered, stream_wrapper_restore('file') changes nothing, auto_prepend_file empty.
  • Realpath cache TTL: state persists far beyond realpath_cache_ttl=120 with no requests in between.
  • Same worker across requests verified with getmypid().
  • PHP 8.4.17 (fpm-fcgi) on the same host and pool configuration: not reproducible; downgrading the pool fixed the issue.
Relevant configuration
  • open_basedir set for the pool (docroot, session and tmp dirs, /tmp:/usr/bin)
  • realpath_cache_ttl=120
  • disable_functions: pcntl_*, leak, dl, stream_socket_server, stream_socket_sendto
  • Extensions: Core, date, lexbor, openssl, pcre, zlib, filter, hash, json, uri, Zend OPcache, random, Reflection, SPL, session, standard, sodium, libxml, cgi-fcgi, apcu, bcmath, bz2, calendar, ctype, curl, dba, dom, fileinfo, ftp, gd, gmp, gettext, iconv, igbinary, imagick, imap, intl, mbstring, exif, msgpack, memcached, mysqlnd, mysqli, pgsql, sqlite3, PDO, pdo_mysql, pdo_pgsql, pdo_sqlite, Phar, posix, redis, shmop, SimpleXML, sockets, sysvmsg, sysvsem, sysvshm, tokenizer, xml, xmlreader, xmlwriter, xsl, soap, zip
PHP Version
8.5.2 fpm-fcgi
Operating System

Debian GNU/Linux 12 (bookworm), kernel 4.19.0-27-amd64, Apache + PHP-FPM

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the HTTP request in a long-lived PHP-FPM worker with open_basedir enabled, following the file_exists(), clearstatcache(), unlink(), stat(), realpath(), fopen(), and scandir() sequence in the report. Compare PHP 8.5.2 with 8.4.17 and confirm that the fix makes file_exists() return false after deletion without requiring the worker to exit.

Written by the indexing model from the issue text.

Assessment

Tech stack
c, php
Domain
backend, operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.