Problem/Motivation
See #3490322: Use nickic/php-parser for attribute-based plugin discovery to avoid errors for some discussion. This doesn't replace that issue, it just provides a possible alternative way to handle missing traits, which is part of what that issue deals with.
Steps to reproduce
Plugin attribute parsing relies on reflection, which relies on autoloading the class.
Because modules very often provide plugins that have dependencies on other modules which may or may not be installed, the class being discovered may not actually be a valid class.
PHP throws a catchable Error when a class implements a missing interface or extends a missing class. This allows us to just ignore those plugins, then they become available if the requisite modules are installed.
However, it throws a proper fatal error when a trait isn't available.
Proposed resolution
Add a fallback classloader that runs last. Look for classes named *Trait (because that's the only way we have to identify a trait, but nearly all Drupal code adds an Interface suffix for interfaces and Trait suffix for traits.
Also because this classloader can detect missing classes without having to pass error message strings, we can use that instead of error message string parsing in attribute discovery more generally. When we no longer need the trait workaround, that aspect of the MR can be left in place.
The original idea here was to throw an exception or trigger an error and mess around with the error handler, but throwing an exception during classloading triggers an uncatchable fatal error which is what we're trying to prevent.
Instead, when the Trait isn't found, use class_alias() to swap out the missing trait for an empty StubTrait. This allows the class to be loaded enough to run reflection on it.
It does not work when methods are aliased, but that is a real edge case.
The classloader is only added during attribute parsing, then removed again.
Remaining tasks
We should open an issue against PHP to ask whether it's possible to have the same behaviour for missing traits as for classes and interfaces - because it's possible this is an oversight from when the behaviour was changed for classes and interfaces rather than by design. If it was changed in a PHP version, we could remove this hack once we require this PHP version, but it would provide forwards compatibility until then. https://github.com/php/php-src/issues/17959 created
User interface changes
Introduced terminology
API changes
Data model changes
Release notes snippet
Issue fork drupal-3502913
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:
- 3502913-add-a-fallback
changes, plain diff MR !11030
- 3502913-test-migrate-source
changes, plain diff MR !11072
Comments
Comment #3
catchI borrowed the test coverage from #3490322: Use nickic/php-parser for attribute-based plugin discovery to avoid errors.
It doesn't pass yet, but I am able to prevent a missing trait from throwing a fatal error, and also still detect that a class/interface/trait was missing. It is quite evil but I think it will work.
Comment #4
catchThink I got it working.
It is a bit trickier than I thought although that is not suprising.
It's illegal to throw an exception from class loading, so we can't do that to make a fatal failure to load recoverable, it results in another fatal.
However, we can store if we swapped a trait in the classloader itself, then ask it after classloading whether that happened.
This works pretty nicely with one caveat:
The entire trick relies on class_alias to stub out missing traits.
The caveat is that because we stub out the trait, the class is successfully loaded in attribute parsing. So if that gets called twice, the second time, we can actually parse the attributes, which we don't want!
I added a class property in the attribute parser to store the classes we shouldn't try to parse again, just for the request, not in the filecache.
Another option would be to use class_uses() and look for our stub trait class, but that has to be done recursively and it would have to run on every class, so it seems a bit heavy.
Comment #5
catchComment #6
oily commentedAdded comments to the MR. I dont see anything else in the code that stands out as 'is that right?'
True, yes this seems quite creative approach.
Comment #7
catchThird option would be to define a very uniquely named method on StubTrait, and then check method_exists(), and if it does, then we know the class_alias logic ran, and ignore the class then. This is a huge hack but it would be fast and not rely on static caching.
@oily thanks for the review, but I just copied the tests from the other issue to have a good testing base, and am not sure if the entire approach here would be accepted or not yet.
Comment #8
oily commented@catch Ah, okay. Thanks.
Comment #9
godotislateThis approach is a clever idea, and it does decrease the chances of a fatal error.
Commented (#27) in #3490322: Use nickic/php-parser for attribute-based plugin discovery to avoid errors, where I tested a similar solution. A couple still outstanding points:
Since StubTrait doesn't have "theMethod", reflection still fatal errors.
Comment #10
catch#9.1 yes and couldn't see any way around that. We could possibly add a coding standard to enforce this though, I think we already do with Interface
#9.2 that feels impossible to cover here. It might be very rare with the sorts of classes we're discovering but if not then it would still blow up.
Another problem here is that even though the autoloader is only used in this situation, any missing traits are aliased to StubTrait for the rest of the request, so if something called trait_exists() it would return true. Might be theoretical but worth no
Comment #11
godotislateI made a suggestion that the "class_exists" check be moved to the top, even before trying to retrieve the definition from file cache. This is to skip over definitions that were previously stored in file cache but should be skipped now because modules the definitions have implicit dependencies on are now uninstalled.
IMO, given the relative simplicity of this solution, it's worth going forward on, even with the noted issues. It's better than existing without adding much complexity.
Comment #12
catchWe could put it
Drupal\Component\Classloaderwhich could maybe be used for #3502882: Add a classloader that can handle class moves too? But I'm not sure how usable these are outside Drupal and then we need a composer.json etc. if we do that. Or in Drupal\Component\Discovery?Made the other changes except the rename for now.
Comment #13
dwwIn principle, this all sounds good.
Initial cleanup of MR metadata and resolving threads that have already been addressed.
Need to do a more thorough review, but no time for that right now.
Comment #14
godotislateI think this makes sense, but it's probably fine to leave it in the plugin namespace for now until it's needed for other discovery? Can even use the work from #3502882: Add a classloader that can handle class moves to move it later. :)
Comment #16
godotislateStarted MR 11072 incorporating a minimal set of changes from the #3421014: Convert MigrateSource plugin discovery to attributes MRs to test the problematic source plugins using
I18nQueryTrait, on top of the changes from MR 11030.Have some test failures on that MR will look into later.
Comment #17
catchThe debug classloader being unhelpful again.
Comment #18
godotislateBeen debugging this, but don't understand what's going on yet.
The other failure is this one, which I don't think is exactly a problem with the changes introduced this issue:
Basically, the Migrate source plugin manager filters out definitions using the
ProviderFilterDecoratorand the way thatAnnotatedClassDiscoveryAutomatedProvidersextracts multiple providers for source plugins based on used namespaces. Since the hope here is replace that multiple provider functionality specific to migrate source with theclass_existschecks, during test runs plugins are not filtered out as expected because classes/traits/interfaces for all modules are loaded during test runs.Comment #19
catch@godotislate I may have unfairly maligned the debug classloader.
The namespace declaration for that file is:
Which is definitely not the namespace of the test module and looks like copypasta. Didn't test if this is the problem but it would fit with the error message.
Comment #20
godotislateFixed the namespace and all tests pass except the one in #18. That will be something to address #3421014: Convert MigrateSource plugin discovery to attributes, but everything should be good for this issue, at least functionally. I think MR 11030 will need another person to review, since I wrote the tests.
Comment #22
claudiu.cristea#3397318: Parse attributes before annotations is blocked on this.
Comment #23
catchComment #24
godotislateI suggested it, but I wondered whether moving the
class_exists()check inAttributeClassDiscovery::getDefinitions()to go before retrieving the definition from file cache can be avoided. While it does filter out definitions retrieved from cache from being discovered when their modules are not installed, it also means that every plugin class is loaded into memory in the request when the plugin manager gets the definitions from a cold cache. Separately, relying on class_exists to filter out plugins is difficult to test, since all classes are loaded in tests. I also wonder whether there may be a situation during cache rebuilds/module uninstalls where a plugin class in a module that is being uninstalled has already been loaded into memory earlier in the request, so erroneously does not get filtered out.With this in mind, I tried moving that check back to after the file cache look up, and since Reflection in parseClass() should now be safe, I used reflection to loop through to get all class, interface, and trait dependencies for each plugin class. Then in Core AttributeClassDiscovery, I extracted the modules via namespace of those dependencies and checked whether the modules were installed. This check is done before including the plugin definition in the list returned from
getDefinitions().This broke a lot of tests, because there were usages of node, and entity_test entity types where the user module was not installed, and since those types implement Drupal\user\EntityOwnerInterface, those entity types were not being found without adding 'user' to the installed modules. (Separately there seems to be a lot deprecation warnings for I18nQueryTrait for some weird. reason, though there should not be any classes using the version of that trait in content_translation.)
Regardless, this filtering work probably is out of scope here and maybe belongs in #2786355: Convert multiple provider plugin work from migrate into plugin dependencies in core, but I wanted to see if it would work. See test MR 11072.
Comment #25
godotislateHave a feeling that running
class_exists()on every file in the plugin directory means that the trait file is loaded and triggering this deprecation. Will look into it.Comment #26
catchThat seems very likely. Should we revert here prior to moving the check up, and then open a follow-up for the filtering?
Comment #27
godotislateSounds like a good way to go. We can either use #2786355: Convert multiple provider plugin work from migrate into plugin dependencies in core for the follow up or create a new one if that one doesn't fit.
Closing my test MR.
Comment #29
berdirShould we add the conversion of \Drupal\layout_builder\Plugin\Block\InlineBlock into this issue? should be straight forward then and not require any workarounds that #3265945: Deprecate plugins using annotations and plugin types not supporting attributes currently has to do. And gives us real-world test coverage of this as well then.
Needs work as this apparently needs a rebase at least.
Comment #30
catchYeah I think #29 is a good idea, although it would probably then make this issue a hard blocker of that one, but if people otherwise think it's close to ready, that might be fine too.
Comment #31
godotislateRebased MR and did the following:
Also, the I18nQueryTrait deprecation warning came up because of the
class_exists()checks. At first I wrongly moved the new Trait location out of the Plugin/migrate/source folder, which had no effect because the original deprecated trait was the one causing the issue. Instead of this, I set a custom error handler right before theclass_exists()check to suppress any deprecation warnings, which I hope is kosher. Still, this is another reason to move non-plugin files out of the plugin discovery directories, and one thing to consider for #3397318: Parse attributes before annotations is whether to also use an error handler that suppresses deprecation warnings when instantiating Reflection inparseClass().All tests pass now, so back to NR.
Comment #32
catch@godotislate I think #3502882: Add a classloader that can handle class moves might fix the i18nQueryTrait issue because we could physically remove the file from that folder then?
Comment #33
godotislateYes, agreed. Should this issue be postponed on that one? Then maybe the InlineBlock conversion shouldn't be done here so it isn't a blocker for #3265945: Deprecate plugins using annotations and plugin types not supporting attributes?
Comment #34
godotislateThough one thing about I18nQueryTrait is that it was decided to keep the original trait intact and not a wrapper for the new trait so that the new trait could have type declarations. See #55 of #3258581: Move I18nQueryTrait from content_translation to migrate_drupal.
Comment #35
catchLet's postpone and just try to get all three issues done. Tagging as an 11.2.0 release priority since this is blocking full attribute conversion now.
Comment #36
godotislateWith #3502882: Add a classloader that can handle class moves now in, rebased and used that to remove I18nQueryTrait.php from content_translation.
Also added docblocks to the new classloader methods, and in doing so,
missingClassdidn't seem to fit, so renamed tomissingTrait.This should be ready again.
Comment #37
alexpottI created an issue against PHP - see https://github.com/php/php-src/issues/17959
Comment #38
nicxvan commentedThere is already a pull request with a fix.
Comment #39
godotislateThey said several years back that traits and interfaces were much more difficult to deal with:
Though they clearly starting throwing exceptions for missing interfaces as well sometime between then and now.
Comment #40
godotislateComment #41
catchhttps://github.com/php/php-src/issues/17959 landed making this properly temporary, which is a relief because it's a massive hack.
It looks like it will only be in PHP 8.5, so the earliest we can drop it is Drupal 12 (assuming core requires PHP 8.5 by then). We'd be able to put the classloader registration/unregistration behind a PHP version check in Drupal 11.4-ish though.
Comment #42
godotislateNice!
Comment #43
godotislateI wonder if the classloader, or a similar one, should be kept in place, just to set a flag whenever there is a missing class/interface/trait. The missing class/interface detection right now is based on
'/(Class|Interface) .* not found$/'regexp match, which is brittle and will need to be expanded to include traits. Instead of that, the classloader could set a flag for any detected missing dependency, and when catching the exception, use that flag instead of the regexp match. Actually, I think that can be done now, with the removal of trait aliasing being the only necessary change future change once PHP 8.5 is required?Comment #44
catch#43 is a good idea, if we do that here we also need to rename it..
Comment #45
andypostComment #46
catchFor implementing #43, instead of TraitSafeClassLoader how about MissingClassDetectionClassLoader ?
Comment #47
godotislateFunny enough this is what I also came up with.
Comment #48
catchRebased and implemented #46 / #47 and I think all of @alexpott's feedback.
Comment #49
catchRefactored things a bit and added additional comments to try to make it clearer what can be removed once we require PHP 8.5. It's not a lot of additional code to avoid the error message parsing once the trait workarounds are removed again.
Comment #50
catchComment #51
godotislateApplied suggestions from @quietone.
Comment #52
godotislateApplied two more suggestions. Two comments from @quietone are still open, though I added a comment/question to one.
Comment #53
catchI made a commit for one of the comments, and left a possibly unsatisfactory answer on the other.
Comment #54
nicxvan commentedThis is pretty close I think. I have one suggestion for the comment describing what is happening.
I have one clarifying question that I couldn't quite understand.
One comment commiserating on the actual workaround bit lol.
Not a fan of final, it's already marked as internal which should be sufficient. I wouldn't block this issue on that opinion since it's not settled.
Comment #55
catchOn final I think this is case where we'd prefer it never existed, let alone that someone tries to extend it or call it directly, so even more internal than controllers etc.
I applied the comment suggestion which I agree reads better.
Got turned around on the implementation question, turns out it was right in the first place, but in the process added a long comment to that.
Comment #56
nicxvan commentedI think catch's involvement means we can remove the framework manager tag.
After some back and forth on slack the confusing bit has been clarified on why it's inverse.
I think this is unfortunate but necessary, and it's only temporary since the upstream issue was committed so quickly.
Comment #57
larowlanLooks good to me, just some ubernits. Fine to self RTBC after addressing
Comment #58
godotislateApplied two suggestions. Comment about test coverage is still open, but I wonder if something can be added to core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php for that.
Comment #59
catchI tried to add test coverage for this, and the new test passed, but then I removed that hunk and it still passed, so either the test was wrong or the code is unnecessary, but to be honest I don't know which it is. So I opened #3520815: Add test coverage for Errors thrown from autoloading classes.
Comment #60
godotislateI actually added #3520811: Add test coverage for AttributeClassDiscovery for unexpected class_exists exceptions and pushed a commit with the @todo, but we can pick one to close as a dupe.
Comment #61
godotislatePushing back to RTBC per #57.
Comment #63
quietone commentedI rebased and reworded a comment. It did not change the meaning, so I am leaving this Major issue at RTBC.
Comment #64
larowlanCredits
Comment #66
larowlanCommitted to 11.x - thanks!
Comment #68
godotislateIn #9/#10, there was talk of adding a coding standard for all traits to have names that end in
Trait. Is this still needed? Seems like once Drupal's minimum PHP version 8.5, we won't need to do detection specifically for traits.Comment #69
larowlanI think it is worth opening an issue in the coding standards queue to discuss
Comment #70
godotislateOpened coding standards #3521443: Traits should always have the suffix "Trait".
Comment #71
godotislatePut up MR over in #2786355: Convert multiple provider plugin work from migrate into plugin dependencies in core to do the filtering mentioned in #24.
Comment #72
godotislateThere's a small bug here in:
In
Drupal\Component\Plugin\Discovery\AttributeClassDiscovery,$this->getPluginNamespaces()returns$this->pluginNamespaces;, which is the container namespaces, so this works correctly, with test coverage showing so. However, the subclassDrupal\Core\Plugin\Discovery\AttributeClassDiscovery(or its subclassAttributeDiscoveryWithAnnotations) is the class used by the DefaultPluginManager, and there$this->getPluginNamespaces()is generated from container namespaces with the expected plugin subdirectory appended to each. So instead of array keys likeDrupal\node,Drupal\user, etc., instead the keys are (for block plugins)Drupal\node\Plugin\Block,Drupal\user\Plugin\Block, etc.That means
!isset($this->getPluginNamespaces()[$missing_class_namespace])is always TRUE.The changes in the MR for #2786355: Convert multiple provider plugin work from migrate into plugin dependencies in core fix this, but since there are other more involved changes there, maybe a separate issue just to address the above is needed? It can be addressed with a subset of the #2786355: Convert multiple provider plugin work from migrate into plugin dependencies in core work, though there might be explicit test coverage that needs to be added.