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

Command icon 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

catch created an issue. See original summary.

catch’s picture

Status: Active » Needs work

I 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.

catch’s picture

Status: Needs work » Needs review

Think 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.

catch’s picture

Title: Add a fallback classloader that can handle missing traits » Add a fallback classloader that can handle missing traits for attribute discovery
oily’s picture

Added comments to the MR. I dont see anything else in the code that stands out as 'is that right?'

It's illegal to throw an exception from class loading

True, yes this seems quite creative approach.

catch’s picture

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.

Third 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.

oily’s picture

@catch Ah, okay. Thanks.

godotislate’s picture

This 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:

  • This relies on traits having names that end in "Trait" (and no classes or interfaces that do), though it might be edge-casey to be using traits that don't
  • Trait use statements that alias methods (and likely similar) will still fatal error. Example:
    use CustomTrait {
        theMethod as theTraitMethod;
    }
    

    Since StubTrait doesn't have "theMethod", reflection still fatal errors.

catch’s picture

#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

godotislate’s picture

I 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.

catch’s picture

We could put it Drupal\Component\Classloader which 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.

dww’s picture

In 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.

godotislate’s picture

Or in Drupal\Component\Discovery?

I 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. :)

godotislate’s picture

Started 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.

catch’s picture

RuntimeException: The autoloader expected class "Drupal\migrate_plugin_config_test\Plugin\migrate\source\SimpleSource" to be defined in file "/builds/issue/drupal-3502913/core/modules/migrate/tests/modules/migrate_plugin_config_test/src/Plugin/migrate/source/SimpleSource.php". The file was found but the class was not in it, the class name or namespace probably has a typo.

The debug classloader being unhelpful again.

godotislate’s picture

The debug classloader being unhelpful again.

Been 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:

Drupal\Tests\migrate\Kernel\Plugin\MigrationPluginListTest::testGetDefinitions
Failed asserting that an array is empty.

core/modules/migrate/tests/src/Kernel/Plugin/MigrationPluginListTest.php:81

Basically, the Migrate source plugin manager filters out definitions using the ProviderFilterDecorator and the way that AnnotatedClassDiscoveryAutomatedProviders extracts multiple providers for source plugins based on used namespaces. Since the hope here is replace that multiple provider functionality specific to migrate source with the class_exists checks, during test runs plugins are not filtered out as expected because classes/traits/interfaces for all modules are loaded during test runs.

catch’s picture

@godotislate I may have unfairly maligned the debug classloader.

The namespace declaration for that file is:

namespace Plugin\migrate\source;

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.

godotislate’s picture

Fixed 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.

godotislate changed the visibility of the branch 3502913-test-migrate-source to hidden.

claudiu.cristea’s picture

catch’s picture

Issue summary: View changes
godotislate’s picture

I suggested it, but I wondered whether moving the class_exists() check in AttributeClassDiscovery::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.

godotislate’s picture

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.

Have 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.

catch’s picture

Have 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.

That seems very likely. Should we revert here prior to moving the check up, and then open a follow-up for the filtering?

godotislate’s picture

Should we revert here prior to moving the check up, and then open a follow-up for the filtering?

Sounds 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.

berdir’s picture

Status: Needs review » Needs work

Should 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.

catch’s picture

Yeah 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.

godotislate’s picture

Rebased MR and did the following:

  • Moved the class_exists check back below retrieving from file cache, per #26
  • Converted InlineBlock to attributes, per #29
  • Moved the StubTrait and class loader to Drupal\Component\Discovery, per #12

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 the class_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 in parseClass().

All tests pass now, so back to NR.

catch’s picture

@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?

godotislate’s picture

#3502882: Add a classloader that can handle class moves might fix the i18nQueryTrait issue

Yes, 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?

godotislate’s picture

Though 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.

catch’s picture

Title: Add a fallback classloader that can handle missing traits for attribute discovery » [PP-1] Add a fallback classloader that can handle missing traits for attribute discovery
Priority: Normal » Major
Issue tags: +11.2.0 release priority

Let'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.

godotislate’s picture

Title: [PP-1] Add a fallback classloader that can handle missing traits for attribute discovery » Add a fallback classloader that can handle missing traits for attribute discovery

With #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, missingClass didn't seem to fit, so renamed to missingTrait.

This should be ready again.

alexpott’s picture

Issue summary: View changes

I created an issue against PHP - see https://github.com/php/php-src/issues/17959

nicxvan’s picture

There is already a pull request with a fix.

godotislate’s picture

There is already a pull request with a fix.

They said several years back that traits and interfaces were much more difficult to deal with:

To support the same for interfaces/traits, the basic idea would be to have a separate opcode like COMMIT_CLASS, which separates the addition of the class to the hashtable from the binding procedure, in which case all binding errors could become exceptions. This part I would assume to be relatively straightforward. The issue is that the inheritance logic also modifies the class in-place, this means that it's not possible to run inheritance on a class entry twice (e.g. after defining a necessary interface that previously threw). Solving that part wouldn't be so simple.

Though they clearly starting throwing exceptions for missing interfaces as well sometime between then and now.

godotislate’s picture

catch’s picture

https://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.

godotislate’s picture

Nice!

godotislate’s picture

We'd be able to put the classloader registration/unregistration behind a PHP version check in Drupal 11.4-ish though.

I 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?

catch’s picture

#43 is a good idea, if we do that here we also need to rename it..

andypost’s picture

catch’s picture

For implementing #43, instead of TraitSafeClassLoader how about MissingClassDetectionClassLoader ?

godotislate’s picture

instead of TraitSafeClassLoader how about MissingClassDetectionClassLoader

Funny enough this is what I also came up with.

catch’s picture

Rebased and implemented #46 / #47 and I think all of @alexpott's feedback.

catch’s picture

Refactored 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.

catch’s picture

Issue summary: View changes
godotislate’s picture

Applied suggestions from @quietone.

godotislate’s picture

Applied two more suggestions. Two comments from @quietone are still open, though I added a comment/question to one.

catch’s picture

I made a commit for one of the comments, and left a possibly unsatisfactory answer on the other.

nicxvan’s picture

This 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.

catch’s picture

On 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.

nicxvan’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs framework manager review

I 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.

larowlan’s picture

Status: Reviewed & tested by the community » Needs work

Looks good to me, just some ubernits. Fine to self RTBC after addressing

godotislate’s picture

Applied 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.

catch’s picture

Status: Needs work » Needs review

I 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.

godotislate’s picture

I 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.

godotislate’s picture

Status: Needs review » Reviewed & tested by the community

Pushing back to RTBC per #57.

quietone made their first commit to this issue’s fork.

quietone’s picture

I rebased and reworded a comment. It did not change the meaning, so I am leaving this Major issue at RTBC.

larowlan’s picture

Credits

  • larowlan committed e2c511e8 on 11.x
    Issue #3502913 by catch, godotislate, quietone, nicxvan, larowlan, oily...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Committed to 11.x - thanks!

godotislate’s picture

In #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.

larowlan’s picture

I think it is worth opening an issue in the coding standards queue to discuss

godotislate’s picture

godotislate’s picture

godotislate’s picture

There's a small bug here in:

                  // If we arrive here a second time, and the namespace is still
                  // unavailable, ensure discovery is skipped. Without this
                  // explicit check for already checked classes, an invalid
                  // class would be discovered, because once we've detected a
                  // a missing trait and aliased the stub instead, this can't
                  // happen again, so the class appears valid. However, if the
                  // namespace has become available in the meantime, assume that
                  // the class actually should be discovered since this probably
                  // means the optional module it depends on has been enabled.
                  if (!isset($this->getPluginNamespaces()[$missing_class_namespace])) {
                    $autoloader->reset();
                    continue 2;
                  }

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 subclass Drupal\Core\Plugin\Discovery\AttributeClassDiscovery (or its subclass AttributeDiscoveryWithAnnotations) 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 like Drupal\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.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.