What happened?
Claude Code found this and provided this report. I can confirm the behaviour and it fixed the problem in my project that from time to time my client lost the appearance of CKEditor fields in the backend, without any luck for me to reproduce that in a reliable way.
Description
After using Utilities → Clear Caches → "Control panel resources" (which deletes web/cpresources/* and truncates the resourcepaths table), the next control panel page load can leave a large asset bundle (in our case craftcms/ckeditor's dist/ folder, ~40MB / 302 files) permanently, partially copied — some files inside the freshly-created hash folder are missing forever, even after repeated reloads, until the folder is deleted by hand and republished in a single uncontended request.
This is not specific to a load-balanced / non-shared-filesystem setup (unlike #9738) — it reproduces on a single server with a single shared filesystem, purely from PHP-FPM handling several concurrent requests for the same page's assets.
Root cause (as far as we can tell)
\craft\helpers\App::resourcePathByUri() calls $assetManager->publish($sourcePath) whenever a requested cpresources file doesn't exist on disk yet. That eventually reaches \yii\web\AssetManager::publishDirectory():
} elseif (!empty($options['forceCopy']) || ($this->forceCopy && !isset($options['forceCopy'])) || !is_dir($dstDir)) {
...
FileHelper::copyDirectory($src, $dstDir, $opts);
}
The only guard against re-copying is is_dir($dstDir). There's no lock. A browser loading one CP page typically fires off several parallel requests for different files belonging to the same bundle (JS, CSS, per-language translation files, source maps, …). If several of those requests hit _processResourceRequest() at (almost) the same moment, right after web/cpresources/ was just emptied:
- Request A finds
$dstDir missing, starts copyDirectory() (which creates the directory near-immediately, then copies ~300 files).
- Request B, C, … (separate PHP-FPM workers, same few milliseconds) also resolve the same hash, see
is_dir($dstDir) === true (A already created it), and skip copying entirely — even though A's copy is still in progress.
- Whichever specific file B/C/… needed isn't there yet →
resourcePathByUri() throws InvalidArgumentException("$filePath does not exist.") → the browser gets a 400 for that script/css/translation file.
- Because the directory now "exists" (per the
is_dir() check), no later request will ever retry the copy, even though it's incomplete. The specific missing file(s) 404/400 forever, for every subsequent request, until someone manually deletes that hash folder.
We confirmed this via storage/logs/web-*.log: multiple different files (an unrelated Craft-core bundle's locale file, and several files from the CKEditor bundle) threw "$file does not exist" within a few seconds of each other after a single cache-clear — consistent with several concurrent requests each hitting a different missing file inside the same half-copied directory, not with a single one-off failure.
Why this is easy to miss
- It doesn't reproduce reliably on a low-traffic/low-concurrency dev environment (e.g. DDEV with few PHP workers and fast local disk) — the copy usually finishes before a second concurrent request can race it there.
- It reproduces reliably on a shared-hosting environment with more PHP-FPM workers and slower disk I/O for a ~40MB/300-file bundle, immediately after using the CP's own "Clear Caches" utility.
- The resulting symptom (a large CKEditor field's toolbar/translations silently failing to load, or breaking third-party plugins that also publish sizeable bundles) looks like an unrelated JS/plugin bug rather than an infra issue, and is hard to reproduce on demand.
Workaround we're using
Setting linkAssets = true on the assetManager component (via config/app.php) avoids the issue entirely, since symlink() is a single atomic syscall with no partially-completed state:
'components' => [
'assetManager' => function() {
$config = \craft\helpers\App::assetManagerConfig();
$config['linkAssets'] = true;
return Craft::createObject($config);
},
],
This isn't something we'd expect every Craft installation to want as a default (it exposes the real vendor path via the symlink target, and needs FollowSymLinks allowed on the web server), but it might be worth documenting as a recommended mitigation, and/or publishDirectory()/publish() could use a lock (e.g. flock() on a marker file per hash, or copy-to-temp-dir-then-atomic-rename()) so a partial copy can never be observed as "done" by a concurrent request.
Steps to reproduce
- Use a Craft install with a reasonably large custom/plugin asset bundle (CKEditor's own bundle works well — 300+ files).
- Utilities → Clear Caches → check only "Control panel resources" → Clear Caches.
- Immediately open a CP page that uses that bundle (e.g. an entry with a CKEditor field), ideally with dev tools' Network tab open and throttled slightly, or just on a host with several concurrent PHP-FPM workers under some load.
- Some of the bundle's files may 400 (
"$file does not exist" in storage/logs), and keep failing on every reload afterwards.
Reproduces reliably on shared hosting (Hostpoint) with multiple PHP-FPM workers; does not reproduce on DDEV (single container, low concurrency, fast local disk)
Craft CMS version
5.10.14
PHP version
8.3
Operating system and version
No response
Database type and version
No response
Image driver and version
No response
Installed plugins and versions
What happened?
Claude Code found this and provided this report. I can confirm the behaviour and it fixed the problem in my project that from time to time my client lost the appearance of CKEditor fields in the backend, without any luck for me to reproduce that in a reliable way.
Description
After using Utilities → Clear Caches → "Control panel resources" (which deletes
web/cpresources/*and truncates theresourcepathstable), the next control panel page load can leave a large asset bundle (in our casecraftcms/ckeditor'sdist/folder, ~40MB / 302 files) permanently, partially copied — some files inside the freshly-created hash folder are missing forever, even after repeated reloads, until the folder is deleted by hand and republished in a single uncontended request.This is not specific to a load-balanced / non-shared-filesystem setup (unlike #9738) — it reproduces on a single server with a single shared filesystem, purely from PHP-FPM handling several concurrent requests for the same page's assets.
Root cause (as far as we can tell)
\craft\helpers\App::resourcePathByUri()calls$assetManager->publish($sourcePath)whenever a requestedcpresourcesfile doesn't exist on disk yet. That eventually reaches\yii\web\AssetManager::publishDirectory():The only guard against re-copying is
is_dir($dstDir). There's no lock. A browser loading one CP page typically fires off several parallel requests for different files belonging to the same bundle (JS, CSS, per-language translation files, source maps, …). If several of those requests hit_processResourceRequest()at (almost) the same moment, right afterweb/cpresources/was just emptied:$dstDirmissing, startscopyDirectory()(which creates the directory near-immediately, then copies ~300 files).is_dir($dstDir) === true(A already created it), and skip copying entirely — even though A's copy is still in progress.resourcePathByUri()throwsInvalidArgumentException("$filePath does not exist.")→ the browser gets a 400 for that script/css/translation file.is_dir()check), no later request will ever retry the copy, even though it's incomplete. The specific missing file(s) 404/400 forever, for every subsequent request, until someone manually deletes that hash folder.We confirmed this via
storage/logs/web-*.log: multiple different files (an unrelated Craft-core bundle's locale file, and several files from the CKEditor bundle) threw"$file does not exist"within a few seconds of each other after a single cache-clear — consistent with several concurrent requests each hitting a different missing file inside the same half-copied directory, not with a single one-off failure.Why this is easy to miss
Workaround we're using
Setting
linkAssets = trueon theassetManagercomponent (viaconfig/app.php) avoids the issue entirely, sincesymlink()is a single atomic syscall with no partially-completed state:This isn't something we'd expect every Craft installation to want as a default (it exposes the real vendor path via the symlink target, and needs
FollowSymLinksallowed on the web server), but it might be worth documenting as a recommended mitigation, and/orpublishDirectory()/publish()could use a lock (e.g.flock()on a marker file per hash, or copy-to-temp-dir-then-atomic-rename()) so a partial copy can never be observed as "done" by a concurrent request.Steps to reproduce
"$file does not exist"instorage/logs), and keep failing on every reload afterwards.Reproduces reliably on shared hosting (Hostpoint) with multiple PHP-FPM workers; does not reproduce on DDEV (single container, low concurrency, fast local disk)
Craft CMS version
5.10.14
PHP version
8.3
Operating system and version
No response
Database type and version
No response
Image driver and version
No response
Installed plugins and versions