Skip to content

[5.x]: Concurrent requests right after clearing "Control panel resources" can leave a large asset bundle permanently, partially published (AssetManager::publishDirectory() is not lock-protected) #19477

Description

@idleog

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:

  1. Request A finds $dstDir missing, starts copyDirectory() (which creates the directory near-immediately, then copies ~300 files).
  2. 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.
  3. 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.
  4. 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

  1. Use a Craft install with a reasonably large custom/plugin asset bundle (CKEditor's own bundle works well — 300+ files).
  2. Utilities → Clear Caches → check only "Control panel resources" → Clear Caches.
  3. 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.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions