Problem/Motivation
In HEAD, the same request is pushed onto the RequestStack twice for every main and sub request:
- First by
\Drupal\Core\DrupalKernel::preHandle(), which is called from the KernelPreHandle middleware.
- Then again by
\Symfony\Component\HttpKernel\HttpKernel::handle(), which unconditionally pushes the request before dispatching it.
However, only HttpKernel::handle() pops its push (in its finally block).
This architecture was fragile because:
- The request stack contained a duplicate entry during the entire request lifecycle.
- The request stack is never cleared.
- Sub-requests compounded the problem since their push/pop lifecycle was not properly scoped.
Proposed resolution
The fix takes ownership of the request stack lifecycle entirely within Drupal's own code, rather than relying on the interplay between Symfony's HttpKernel and a terminate event subscriber:
DrupalKernel::preHandle() continues to push the request onto the stack early (this is needed so the request is available during Drupal's boot and service initialization).
KernelPreHandle::handle() now pops sub-requests in a finally block after HttpKernel::handle() returns.
HttpKernel::handle() pushes and pops to a new RequestStackWrapper that, allows the Symfony kernel access to the request but does not respect the it's calls to pop() and push().
DrupalKernel::terminate() now pops the main request at the very end of the execution cycle — after destructible services have run. This ensures the main request remains available on the stack throughout the entire lifecycle, including during kernel termination.
Remaining tasks
Get RTBC and commit
User interface changes
no
API changes
None.
Data model changes
None
Comments
Comment #2
effulgentsia commentedComment #3
effulgentsia commentedBy the way, I initially thought #2613040: The "Permalink" comment link links to the non-permanent URL might be due to this, but it's not. That's a separate issue. But the drupalSettings problem that's in the issue summary is due to this issue, and not that one.
Comment #4
dawehnerLet's see what happens.
Comment #6
dawehnerHa, wrong project, wrong patch.
Comment #8
Crell commentedThe symmetry here makes sense, although I'm unclear why it's failing for a simple patch... The log suggests that some code is relying on the request still being there after we've popped it, which suggests a deeper structural/ordering issue.
Comment #9
wim leers+1 to everything in #8.
Comment #10
dawehnerI simply believe that there is something accessing the route match / request stack outside of the prehandle, for example during the terminate event. I could not find an exanple but
\Drupal\Core\Path\AliasManager::writeCachecould have been one (thought it uses the globals)Comment #11
cpj commentedBy tracing the execution path, I have found one failure mode for an anonymous user visiting the front page for the first time.
In Drupal\page_cache\StackMiddleware\PageCache->fetch(), after the response has been created by httpKernel->handle(), the request stack has been popped twice, so is empty.
Then line 247 calls responsePolicy->check() = Drupal\Core\PageCache\ChainResponsePolicy->check()
The first rule it checks is Drupal\node\PageCache\DenyNodePreview->check()
This calls routeMatch->getRouteName() = Drupal\Core\Routing\CurrentRouteMatch->getRouteName()
This calls requestStack->getCurrentRequest(), which is empty
Then getRouteMatch(Request $request) throws an error because it's expecting a Request object and is passed null
Comment #12
cpj commentedI've taken a closer look at the classes that implement HttpKernelInterface and their sequence in the StackMiddleware chain, plus the interaction with HttpKernel. The way KernelPreHandle calls DrupalKernel to push / pop the RequestStack appears to conflict with the push / pop in HttpKernel. It seems to me that only one of these classes should be in control of pushing and popping from the RequestStack.
If we decide that DrupalKernel should be in control, then we could create a class to extend HttpKernel with versions of handleRaw and finishRequest that simply comment out the RequestStack push and pop. After that I think we have a better chance of fixing this issue and avoiding future issues with subrequests.
Comment #13
dawehnerHere is an alternative approach. It just seems to be not possible to have a symetrie, so we rather have to drop as late as possible.
Comment #15
wim leersDrupalKernel::preHandle()also needs to push$requestonto the stack. If that weren't necessary, removing it would solve all this at once?Super confusing issue.
Comment #16
dawehnerJust to be clear, this issue is about research, not about complaining or anything else. Its research to understand and to solve things the proper way at the end.
There is code executed which needs a request before the HTTP kernel jumps in. One example is
\Drupal\Core\Session\SessionManagercalled from within\Drupal\Core\StackMiddleware\Session::handleSo we have a onion of middlewares:
When we would remove the request from the stack nothing outside of pre_handle could access the request on the stack again. This is not only directly accessing of
the request, but ALSO the current route match, outside of the scope of pre_handle. There seems to be code relying on it, implicitly.
One example:
So here is an alternative approach. Let's see how this helps
More failures as? Patch number 1 which was the wrong patch? Or do you compare with #6 and use some more advanced math?
Comment #18
wim leersI was not complaining. I was just stating that I don't understand most of what's been going on here so far :)
Just like Crell said in #8: he doesn't understand why it's failing for a simple patch. I also don't understand that failure. But I also don't understand most of what was posted after that.
I'm hoping somebody will be able to come up with a good explanation that will make everybody understand. That is all :)
Comment #19
dawehnerWell, its so simple. People relied on that behaviour over time.
Comment #20
cpj commentedAs per #16, this patch is not intended as a final solution, but just to test what I said in #12
Comment #21
cpj commentedComment #22
cpj commentedI also locally tested another way to do the same thing as #20, which is to have a version of Symfony\Component\HttpFoundation\RequestStack with an optional boolean parameter on the push/pop, so that it ignores the push/pop from HttpKernel. This has the same effect as #20 but leads to a lot more changed files, mostly in tests that use the request stack. It has the advantage that it doesn't mess with HttpKernel itself.
So where next ? If you ignore that the approach is not ideal, does the fact that #20 passes mean it "fixes" the original issue ?
Comment #23
dawehner@cpj
Well, no, the fact that it passes just means we have no test coverage. This now still doesn't solve the issue. It now just pushes once in DrupalKernel but never pops.
GRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR I love symfony's idea of sharing code
Comment #24
cpj commented@dawehner,
OK, understood. I will look again at the number and sequence of pushes/pops. Yes, the private functions in HttpKernel are a real pain. Some of the other oldest parts of Symfony2 are written in a similar style.
It's not yet clear to me if we really do need to disable the push/pop from HttpKernel, but assuming we do, I wasn't sure if it was preferable to essentially replacing most of the HttpKernel class with our own version or to replace the request-stack service. Replacing HttpKernel is simpler but might be harder to keep in sync with Symfony's HttpKernel class, replacing the request-stack service with an optional parameter will leave HttpKernel untouched, but requires lots of one-line changes, mostly in tests. Any preference ?
Comment #25
dawehnerMh, we could change the request stack to check whether we the current top entry is the same as we already try to it. This way the calls in HttpKernel would simply not do anything.
Nevertheless, we need to add some popping in DrupalKernel,
Comment #26
dawehnerSo we do we agree that this is the behaviour we want?
Comment #28
cpj commentedThis is another attempt to simplify this issue and again is not intended as a final solution. I tried various approaches to control the push/pop processes but the way HttpKernel and RequestStack are written makes it very difficult to find a clean solution. This version uses separate request stack services for DrupalKernel and HttpKernel. The intention of RequestStackPush MIddleware is to move the push/pop outside of DrupalKernel, but in this test it is disable because right now the installer calls DrupalKernel->preHandle directly, which needs to set up the RequestStack for the install to work. Lets see where it fails.
Comment #30
cpj commentedFinally had time to look at this again. This completes the concept started in 28. I have successfully tested it with a dummy module that creates multiple sub-requests, so lets see if it works here. The patch adds a second request stack service and a middleware service called RequestStackMiddleware.
Symfony HttpKernel normally manages the request stack, but Drupal needs the request stack before Symfony HttpKernel::handleRaw() pushes the current request to the stack and after HttpKernel::finishRequest() pops it. There is no clean way to modify the behaviour of HttpKernel, hence we need two request stacks. 'request_stack' is used by Drupal and 'symfony_request_stack' is used by Symfony.
The RequestStackMiddleware service manages the Drupal request stack. It has the highest middleware priority in core.services.yml so that the current request is pushed / popped from the request stack before / after any other Middleware service is called.
The request stack push is removed from DrupalKernel::preHandle(). A push to the request stack is added to install.core.inc install_begin_request() because the stack middleware is not active during the install process and the installer uses the request stack.
Comment #31
cpj commentedComment #34
dawehner@cpj Any ideas about the remaining failures?
Comment #35
cpj commented30 works fine for me against 8.1.x, so am resubmitting for testing
Comment #36
cpj commentedComment #38
cpj commented@dawehner - looks like the errors in #35, where they refer to null being passed to line #99 of core/lib/Drupal/Core/Routing/CurrentRouteMatch.php, are because after these changes, the tests don't initialise the request stack any more, probably because of the change to DrupalKernel.php. Where does this initialisation take place for the tests ? Sorry, I'm not expert enough in Drupal tests to work it out for myself.
Comment #39
cpj commentedAdded a push/pop to DrupalKernel.php terminate(request, response) because this also references the request stack, which is already popped when terminate is called. Ran simpletest locally on Drupal\block\Tests\BlockHtmlTest, which now passes. So lets see what happens now...
Comment #41
dawehnerreally nice progress!
Comment #42
cpj commentedSo this may fix one tiny failure in RequestStackKernelTest, which needs to call the request stack middleware service rather than http_kernel.
Comment #44
cpj commentedAnother attempt to fix one failure in RequestStackKernelTest. This time we let RequestStackMiddleware handle the request to push/pop correctly
Comment #46
cpj commentedAfter debugging with big-pipe enabled, the problem is obvious. Anything that refers to the request stack in $response-send() is going to fail. Typically, this will be because modules re-open the session, which needs the current request. The reason big-pipe etc work in head is that in the current implementation we push twice & pop once, so there is still a request object on the stack in $response-send() and $response->terminate(). I will investigate solution options.
Comment #47
cpj commentedThis patch adds send(Request $request, Response $reponse) to DrupalKernal. This wraps the HttpFoundation $response->send() with a push/pop to request stack. This works locally with big-pipe enabled, so lets see if more tests pass...
Comment #50
cpj commentedA new approach which attempts to solve the following issues:
The existing call to DrupalKernel::preHandle() from the KernelPreHandle StackMiddleWare, pushes to the request stack early enough, so this is unchanged.
The remaining issues are hopefully resolved with following new classes
The key new learning is that StackMiddleWare alone cannot solve this problem. HttpKernel issues KernelEvents::TERMINATE after the StackMiddleWare classes have terminated. There are subscribers to KernelEvents::TERMINATE that require the current request, so the pop from the request stack must occur after these have run. A controlled way to do this is with a new subscriber to the KernelEvents::TERMINATE event that has a priority that means it runs last of all. Only __destruct() functions run after KernelEvents::TERMINATE completes.
Lets see what happens.
Comment #51
cpj commentedThis time with a modified version of the unit test from #26, which now tests the request stack is empty after $http_kernel->terminate($request, $response).
Comment #53
dawehnerHaha, so the unit test breaks?
I think moving the request stack handling out of http kernel is the right thing to do, given that the middleware layer is the place we interact with incoming/fake http requests.
Comment #54
dawehnerThat seems to be an unrelated change.
For documentation purposes I think we should try to answer what terminating the environment means. What is terminated ...
Nice documentation! I don't get the last point though. We extend the existing class, so from just the type system, it should be allowed to pass it in. Maybe I'm missing something here :)
Nice documentation!
Just a quick comment, this patch doesn't comply yet with our coding standard, see https://www.drupal.org/docs/develop/standards/coding-standards Using phpcs with the rules defined in https://www.drupal.org/project/coder make your life much easier!
It is great to have test coverage here
Comment #55
cpj commentedHi @dawehner,
Thanks for the comments in #54, which I will work though.
Yes, I just cannot make unit test work, although I'm not sure you can call it "unit test" when it goes through the entire request / response cycle. Locally I have built a much more detailed set of unit tests, but so far it doesn't produce the results I expect. Any hints or links on how to debug unit tests with Xdebug in PhpStorm ?
Comment #56
sinasalek commentedHere is the meta issue #2218651: [meta] Make Drupal compatible with persistent app servers like ReactPHP, PHP-PM, PHPFastCGI, FrankenPHP, Swoole
Comment #57
cpj commentedWith some help from @dawehner, I think I finally have a set of tests that should work - at least they pass phpunit locally
Comment #58
cpj commentedSame patch after phpcbf
Comment #59
cpj commentedRemoved redundant code from test.
Updated documentation as per #54
I think I'm done here.
Comment #60
dawehnerGreat work! Thank you @cpj!
We should explain more why we are doing things here.
Why can't we pass in the request stack as normal parameter in the constructor?
Comment #61
cpj commentedHi @dawehner,
I added more documentation to StackMiddlewareSubscriber. See what you think.
As suggested, I've added HttpKernelWrapper::__construct() to override HttpKernel::__construct() and to inject RequestStackWrapper that way. I removed the redundant setter for RequestStackWrapper from HttpKernelWrapper.
Comment #62
almaudoh commentedNice to see this patch green finally. Great job @cpj! I have some comments / questions:
1. Seeing as the approach no more uses stack middleware, I think the
RequestStackWrapperandHttpKernelWrappershould be moved to more appropriate namespaces - maybe\Drupal\Corewhere theDrupalKernelalso resides.2. Do we really need the
RequestStackWrapperas it seems to just replicate the Symfony request stack. The class doc says "This also prevents both Drupal and Symfony pushing the same request to RequestStack twice and only popping the request once.". Maybe I'm not seeing everything, but the code doesn't seem to do that. Are we planning to do uniqueness checks? The unit test would need to cover that.3. A more appropriate name for
StackMiddlewareSubscribermay beRequestStackSubscriberor something similar since this is no more stack middleware.4. Great test coverage!
Comment #63
cpj commentedHi @almaudoh,
1. This work is intimately linked with StackMiddleware, so I see no reason to move it to a different namespace.
2. RequestStackWrapper is absolutely critical to this fix and is not the same as RequestStack. It is used by HttpKernel only. It prevents HttpKernel from push/pop to/from the RequestStack but still gives HttpKernel access to the RequestStack.
3. See 1.
Comment #64
almaudoh commented#63.2 Ok, thanks for clarifying.
The doc in RequestStackWrapper needs to be updated:
This should be adjusted to reflect the constructor injection now.
Comment #65
cpj commentedThanks @almaudoh - I've updated the doc in RequestStackWrapper.
Comment #66
dawehnerI still don't understand this class. The request stack wrapper is a valid request stack, so you should be able to simply pass it into the normal http kernel.
Comment #67
cpj commentedHi @dawehner - I don't think you can pass HttpKernelWrapper directly to HttpKernel because the constructor of HttpKernel expects RequestStack. Perhaps I'm missing something ?
EDIT: I mean "I don't think you can pass RequestStackWrapper directly to HttpKernel because the constructor of HttpKernel expects RequestStack"
Comment #68
cpj commentedHi @dawehner,
my mistake, of course, you're correct. RequestStackWrapper is an instance of RequestStack so can be passed to HttpKernel. ( I'm clearly spending way too much time working in C#, with it's stricter parameter type checking).
I've removed the HttpKernelWrapper class, and this version of core.services.yml injects RequestStackWrapper into HttpKernel.
Comment #69
dawehnerI wonder whether it would be easier to understand if this checking is done more near the getting of the request objects. What do you think?
Comment #70
cpj commentedOK @dawehner - is this version of RequestStackMiddlewareTest easier to understand ? I rearranged the code and added more comments.
Comment #71
dawehnerI personally think so. Thank you @cpj
Comment #72
cpj commentedHi @dawehner - OK, thanks. Are we done here ?
Comment #74
dawehner@cpj
Can you add an issue summary to describe the possible solutions and why we went with the one in the patch?
Comment #75
fubhy commentedI ran into this issue while working on the GraphQL module... So I ended up doing this: https://github.com/fubhy/graphql-drupal/pull/100/files
Comment #77
Anonymous (not verified) commented8.4 is already out. 8.5 is closing in. What's the current state?
Comment #78
wim leersBug fixes are committed to the current stable release, which is 8.4.
The current state can be seen in this issue: nobody is working on this. It’d be great if you could help move this issue forward! :)
Comment #80
martin107 commentedComment #81
martin107 commentedNeeded a reroll.
Comment #82
andypostDocs clean-up, btw why not add this "poping" in
\Drupal\Core\EventSubscriber\KernelDestructionSubscriber::getSubscribedEvents()this one also supposed to run lasts/Pop/Pops
needs doc block
should be inherited
Comment #83
martin107 commentedThe smallest of changes
array() becomes [].
Comment #84
andypostIt just needs to update summay why another subscriber used and that is what not clear to me in #82 about KernelDestructionSubscriber
Comment #85
mglamanThis sums it up. It's destroying requests objects, so it needs to be _the last thing_, even after the core destruct event.
The destruct subscriber has a priority of -100.
So we need to communicate that into the issue summary, then?
Comment #89
andypostReroll for 8.9
Comment #90
andypostSo this one and related the only blocker remaining
Updated issue summary a bit
Comment #92
andypostPatch fixes test but failures in views looks valid, the last one is about related #2962157: TestSiteApplicationTest requires a database despite being a unit test
Comment #94
andypostDebugged last failed test and the issue is that now subrequest in
\Drupal\Tests\taxonomy\Kernel\Views\TaxonomyDefaultArgumentTest::initViewWithRequest()does not populate route match parametersWhich is caused by subrequest returns 404 for
/node/1Comment #96
andypostRoute rebuild no longer needed
Comment #97
andypostComment #101
andypostre-roll for 9.3
Comment #103
andypostFix usage of deprecated
getMasterRequest()Comment #105
andypostComment #106
joachim commentedCould this be the cause of #3218022: Running an HTTP subrequest leaves the request stack in an incorrect state?
Comment #108
andypostLooks it could be combined with #2331913: Move DrupalKernel::preHandle code into kernel decorator
it makes sense in context of #2467021: Make it possible to just use Kernel::boot() and not require preHandle
Comment #111
andypostfew TODO added in #2484991: Add the session to the request in KernelTestBase, BrowserTestBase, and drush
Comment #115
volegerRerolled.
Changes affected by CR Drupal\Core\Http\RequestStack is deprecated
Created MR
Comment #116
andypostThank you, some tests failed and it needs CR
As there's many+1 in related issue it could be follow-up to prevent popping of ech request twice
Comment #117
andypostFixed tests after https://www.drupal.org/node/3357408
Comment #118
andypost2 tests still fail
Comment #119
andypostComment #121
alexpottI've looked at the MR and the current solution is quite confusing for me. I wonder if instead of having a wrapped request stack we can pass in a NULL for the request stack and allow the Symfony HttpKernel to use it's own request stack - separate from Drupals. This would decouple the request stacks which I think might make this make more sense than wrapping.
Alternatively we could admit the \Symfony\Component\HttpKernel\HttpKernel doesn't work for us and we could copy and maintain the code in a way that does.
Comment #123
alexpottSo the idea of just allowing the Symfony code to instantiate its own request stack did not work - see https://git.drupalcode.org/project/drupal/-/merge_requests/9861 - interesting fails.
I've updated the old MR to pass PHPStan and coding standards on 11.x plus I've made the service it adds private and less useful looking. I'm still not convinced that adding a new service is better than reworking Symfony\Component\HttpKernel\HttpKernel to work the way we want it to.
Comment #124
joachim commented> Alternatively we could admit the \Symfony\Component\HttpKernel\HttpKernel doesn't work for us and we could copy and maintain the code in a way that does.
The fact that we use the Symfony HttpKernel is pretty useful over at #3390193: Add a drupalGet() method to KernelTestBase.
Comment #125
larowlanHit a pretty gnarly bug from this yesterday on a client project that was making use of sub-requests and cacheable json responses.
The net result was cache poisoning via the route cache context.
With a sub-request we push each request to the stack twice but only pop it once, the current route match can end up still pointing at the sub-request rather than the parent request and as a result any cacheable responses that use the route cache context end up with the wrong route name and parameters.
This lead to a scenario where an anonymous user could request a JSON page that made a subrequest to a regular node page but the JSON page ended up being cached with the node page's route params. After that any request to the node page was getting the cached JSON response.
What's left here?
Comment #126
pwolanin commentedWhat's wrong with the original MR that tries to pop off the extra request?
Comment #127
smustgrave commentedNot sure which to review as there are 2 MRs but both have test failures
Comment #130
alexpottOkay I think all the tests are fixed here. The approach now pops the request in DrupalKernel::terminate(). I think this is a nice symmetry with the push in DrupalKernel::preHandle(). The same object does the push and pop - which makes sense to me and I think addresses #126.
The test fix in core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyDefaultArgumentTest.php is interesting and I think shows why #125 is gnarly and there are probably more problems for sub requests here.
It would be fantastic to be done here.
Comment #131
alexpottComment #132
alexpottActually I still think this is wrong because when we handle a sub request we're not popping the sub request off the stack once we've handled it.
Comment #133
alexpottUpdated the issue summary to reflect what is changing/being fixed here.
Comment #134
alexpottComment #135
alexpottI've changed the test coverage to have very explicit testing of the request stack when making sub-requests and after termination.
What is very interesting about this work is that I think it is wrong for our services like the route builder that runs in termination to need the request. But changing this is going to be hard and this MR is a definite improvement. I think moving using Drupal in an event loop like frankenphp is going to make this part of the issue rear it's head again and I suggest that we file a follow-up to discuss solutions for that. We also need a follow-up to remove the unnecessary push in \Drupal\Core\Update\UpdateKernel::handle().
Comment #136
mfbLogging or tracing-related tasks generally are going to want the request object, but also qualify as "heavy" actions that should be performed after the response has been streamed. If terminate event listeners shouldn't have access to the request, then.. do we need another event that is aware of the request but is dispatched after the response is sent?
Comment #138
znerol commentedIndeed. Same for the session and currentUser. I feel that long(ish) running tasks should be created during the request-response cycle, but in a self-contained way (i.e., independent of state or stateful services). Like this they can be fired in the terminate event without access to the request context. And later also in a background fiber when a runloop becomes available.
Comment #139
alexpottSo looking deeper into request stack usages after we should have popped the request stack - so for example we have:
I can see two options:
The advantage of option 1 is that it keeps the request stack consistent with the upstream logic. The advantage of option 2 is that http kernel middle in contrib / custom that relies on the request still been on the stack in between \Drupal\Core\StackMiddleware\KernelPreHandle::handle() and \Drupal\Core\DrupalKernel::terminate() will continue to work.
I guess there is a question whether these things should actually be happening in terminate.
My gut feel is that we should combine option 1 and 2. Then we can deprecate accessing the request outside \Drupal\Core\DrupalKernel::terminate() and \Drupal\Core\StackMiddleware\KernelPreHandle::handle() to the request stack post
\Drupal\Core\StackMiddleware\KernelPreHandle::handle()Comment #140
alexpottSo from implementing a combination of option 1 and 2 I've discovered that \Drupal\page_cache\StackMiddleware\PageCache::handle() relies on being able to get the current route match based on the current request. This happens when it does the response policy check in \Drupal\page_cache\StackMiddleware\PageCache::storeResponse() - it eventually hits \Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes::check(). Interesting and very tricky.
I think we have to accept that Drupal's expectations around the kernel and the request stack are different from Symfony's. And the way the current MR is working is the best we can do. It is the most honest. We expect the main request to persist until kernel terminate is done. I'm not sure what this means for FrankenPHP and event loop based code. My best guess is that the event loop code is going to have to manage the request stack a little and how and when it calls terminate.
Comment #142
alexpottDoing some more reading. #3313404: Use symfony/runtime for less bespoke bootstrap/compatibility with varied runtime environments says:
This convinces me even more that the current approach this MR takes is the correct approach. It is odd to leave the main request on the stack and not pop it off at the same place we pop sub requests BUT this reflects Drupal's requirements and at least now at the end of index.php execution we have no requests on the stack.
Comment #143
larowlanLeft a couple of observations on the tests on the MR. I think other than that, this is RTBC
Comment #144
alexpottAddressed the reviews - thanks everyone!
Comment #145
larowlanLooks good, will be great to resolve this after all this time - thanks Alex
Comment #147
catchThis looks great. Comments nicely explain why we're doing it this way. Would be major surgery to have the request not available in ::destruct() given how much we rely on that for end of request tasks so glad we found a different way to handle things.
Did my best with issue credit but this is a long issue so may have overlooked people, apologies if so.
Committed/pushed to main and 11.x, thanks!