Problem/Motivation

3rd party contrib streamWrappers may(and do) call the Drupal Container (\Drupal::service()) during __construct() to obtain necessary services for their operation (maybe we shouldn't, however its been the case since at least D8.9, I wouldn't expect this to change in a minor).

After #3453216: Clean up outdated mentions of prepareLegacyRequest the Drupal Kernel attempts to register streamWrappers before the container has been fully populated with causes a ServiceNotFoundException.

Seen in #3601847: D11.4, s3fs service not found.

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: You have requested a non-existent service "s3fs". in Drupal\Component\DependencyInjection\Container->get() (line 153 of core/lib/Drupal/Component/DependencyInjection/Container.php).

Drupal::service() (Line: 181)
Drupal\s3fs\StreamWrapper\S3fsStream->__construct() (Line: 262)
Drupal\Component\DependencyInjection\Container->createService() (Line: 173)
Drupal\Component\DependencyInjection\Container->get() (Line: 453)
Drupal\Component\DependencyInjection\Container->{closure:Drupal\Component\DependencyInjection\Container::resolveServicesAndParameters():452}() (Line: 61)
Symfony\Component\DependencyInjection\ServiceLocator->doGet() (Line: 43)
Symfony\Component\DependencyInjection\ServiceLocator->get() (Line: 110)
Drupal\Core\StreamWrapper\StreamWrapperManager->getClass() (Line: 143)
Drupal\Core\StreamWrapper\StreamWrapperManager->register() (Line: 1073)
Drupal\Core\DrupalKernel->initializeContainer() (Line: 881)
Drupal\Core\DrupalKernel->updateModules() (Line: 722)
Drupal\Core\Extension\ModuleInstaller->updateKernel() (Line: 320)
Drupal\Core\Extension\ModuleInstaller->doInstall() (Line: 229)
Drupal\Core\Extension\ModuleInstaller->install() (Line: 503)
Drupal\system\Form\ModulesListForm->submitForm() (Line: 108)
Drupal\Core\Form\FormSubmitter->executeSubmitHandlers() (Line: 45)
Drupal\Core\Form\FormSubmitter->doSubmitForm() (Line: 620)
Drupal\Core\Form\FormBuilder->processForm() (Line: 343)
Drupal\Core\Form\FormBuilder->buildForm() (Line: 73)
Drupal\Core\Controller\FormController->getContentResult()
call_user_func_array() (Line: 123)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->{closure:Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber::wrapControllerExecutionInRenderContext():121}() (Line: 638)
Drupal\Core\Render\Renderer::{closure:Drupal\Core\Render\Renderer::executeInRenderContext():638}()
Fiber->resume() (Line: 653)
Drupal\Core\Render\Renderer->executeInRenderContext() (Line: 121)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->wrapControllerExecutionInRenderContext() (Line: 97)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->{closure:Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber::onController():96}() (Line: 183)
Symfony\Component\HttpKernel\HttpKernel->handleRaw() (Line: 76)
Symfony\Component\HttpKernel\HttpKernel->handle() (Line: 53)
Drupal\Core\StackMiddleware\Session->handle() (Line: 30)
Drupal\Core\StackMiddleware\KernelPreHandle->handle() (Line: 28)
Drupal\Core\StackMiddleware\ContentLength->handle() (Line: 32)
Drupal\big_pipe\StackMiddleware\ContentLength->handle() (Line: 118)
Drupal\page_cache\StackMiddleware\PageCache->pass() (Line: 92)
Drupal\page_cache\StackMiddleware\PageCache->handle() (Line: 48)
Drupal\Core\StackMiddleware\ReverseProxyMiddleware->handle() (Line: 51)
Drupal\Core\StackMiddleware\NegotiationMiddleware->handle() (Line: 61)
Drupal\Core\StackMiddleware\AjaxPageState->handle() (Line: 54)
Drupal\Core\StackMiddleware\StackedHttpKernel->handle() (Line: 753)
Drupal\Core\DrupalKernel->handle() (Line: 35)
Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner->run() (Line: 32)
require('/var/www/html/vendor/autoload_runtime.php') (Line: 22)
require_once('/var/www/html/web/autoload_runtime.php') (Line: 13)

Steps to reproduce

Install a module with a streamWrapper that makes a \Drupal::service('module.service') call to a service located only in the module providing the streamWrapper.

Proposed resolution

Ensure service container is populated before registering streamWrappers.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3603333

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

cmlara created an issue. See original summary.

cmlara’s picture

A few other streamWrappers that appear to have same fault now:

https://www.drupal.org/project/media_avportal
https://www.drupal.org/project/gcsfs
https://www.drupal.org/project/rokka

These were all a simple "download module, install module", if it failed at that point with a similar stack it was considered to have the fault. No back validation performed on lower core versions.

Note: That depending how the installs fail they can leave the site in an unstable condition. The rokka module can't be uninstalled via the UI(isn't even accessible) or drush pmu due to multiple database table never created, even the drush cex; drush cim trick (where you remove the module from the core.extensions.yml) fails.

S3FS took down the entire test lab due to a thrown exception from a dependency library (missing default config, which in fairness we likely need to do something there to help mitigate) with no apparent way to recover the site.

freelock’s picture

I ran into several issues like this in the 11.3 upgrade cycle, mostly in hook classes - dependency injection led to circular dependency issues. The solution there was to make the classes lazy load their dependencies instead of injecting them. Is that an option here?

cmlara’s picture

The solution there was to make the classes lazy load their dependencies instead of injecting them. Is that an option here?

StreamWrappers don’t support injection (it’s not on the API spec in PHP itself) you have to call the global container.

Technically contrib could move the calls from the constructor to inline to try and delay the request to slightly latter however that create at least a couple concerns:

  1. A streamWrapper needs to be able to start serving data immediately,
  2. Not without being a potential API breach in the StreamWrapper class

Point one might look successful at first, howver the moment you add any code that triggers a read before the container is booted will cause abnormal issues or WSOD’s.

Point 2 is more about the fact streamWrapper classes may be API (depends on each contrib module, over in s3fs we do know our streamWrapper has been extended by others) and changing that is a major break, moving the error handling and failure points deeper into code that’s needs to be ready to respond at any time.

dependency injection led to circular dependency issues

We have fought that a few times in S3FS and are starting to learn it’s better to detangle the circle. Anything we did for that in being lazy or avoiding current known usages of circular dependency in the past eventually came back to bite us.

catch’s picture

Priority: Normal » Major

It looks to me like #3583911: Clean up StreamWrapperManager::register() calls might fix this - could you test that MR against s3fs? If it does we'd need to figure out how backportable it is.

If it doesn't help or can't be backported, then we might need to revert #3453216: Clean up outdated mentions of prepareLegacyRequest in 11.x, but would be good to try to go forwrards rather than backwards if we can.

cmlara’s picture

might fix this - could you test that MR against s3fs?

Technically speaking it now fails sooner due to a circular dependency error due to the first line of our constructor being:

  public function __construct() {
     $this->streamWrapperManager = \Drupal::service('stream_wrapper_manager');

For sake of testing I did comment out the circular call and the registration did proceed further, so in concept yes that patch helps however carries its own problems.

I see az_blob_fs and gcsfs appear they would be broken by that patch for same circular dependency flaw.
https://search.tresbien.tech/search?q=streamWrapperManager%20f%3A.tream....
(note, this search is limited assuming 'tream' is part of the file name, which is a bad assumption IMO however confirms at least two other impacted modules)

In s3fs we use StreamWrapperManagerInterface::normalizeUri() (not static) and a couple static methods(called on the instance). The statics I could solve by moving off the object instance, normalizeUri() would require duplicating code (and again possible API change for s3fs as would have to remove the property from our base stream Wrapper class)

Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException: Circular reference detected for service "stream_wrapper_manager", path: "stream_wrapper_manager -> stream_wrapper_manager".

/var/www/html/vendor/symfony/dependency-injection/Container.php:214
/var/www/html/vendor/symfony/dependency-injection/Container.php:203
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:598
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:582
/var/www/html/web/core/lib/Drupal.php:203
/var/www/html/web/modules/custom/s3fs/src/StreamWrapper/S3fsStream.php:158
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:1202
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:637
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:1320
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:1265
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:1276
/var/www/html/vendor/symfony/service-contracts/ServiceLocatorTrait.php:61
/var/www/html/vendor/symfony/dependency-injection/ServiceLocator.php:43
/var/www/html/web/core/lib/Drupal/Core/StreamWrapper/StreamWrapperManager.php:115
/var/www/html/web/core/lib/Drupal/Core/StreamWrapper/StreamWrapperManager.php:27
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:1202
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:637
/var/www/html/vendor/symfony/dependency-injection/ContainerBuilder.php:582
/var/www/html/web/core/lib/Drupal/Core/Test/TestRunnerKernel.php:74
...

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

longwave’s picture

Status: Active » Needs review
Related issues: +#3571094: Remove RegisterStreamWrappersPass

This is actually a bug introduced by #3571094: Remove RegisterStreamWrappersPass I think. register() now instantiates the stream wrappers in order to get the class name, but it shouldn't actually do this.

MR!16301 fixes this by injecting the class names as a separate argument, as there is no way to retrieve them from the service locator without instantiating them, reintroducing part of the compiler pass we previously removed. There is also a test with a stream wrapper that retrieves the stream wrapper manager in its constructor, which should prove that contrib stream wrappers can inject any service in their constructor.

Parts of this MR were assisted by Claude Code.

longwave’s picture

After this lands we should also be able to do #3583911: Clean up StreamWrapperManager::register() calls with no issues.

godotislate’s picture

A couple nits on the MR.

I also ran the test-only job, and it failed:

There was 1 failure:
1) Drupal\KernelTests\Core\StreamWrapper\StreamWrapperManagerTest::testModuleInstallRegistration
Failed asserting that two variables reference the same object.
/builds/core/tests/Drupal/KernelTests/Core/StreamWrapper/StreamWrapperManagerTest.php:64
FAILURES!
Tests: 4, Assertions: 6, Failures: 1.

I was thrown off initially because the test fails instead of erroring with an exception per the IS, but I believe that's because the test stream wrapper is getting the stream_wrapper_manager, which is defined in the old container. So the missing service exception isn't thrown, but the streamWrapperManager object in the test stream wrapper does not match the new stream wrapper manager.

Maybe it would be more clear if the test module had a new service defined, with the test stream wrapper instantiating that new service in its constructor?

cmlara’s picture

Quick test of MR!16301 with s3fs and it does appear to allow tests to pass.

As an aside (not relevant to this issue, however worth Core team knowing from a design perspective):
I haven't been working on it it in some time haven't tested against 11.x at all, however in the s3fs 4.x series we would need to dynamically register streamWrappers, we couldn't expect them to be previously defined from a static list that would just be altered (as we do in the 3.x branch).

I'm not intendeding to imply any of these linked issues would actually break anything for us in 4.x I just want to encourage Core to keep in mind how early they bring streamWrappers in (ensure first compiler pass has always fully complete before the stream_wrapper_manager service is initialized). We have had to defend against core switching to service closures in the past cause issues with previously working code when the closure container differs from the final container.

longwave’s picture

Re #12 are you registering them via a module service provider, or some other way? Out of scope here but if contrib has a pattern for this we can always test for it in core to avoid breaking it in future, especially as stream wrappers act a bit differently to other things due to needing to be registered with PHP itself.

cmlara’s picture

For #13 (off topic to initial issue):
Yes a service provider, https://git.drupalcode.org/project/s3fs/-/blob/ef7d09953f5380b83ea1aa197...

Flysystem also does similar https://git.drupalcode.org/project/flysystem/-/blob/bc56a1f089f7a60f336e... (and theirs is in a stable release).

For context:
https://git.drupalcode.org/project/s3fs/-/commit/ef7d09953f5380b83ea1aa1... was the previous incident where service locators (closure with reduced container size) caused us issues (we were a second compiler pass to give us better access to Drupal Core services to load config).

I never raised that as a core issue as 4.x was/is pre-alpha and we found a workaround however from a design standpoint the latter a streamWrapper can register itself into the container the more UX and DX friendly it can be (and ideally everything during all the compiler passes is avoiding any stream access and is only working with raw files meaning late enrollment in container is a non-issue).

godotislate’s picture

Title: After #3453216 kernel registers streamWrappers before container fully booted, breaks streamWrappers. » Errors when stream wrappers instantiate services in constructors because register() is called before container is built
Status: Needs review » Reviewed & tested by the community

lgtm
Test only fails as expected: https://git.drupalcode.org/project/drupal/-/jobs/10918323.

I gave a shot at retitling this issue, though it's a bit long.

  • catch committed 39ad5ef4 on 11.x
    fix: #3603333 Errors when stream wrappers instantiate services in...

  • catch committed de0a865f on main
    fix: #3603333 Errors when stream wrappers instantiate services in...

  • catch committed 3fb729a7 on 11.4.x
    fix: #3603333 Errors when stream wrappers instantiate services in...

catch’s picture

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

Committed/pushed to main and cherry-picked to 11.x and 11.4.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.