Problem/Motivation
This wawa fixed on main but the fix is something we can't backport.
HookCollectorPass::collectModuleHookImplementations() scans each installed module's directory to find hook implementations. The scan has no upper bound on where it can go:
$iterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS); $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...));
and filterIterator() prunes tests, js, css, whatever $settings['file_scan_ignore_directories'] names, and directories that contain an .info.yml. Two things get through:
1. Hidden directories are scanned. FilesystemIterator::SKIP_DOTS skips . and .., not .git, .devenv, .direnv or .venv. ExtensionDiscovery, which walks the same trees for the same purpose, skips them on the first lines of its filter, with a comment that says exactly why:
// RecursiveExtensionFilterCallback::accept() // FilesystemIterator::SKIP_DOTS only skips '.' and '..', but not hidden // directories (like '.git'). if ($name[0] === '.') { return FALSE; }
2. Symlinks are followed however far out of the module they lead. A symlink several levels down inside a module is followed into whatever it points at — another checkout, a package store, a home directory.
What the existing setting already covers
Worth ruling out first, because it is the obvious objection. #3564112: Cache rebuild triggers file_get_contents Permission denied in theme node_modules despite file_scan_ignore_directories taught filterIterator() to honour $settings['file_scan_ignore_directories'], and every site's settings.php ships node_modules and bower_components in it. That works. Driving the collector over a module with a hook planted in three places, on main without this change:
| hook in | shipped default | setting emptied |
|---|---|---|
| the module root | collected | collected |
node_modules/pkg/ |
not collected | collected |
.hidden/ |
collected | collected |
So a module shipping a front-end build is handled, and a site can add its own names to that list. What the setting does not reach is the two cases above: its default names two directories and nothing in it covers .git or .devenv, and no list of names stops a symlink that leaves the module altogether.
#3564112: Cache rebuild triggers file_get_contents Permission denied in theme node_modules despite file_scan_ignore_directories is worth reading alongside this: it came in from a theme's node_modules, which is the same shape of problem from a different direction.
Where it bites
A module symlinked in from a working checkout — the ordinary way to develop contrib against a site, and the case #3482283 added FOLLOW_SYMLINKS for. The checkout carries whatever tooling state the developer has, none of it named in file_scan_ignore_directories:
| in the checkout | what the scan reaches |
|---|---|
.devenv/, .direnv/ |
a symlink into a /nix/store closure |
.git/ |
the object store |
.venv/, .tox/, editor and test caches |
whatever they hold |
| a symlinked subdirectory anywhere below the module | wherever it points |
Core's own test tooling trips on the same tree, which is worth mentioning as corroboration: core/phpunit.xml.dist declares testsuites over ../modules/*/**/tests/src/Kernel and friends, and PHPUnit's directory globbing follows symlinks too. On the site this was found on, running any kernel test hung until the offending directory was moved out of the tree. So the assumption that a module directory is bounded is not confined to HookCollectorPass — though the collector is where it costs every rebuild.
Only drush cr and other container rebuilds are affected, since that is when the collector runs. That also makes it easy to misdiagnose: drush cache:clear bin discovery stays instant while a newly added hook stays undiscovered, so function_exists() reports TRUE while hasImplementations() reports FALSE, which sends you looking in the wrong place.
Steps to reproduce
- Check out a module beside a site and symlink it into place:
ln -s ../../../../mymodule web/modules/contrib/mymodule - In the checkout, create tooling state whose symlinks leave the tree. devenv and nix-direnv do this by themselves; by hand:
mkdir -p .devenv && ln -s /nix/store/… .devenv/profile time drush cr
Adding .devenv to $settings['file_scan_ignore_directories'] works around it, which is the point: a developer has to know to do that, for each name their tooling leaves behind.
Measured
A symlinked module checkout with .devenv in it. The module is 926 directories; following one symlink in .devenv reaches 38,676.
| user | system | wall | |
|---|---|---|---|
| unpatched | 14s | 262s | >5 min (killed at 300s) |
| patched | 0.7s | 0.4s | 1.4s |
Almost all of it is system time — readdir/stat, not work. A stack sample during the hang sits in RecursiveCallbackFilterIterator. find -L over the same tree also reports 15 Too many levels of symbolic links errors.
Proposed resolution
Two guards in filterIterator(), in the merge request:
// FilesystemIterator::SKIP_DOTS only skips '.' and '..', but not hidden // directories (like '.git', or a developer's '.devenv'). ExtensionDiscovery // skips them too, and a module cannot be installed from one anyway. if (str_starts_with($fileInfo->getFilename(), '.')) { return FALSE; } // Follow symlinks only at the top level of the module directory. A module // may be symlinked into place, and items may be symlinked into a module // directory — see HookCollectorPassTest::testSymlink — but a symlink deeper // in is as likely to lead out of the module altogether, into a store // closure or a package cache, and the scan has no business following it // there. if ($fileInfo->isLink() && str_contains($sub_path_name, '/')) { return FALSE; }
Verified against the real collector on fixture modules:
| expected | observed | |
|---|---|---|
| hook in the module root | found | found |
| hook behind a top-level symlink | found | found |
| hook behind a symlink one level down | not found | not found |
| hook inside a hidden directory | not found | not found |
The second row is the one that matters for #3482283: symlinks at the top level are still followed, so testSymlink() — which symlinks a module's individual items into the scanned directory — keeps passing.
Remaining tasks
- [ ] Decide whether
vendorbelongs in the prune list, as it is inExtensionDiscovery::$skippedFolders. A module shipping its ownvendor/has it walked on every rebuild. Possibly a follow-up.
User interface changes
None.
API changes
None. filterIterator() is protected static on a compiler pass.
Behaviour change: a hook implementation inside a hidden directory, or behind a symlink below a module's top level, stops being registered. Neither is a documented place to put one, and ExtensionDiscovery already refuses to find an extension in a hidden directory at all, so a module could not be installed from one.
Data model changes
None.
Alternatives considered
Drop FOLLOW_SYMLINKS and realpath() the scan root. Tempting, because a symlinked module directory iterates correctly without the flag — passing the symlink as the root works either way, and the module's own files are still found (measured: 5,039 files reached, .info.yml among them). But testSymlink() symlinks the module's individual items into the scanned directory, so those are symlinks inside the root; dropping the flag breaks that test. The flag has to stay. What needs bounding is the depth.
Confine the scan to the web root. Cannot work: the module directory is inside the web root, and it is the symlink target that lies outside, so the check would have to reject exactly what #3482283 enabled.
A general setMaxDepth(). An arbitrary depth limit would silently stop finding hooks in legitimately deep structures. A hook quietly not registering is worse than scanning too much.
Leaving it to file_scan_ignore_directories. Already honoured since #3564112: Cache rebuild triggers file_get_contents Permission denied in theme node_modules despite file_scan_ignore_directories, and it is the right mechanism for names a site knows about — the table above shows it working. But it does not close this: the default names two directories, so .git and .devenv still get walked on a stock site, and no list of names stops a symlink that leaves the module. The guards bound the scan; the setting lets a site trim it further.
Adding .devenv and friends to the shipped default. Playing whack-a-mole with tool names, and it would still miss the symlink case. Skipping hidden directories covers the whole class at once, and matches what ExtensionDiscovery already does.
Tested
On main at 11.2.0-rc1-2036-g2bee6d53513, PHP 8.5.9, PHPUnit 12.5.17, MariaDB.
On unpatched main the two new tests fail, and the existing testSymlink() passes:
.FF 3 / 3 (100%) 1) Drupal\KernelTests\Core\Hook\HookCollectorPassTest::testHiddenDirectoryIsNotScanned Failed asserting that an array does not have the key 'hidden_thing'. 2) Drupal\KernelTests\Core\Hook\HookCollectorPassTest::testSymlinkBelowModuleRootIsNotFollowed Failed asserting that an array does not have the key 'outside_thing'. Tests: 3, Assertions: 6, Failures: 2.
With the change, the whole class passes:
.................. 18 / 18 (100%) OK (18 tests, 62 assertions)
So the tests fail for the reason they are meant to, and nothing else in the class regresses — testSymlink() included, which is the one that has to keep working. core/phpcs.xml.dist is clean on both changed files.
AI-Generated
Yes (Used Opus 5 to draft and revise).
| Comment | File | Size | Author |
|---|
Issue fork drupal-3616497
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
miklComment #3
miklComment #4
miklSorry, I made this for 11.4.5, I'll update it for main.
Comment #5
miklUpdated summary for main.
Comment #6
miklComment #7
miklComment #8
miklComment #9
nicxvan commentedThank you, I'm moving this to the extension system where hook collection is tracked.
Did you use AI to assist with the issue summary? https://www.drupal.org/docs/develop/issues/issue-procedures-and-etiquett... is the policy for AI generated comments and issues.
I'd be on board with adding the skip for the . preceded directories, I think that makes sense.
We also have the ignore directories setting we can use.
I'm not sure why we'd skip symlinks though.
Comment #10
nicxvan commentedAlso, if you can post an MR, core does not use patches any longer for development, here are some docs to help you get started: https://www.drupal.org/docs/develop/git/using-gitlab-to-contribute-to-dr...
Comment #11
miklSorry, my bad, added the disclosure.
Comment #13
miklSkipping the deep symlinks is a bit of belt-and-suspenders. Skipping hidden folders by default solves my particular problem, but there could be other similar problems.
Happy to remove it if it's too conservative.
And sorry for the mess, it's been years since I last contributed to Drupal core.
Comment #14
nicxvan commentedThank you!
Let's drop the symlinking behavior change, if we do that it will break contrib testing, we had to add it so that tests would work for contrib.
I haven't reviewed the change, but I will once you make that update.
Comment #15
longwaveOut of scope for this issue but I wonder if the Symfony Finder component is more robust and battle tested for things like this than using PHP's own directory iterators.
Comment #16
nicxvan commentedI'd want to carefully benchmark that since doesn't symfony expect to do all scans like that pre deployment? I'd expect php native to be faster, but we should verify.
Comment #17
nicxvan commented@mikl also we will need to do this in ThemeHookCollectorPass.
If you get a chance could you streamline the issue summary? It's a lot to scroll past and could be a lot more concise.
Comment #18
berdirNote that #3610009: Stop discovery of hooks in include files limits the recursion to src/Hook so you'd need to have symlink in there, which is way more unlikely. But that's a main-only change.
Comment #19
nicxvan commentedI think we should postpone this on: #3610009: Stop discovery of hooks in include files
Comment #20
berdirThat issue landed, this might be a 11.x only issue at this point, especially with #3618955: Reduce necessary directory traversing in HookCollectorPass, neither of those will be backported to 11.x
Comment #21
nicxvan commentedComment #22
nicxvan commentedComment #23
nicxvan commentedThe issue summary could use significant trimming down.