Problem/Motivation

#3573856: SiteConfigureForm properties need to be reset after module install in submitForm() addressed a bug in the installer for Standard (and Umami), but a similar bug persists in the site installer UI when installing Umami.
Screenshot of error after installing standard in browser

This is occurring because when the site configure form is submitted, the update module is installed, which leads to Drupal building a new container. However, the another submit handler (DemoUmamiHooks::installConfigureSubmit() is added in DemoUmamiHooks::formInstallConfigureFormAlter(). (DemoUmamiHooks::installConfigureSubmit() calls setUserPasswords, which uses an entity type manager object from the old container to load user entities.

Steps to reproduce

Install Umami via the UI at /core/install.php, and observe the error in the screenshot in Problem/Motivation after submitting the site configuration form with "Check for updates automatically" checked.

The message in the screenshot is

The website encountered an unexpected error. Try again later.

Symfony\Component\DependencyInjection\Exception\RuntimeException: You have requested a synthetic service ("kernel"). The DIC does not know how to construct this service. in Symfony\Component\DependencyInjection\ContainerBuilder->createService() (line 1095 of vendor/symfony/dependency-injection/ContainerBuilder.php).

(The error message is the summary of a <details> element. If you expand it, you are treated to a stack trace.)

Proposed resolution

Change DemoUmamiHooks::installConfigureSubmit() and DemoUmamiHooks::setUserPasswords to static methods and use `\Drupal::entityTypeManager()` instead of a class property.

Remaining tasks

  1. Add test.

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3581958

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

godotislate created an issue. See original summary.

godotislate’s picture

Issue summary: View changes

godotislate’s picture

Status: Active » Needs work
Issue tags: +Needs tests
benjifisher’s picture

DemoUmamiHooks::installConfigureSubmit() calls setUserPasswords, ...

FWIW, that comes from #3045966: Set author and editor passwords to the admin password in the demo to make demoing easier.

@godotislate:

It looks like the second commit adds a test and reverts the first commit. Is that on purpose? Is the "Test-only changes" CI job broken?

nicxvan’s picture

Thank you for finding that!

godotislate’s picture

Version: 11.x-dev » main
Status: Needs work » Needs review
Issue tags: -Needs tests

@benjifisher: It was a bad commit. I was essentially running test-only locally by reverting the fix, and then accidentally committed.

Test only failure: https://git.drupalcode.org/project/drupal/-/jobs/9127609

I do have an somewhat unrelated but general concern about the use of service object callables in form submit handlers, etc. I'm not completely sure about this, but it might be possible on multistep forms or AJAX forms (or any form that gets cached), that when the form is retrieved from cache and unserialized, the submit handler object is stale. And DependencySerializationTrait doesn't help us, because it's not a class property.

godotislate’s picture

Priority: Critical » Normal
benjifisher’s picture

Issue summary: View changes

I reproduced the error using the main branch. I am adding the error message as text to the issue summary: that makes it easier for screen readers and search engines.

If I ignore the error, then visiting various admin pages (/en/admin/modules for one) leads to a WSOD with the error message

Symfony\Component\Routing\Exception\RouteNotFoundException: Route "update.status" does not exist. in Drupal\Core\Routing\RouteProvider->getRouteByName() (line 214 of core/lib/Drupal/Core/Routing/RouteProvider.php).

and a stack trace.

benjifisher’s picture

I repeated the test using the feature branch. I did not see the error in the installer. Instead, I was logged in to the site, redirected to the home page.

I visited a few admin pages, cleared caches, and checked recent log messages. There are some known warnings generated when installing Umami, a couple of warnings about trying to start cron when it is already running, and nothing else of note.

benjifisher’s picture

Status: Needs review » Needs work
Issue tags: +Needs documentation
Related issues: +#3516264: CKEditor 5 loads all plugin translations on AJAX operations

Test coverage

I reviewed the changes to the tests. It makes sense, and the test-only CI job fails with the same error message that I get in manual testing.

I have a tiny reservation that we no longer have the original version of the test: we are modifying the test instead of adding a new one. But it is already a Functional test marked #[Group('#slow')]. Also, I assume the other installer tests do not enable the update module, so we still have test coverage of that code path.

TL;DR: LGTM

Code changes

The fix makes sense:

  • We need to avoid serializing service objects, rebuilding the container, then deserializing.
  • Therefore we have to use static methods for callbacks (like form submit functions).
  • Those static functions have to use \Drupal::service(...) instead of an injected service.

To illustrate, here is an important hunk from the change introduced for this issue:

   /**
    * Sets the password of admin to be the password for all users.
    */
-  public function setUserPasswords(#[\SensitiveParameter] $admin_password): voi
d {
-    $user_storage = $this->entityTypeManager->getStorage('user');
+  public static function setUserPasswords(#[\SensitiveParameter] $admin_password): void {
+    $user_storage = \Drupal::entityTypeManager()->getStorage('user');
     // Collect the IDs of all users with roles editor or author.
     $ids = $user_storage->getQuery()
       ->accessCheck(FALSE)

This function is made static, so it has to use \Drupal::entityTypeManager() instead of the injected $this->entityTypeManager.

The breaking change

I used git bisect to find the issue that caused this problem: #3516264: CKEditor 5 loads all plugin translations on AJAX operations. (I am now very efficient at installing Umami through the admin UI.) It includes these changes:

@@ -26,6 +28,8 @@ class Ckeditor5Hooks {
 
   public function __construct(
     protected LanguageMapper $languageMapper,
+    protected ModuleHandlerInterface $moduleHandler,
+    protected LibraryDependencyResolverInterface $libraryDependencyResolver,
   ) {
 
   }
@@ -221,7 +225,6 @@ public function libraryInfoAlter(&$libraries, $extension): void {
     if ($extension === 'filter') {
       $libraries['drupal.filter.admin']['dependencies'][] = 'ckeditor5/internal.drupal.ckeditor5.filter.admin';
     }
-    $moduleHandler = \Drupal::moduleHandler();
     if ($extension === 'ckeditor5') {
       // Add paths to stylesheets specified by a theme's ckeditor5-stylesheets
       // config property.

In other words, the exact opposite of the fix for this issue: replacing a call to \Drupal::moduleHandler() with an injected service.

That is as close as I get to explaining how that issue caused this one. Is it some interaction between the module handler and the entity type manager from the old service container? (shrug)

I cannot help pointing out that there is a Remaining task on that issue:

Look for side-effects of the proposed resolution.

Maybe now we are ready to check that off. ;)

Needs work: more documentation

@godotislate, I have to admit that I do not understand the interaction of dependency injection (DI), serialization, and container rebuilds as well as you do. (I could not have figured out a fix for this issue in five times as long as you took. Maybe ten times.) For years, I have been following the rule that we should use DI and avoid invoking the \Drupal class in OO code. Now, this issue shows me that there are exceptions, but I still do not know what the exceptions are.

We need some guidelines, and we need some documentation.

Side note: just last week, I was doing a code review at work. Oh, no, someone was injecting the whole container instead of individual services. I know that’s the wrong thing to do! But I could not find a documentation page that said so. I looked hard for such a page.

I am setting this issue to NW: not for code changes but for a more complete explanation, something that we can add to the developer documentation.

godotislate’s picture

To clarify, the issue here is not about serialization. I have a concern about serialization that is not directly related here, but I'll get into that in a bit.

This issue is closely related to #3573856: SiteConfigureForm properties need to be reset after module install in submitForm(), and the problem is this: If the "Check for updates automatically" on the final step of the site installation form is checked, then on submit, Drupal\Core\Installer\Form\SiteConfigureForm::submitForm() will install the Update module. When the module is installed, the container is rebuilt and is set to a new object. After the container is rebuilt, all the form's service object properties are stale, because they were instantiated from the old container object. Any usage of those service properties in subsequent submit handler processing can run into errors because of outdated references. The solution in #3573856: SiteConfigureForm properties need to be reset after module install in submitForm() was to repopulate those service properties from the new container right after the update module is installed.

The issue here is that DemoUmamiHooks alters the SiteConfigureForm build array to add another submit handler. The submit handler is the callable [$this, 'installConfigureSubmit'], and $this is the DemoUmamiHooks object, which is a service instantiated from the original container. In addition, the entityTypeManager property on DemoUmamiHooks is also instantiated from the original container. So when installConfigureSubmit() is invoked (after the container rebuild), and subsequently calls setUserPasswords(), using the outdated entityTypeManager object there causes the error. Since there's no real way to repopulate the entityTypeManger from the new container object other than using the \Drupal global object, we might as well just make the methods static. I don't know that there's much learning to take from this, because having to deal with container rebuilds in runtime is somewhat unusual.

My concern with serialization is this: Assume that some Hook class similarly alters a different form and adds a submit handler in the same way. If the form is serialized because it is cached, then the Hook object that is unserialized is not instantiated from the current container. Though if I think about it, this might not be a problem if the Hooks class itself uses DependendencySerializationTrait. While the Hook object would not be instantiated from the current container, all the Hook class's service properties would be, via DependendencySerializationTrait::__wakeup(). So it's probably fine.

benjifisher’s picture

Issue tags: -Needs documentation

@godotislate:

Thanks. I think I understand what is going on.

To summarize: if a class requests an object from the service container, the class should be careful not to use that object after a container rebuild.

It feels like we are playing Whack-a-mole and fixing bugs when that rule is broken. For this issue, when DemoUmamiHooks alters SiteConfigureForm::submitForm(), it has to realize that its objects might be used after a rebuild, so it has to pass a static method. I am not sure that this counts as "tightly coupled" code, but it is coupled.

I guess the saving grace is that "having to deal with container rebuilds in runtime is somewhat unusual."

I wish there were a way to handle this at the container level during a rebuild. I expect that the old container "knows" which of its services have been instantiated. If we could update those objects and hand them off to the new container, then we would never have stale service objects (and we could remove all the code for services from DependencySerializationTrait). Something like this, for each instantiated service:

// Get an already-instantiated service.
$foo = $old_container->get('foo');
// Magically get the same result as $new_container->get('foo').
$foo->update($new_container);
// Then register $foo as the object to return from $new_container->get('foo').

Unfortunately, after the first line, $foo = $new_container->get('foo') does not do what we want and $foo = clone new_container->get('foo') does the opposite of what we want.

For this issue, can you add a code comment before the line

$user_storage = \Drupal::entityTypeManager()->getStorage('user');

explaining why we are not using DI? I know: if anyone tries to "fix" that, then the new test will fail. But let’s use "defense in depth" and save some future developer a cycle of code-test-recode.

I am pretty sure that the switch to DI was not required to fix #3516264: CKEditor 5 loads all plugin translations on AJAX operations. Someone was just cleaning up a call to \Drupal::moduleHandler() and using DI.

godotislate’s picture

Status: Needs work » Needs review

Added the comment requested in #13. Also added `@internal` to the submit handler functions, since they shouldn't be considered API.

benjifisher’s picture

Status: Needs review » Reviewed & tested by the community

I already reviewed (Comment #11) and tested (Comments #9 and #10). Since then, @godotislate added my requested change (a code comment) and marked two methods @internal. I do not think I need to re-test.

Thanks for the updates. I am happy with this version.

dries’s picture

This looks great. One small inaccuracy: the comment in setUserPasswords() references SiteConfigureForm::submit() but the actual method is SiteConfigureForm::submitForm().

nicxvan’s picture

Found another comment similar to 16, once it's green I think it's ready!

nicxvan’s picture

RTBC plus 1

Both comments have been addressed and test only fails as expected!

  • catch committed 17bb88e1 on 11.x
    fix: #3581958 Alter hook for site configure form in DemoUmamiHooks uses...

  • catch committed 1bf6e1fd on main
    fix: #3581958 Alter hook for site configure form in DemoUmamiHooks uses...
catch’s picture

Version: main » 11.x-dev
Status: Reviewed & tested by the community » Fixed

Committed/pushed to main and 11.x, thanks!

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

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