From c4690891f3649720c7faaaa31a0849af7632bc2b Mon Sep 17 00:00:00 2001 From: catch <catch@35733.no-reply.drupal.org> Date: Tue, 18 Jul 2023 13:47:42 +0100 Subject: [PATCH] Issue #3295790 by bradjones1, mstrelan, rpayanm, catch, alexpott, acbramley, longwave, kim.pepper, mfb: Post-response task running (destructable services) are actually blocking; add test coverage and warn for common misconfiguration --- .../FinishResponseSubscriber.php | 20 +++++++++ core/misc/cspell/dictionary.txt | 1 + .../HtmlResponseBigPipeSubscriber.php | 6 +++ .../src/Functional/JsonApiRegressionTest.php | 18 +++++--- .../jsonapi/tests/src/Functional/NodeTest.php | 4 ++ .../ConfigurableLanguageManagerTest.php | 12 +++++ .../src/Functional/LocaleLocaleLookupTest.php | 5 +++ .../tests/src/Functional/PathAliasTest.php | 4 ++ core/modules/system/system.install | 11 +++++ .../destructable_test.info.yml | 5 +++ .../destructable_test.routing.yml | 7 +++ .../destructable_test.services.yml | 4 ++ .../CallsDestructableServiceController.php | 45 +++++++++++++++++++ .../destructable_test/src/Destructable.php | 34 ++++++++++++++ .../src/Controller/SystemTestController.php | 4 +- .../System/ShutdownFunctionsTest.php | 10 ++--- .../HttpKernel/DestructableServiceTest.php | 43 ++++++++++++++++++ core/tests/Drupal/Tests/ApiRequestTrait.php | 6 +++ 18 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 core/modules/system/tests/modules/destructable_test/destructable_test.info.yml create mode 100644 core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml create mode 100644 core/modules/system/tests/modules/destructable_test/destructable_test.services.yml create mode 100644 core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php create mode 100644 core/modules/system/tests/modules/destructable_test/src/Destructable.php create mode 100644 core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php diff --git a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php index b5fc2aaf25a6..8dc9bac392ea 100644 --- a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php @@ -12,6 +12,7 @@ use Drupal\Core\Site\Settings; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -298,6 +299,21 @@ protected function setExpiresNoCache(Response $response) { $response->setExpires(\DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 UTC')); } + /** + * Sets the Content-Length header on the response. + * + * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event + * The event to process. + */ + public function setContentLengthHeader(ResponseEvent $event): void { + $response = $event->getResponse(); + if ($response instanceof StreamedResponse) { + return; + } + + $response->headers->set('Content-Length', strlen($response->getContent()), TRUE); + } + /** * Registers the methods in this class that should be listeners. * @@ -309,6 +325,10 @@ public static function getSubscribedEvents(): array { // There is no specific reason for choosing 16 beside it should be executed // before ::onRespond(). $events[KernelEvents::RESPONSE][] = ['onAllResponds', 16]; + // Run very late, after all other response subscribers have run. However, + // any response subscribers that convert a response to a streamed response + // must run after this and undo what this does. + $events[KernelEvents::RESPONSE][] = ['setContentLengthHeader', -1024]; return $events; } diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 1719f779143c..0be92d6ffde0 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -625,6 +625,7 @@ linkset linktext lisu litererally +litespeed llamaids llamasarelame llamma diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php index e818522a69ff..03e1d4261cd9 100644 --- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -82,6 +82,8 @@ public function onRespond(ResponseEvent $event) { $content = $response->getContent(); $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content); $response->setContent($content); + // FinishResponseSubscriber::setContentLengthHeader() already ran. + $response->headers->set('Content-Length', strlen($content), TRUE); } // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders, @@ -93,6 +95,10 @@ public function onRespond(ResponseEvent $event) { $big_pipe_response = new BigPipeResponse($response); $big_pipe_response->setBigPipeService($this->getBigPipeService($event)); + + // A BigPipe response's length is impossible to predict. + $big_pipe_response->headers->remove('Content-Length'); + $event->setResponse($big_pipe_response); } diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php index f88fa41416d0..b3cc1c0df83d 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php @@ -1300,10 +1300,13 @@ public function testNonCacheableMethods() { $methods = [ 'HEAD', 'GET', - 'PATCH', - 'DELETE', ]; - $non_post_request_options = $base_request_options + [ + foreach ($methods as $method) { + $response = $this->request($method, Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $base_request_options); + $this->assertSame(200, $response->getStatusCode()); + } + + $patch_request_options = $base_request_options + [ RequestOptions::JSON => [ 'data' => [ 'type' => 'node--article', @@ -1311,10 +1314,11 @@ public function testNonCacheableMethods() { ], ], ]; - foreach ($methods as $method) { - $response = $this->request($method, Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $non_post_request_options); - $this->assertSame($method === 'DELETE' ? 204 : 200, $response->getStatusCode()); - } + $response = $this->request('PATCH', Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $patch_request_options); + $this->assertSame(200, $response->getStatusCode()); + + $response = $this->request('DELETE', Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $base_request_options); + $this->assertSame(204, $response->getStatusCode()); $post_request_options = $base_request_options + [ RequestOptions::JSON => [ diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php index 9c9f5b16c607..d0893201e1a8 100644 --- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php +++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php @@ -393,11 +393,15 @@ protected function assertCacheableNormalizations(): void { $request_options = $this->getAuthenticationRequestOptions(); $request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'title']]; $this->request('GET', $url, $request_options); + // Cacheable normalizations are written after the response is flushed to + // the client; give the server a chance to complete this work. + sleep(1); // Ensure the normalization cache is being incrementally built. After // requesting the title, only the title is in the cache. $this->assertNormalizedFieldsAreCached(['title']); $request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'field_rest_test']]; $this->request('GET', $url, $request_options); + sleep(1); // After requesting an additional field, then that field is in the cache and // the old one is still there. $this->assertNormalizedFieldsAreCached(['title', 'field_rest_test']); diff --git a/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php b/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php index d6b71e3a36a5..2bd7360721c7 100644 --- a/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php +++ b/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php @@ -266,4 +266,16 @@ public function testUserProfileTranslationWithPreferredAdminLanguage() { $assert_session->pageTextNotContains($field_label_es); } + /** + * {@inheritdoc} + */ + protected function drupalGet($path, array $options = [], array $headers = []) { + $response = parent::drupalGet($path, $options, $headers); + // The \Drupal\locale\LocaleTranslation service clears caches after the + // response is flushed to the client; wait for Drupal to perform its + // termination work before continuing. + sleep(1); + return $response; + } + } diff --git a/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php b/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php index 9ba855d1f158..5b43ad50bf1a 100644 --- a/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php +++ b/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php @@ -68,6 +68,11 @@ public function testLanguageFallbackDefaults() { * @dataProvider providerTestFixOldPluralStyle */ public function testFixOldPluralStyle($translation_value, $expected) { + // The \Drupal\locale\LocaleTranslation service stores localization cache + // data after the response is flushed to the client. We do not want to race + // with any string translations that may be saving from the login in + // ::setUp(). + sleep(1); $string_storage = \Drupal::service('locale.storage'); $string = $string_storage->findString(['source' => 'Member for', 'context' => '']); $lid = $string->getId(); diff --git a/core/modules/path/tests/src/Functional/PathAliasTest.php b/core/modules/path/tests/src/Functional/PathAliasTest.php index 1c8873344bee..f6ebeb9be328 100644 --- a/core/modules/path/tests/src/Functional/PathAliasTest.php +++ b/core/modules/path/tests/src/Functional/PathAliasTest.php @@ -66,6 +66,9 @@ public function testPathCache() { \Drupal::cache('data')->deleteAll(); // Make sure the path is not converted to the alias. $this->drupalGet(trim($edit['path[0][value]'], '/'), ['alias' => TRUE]); + // The \Drupal\path_alias\AliasWhitelist service performs cache clears after + // Drupal has flushed the response to the client; wait for this to finish. + sleep(1); $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:' . $edit['path[0][value]']), 'Cache entry was created.'); // Visit the alias for the node and confirm a cache entry is created. @@ -73,6 +76,7 @@ public function testPathCache() { // @todo Remove this once https://www.drupal.org/node/2480077 lands. Cache::invalidateTags(['rendered']); $this->drupalGet(trim($edit['alias[0][value]'], '/')); + sleep(1); $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:' . $edit['path[0][value]']), 'Cache entry was created.'); } diff --git a/core/modules/system/system.install b/core/modules/system/system.install index cbab5df838eb..c5425e95cf48 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -501,6 +501,17 @@ function system_requirements($phase) { } } + if ($phase === 'runtime') { + if (!function_exists('fastcgi_finish_request') && !function_exists('litespeed_finish_request') && !ob_get_status()) { + $requirements['output_buffering'] = [ + 'title' => t('Output Buffering'), + 'error_value' => t('Not enabled'), + 'severity' => REQUIREMENT_WARNING, + 'description' => t('<a href="https://www.php.net/manual/en/function.ob-start.php">Output buffering</a> is not enabled. This may degrade Drupal\'s performance. You can enable output buffering by default <a href="https://www.php.net/manual/en/outcontrol.configuration.php#ini.output-buffering">in your PHP settings</a>.'), + ]; + } + } + if ($phase == 'install' || $phase == 'update') { // Test for PDO (database). $requirements['database_extensions'] = [ diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml new file mode 100644 index 000000000000..ed91a2439874 --- /dev/null +++ b/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml @@ -0,0 +1,5 @@ +name: 'Tests for DestructableInterface' +type: module +description: 'Provides test services which implement DestructableInterface.' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml new file mode 100644 index 000000000000..51eaae2d4b7d --- /dev/null +++ b/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml @@ -0,0 +1,7 @@ +destructable: + path: '/destructable' + defaults: + _controller: '\Drupal\destructable_test\Controller\CallsDestructableServiceController::render' + _title: 'Calls destructable service' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml new file mode 100644 index 000000000000..316ce275d04d --- /dev/null +++ b/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml @@ -0,0 +1,4 @@ +services: + Drupal\destructable_test\Destructable: + tags: + - { name: needs_destruction } diff --git a/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php b/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php new file mode 100644 index 000000000000..42b1e25def8a --- /dev/null +++ b/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\destructable_test\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\destructable_test\Destructable; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Controller to instantiate the destructable service. + */ +final class CallsDestructableServiceController extends ControllerBase { + + /** + * Destructable service. + * + * @var \Drupal\destructable_test\Destructable + */ + protected $destructable; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get(Destructable::class)); + } + + public function __construct(Destructable $destructable) { + $this->destructable = $destructable; + } + + /** + * Render callback. + * + * @return \Symfony\Component\HttpFoundation\Response + * Response. + */ + public function render(Request $request): Response { + $this->destructable->setSemaphore($request->query->get('semaphore')); + return new Response('This is a longer-ish string of content to send to the client, to invoke any trivial transfer buffers both on the server and client side.'); + } + +} diff --git a/core/modules/system/tests/modules/destructable_test/src/Destructable.php b/core/modules/system/tests/modules/destructable_test/src/Destructable.php new file mode 100644 index 000000000000..210adc70aca0 --- /dev/null +++ b/core/modules/system/tests/modules/destructable_test/src/Destructable.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\destructable_test; + +use Drupal\Core\DestructableInterface; + +final class Destructable implements DestructableInterface { + + /** + * Semaphore filename. + * + * @var string + */ + protected string $semaphore; + + /** + * Set the destination for the semaphore file. + * + * @param string $semaphore + * Temporary file to set a semaphore flag. + */ + public function setSemaphore(string $semaphore): void { + $this->semaphore = $semaphore; + } + + /** + * {@inheritdoc} + */ + public function destruct() { + sleep(3); + file_put_contents($this->semaphore, 'ran'); + } + +} diff --git a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php index 03f44fec4ba4..b806e582d6a4 100644 --- a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php +++ b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php @@ -332,8 +332,8 @@ public function shutdownFunctions($arg1, $arg2) { // the exception message can not be tested. // @see _drupal_shutdown_function() // @see \Drupal\system\Tests\System\ShutdownFunctionsTest - if (function_exists('fastcgi_finish_request')) { - return ['#markup' => 'The function fastcgi_finish_request exists when serving the request.']; + if (function_exists('fastcgi_finish_request') || ob_get_status()) { + return ['#markup' => 'The response will flush before shutdown functions are called.']; } return []; } diff --git a/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php b/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php index 6d7798721c71..7c36abd38160 100644 --- a/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php +++ b/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php @@ -42,18 +42,18 @@ public function testShutdownFunctions() { $arg2 = $this->randomMachineName(); $this->drupalGet('system-test/shutdown-functions/' . $arg1 . '/' . $arg2); - // If using PHP-FPM then fastcgi_finish_request() will have been fired - // returning the response before shutdown functions have fired. + // If using PHP-FPM or output buffering, the response will be flushed to + // the client before shutdown functions have fired. // @see \Drupal\system_test\Controller\SystemTestController::shutdownFunctions() - $server_using_fastcgi = strpos($this->getSession()->getPage()->getContent(), 'The function fastcgi_finish_request exists when serving the request.'); - if ($server_using_fastcgi) { + $response_will_flush = strpos($this->getSession()->getPage()->getContent(), 'The response will flush before shutdown functions are called.'); + if ($response_will_flush) { // We need to wait to ensure that the shutdown functions have fired. sleep(1); } $this->assertEquals([$arg1, $arg2], \Drupal::state()->get('_system_test_first_shutdown_function')); $this->assertEquals([$arg1, $arg2], \Drupal::state()->get('_system_test_second_shutdown_function')); - if (!$server_using_fastcgi) { + if (!$response_will_flush) { // Make sure exceptions displayed through // \Drupal\Core\Utility\Error::renderExceptionSafe() are correctly // escaped. diff --git a/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php b/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php new file mode 100644 index 000000000000..38cad0a63854 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\FunctionalTests\HttpKernel; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests invocation of services performing deferred tasks after response flush. + * + * @see \Drupal\Core\DestructableInterface + * + * @group Http + */ +class DestructableServiceTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'destructable_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + public function testDestructableServiceExecutionOrder(): void { + $file_system = $this->container->get('file_system'); + assert($file_system instanceof FileSystemInterface); + $semaphore = $file_system + ->tempnam($file_system->getTempDirectory(), 'destructable_semaphore'); + $this->drupalGet(Url::fromRoute('destructable', [], ['query' => ['semaphore' => $semaphore]])); + // This should be false as the response should flush before running the + // test service. + $this->assertEmpty(file_get_contents($semaphore), 'Destructable service did not run when response flushed to client.'); + // The destructable service will sleep for 3 seconds, then run. + // To ensure no race conditions on slow test runners, wait another 3s. + sleep(6); + $this->assertTrue(file_get_contents($semaphore) === 'ran', 'Destructable service did run.'); + } + +} diff --git a/core/tests/Drupal/Tests/ApiRequestTrait.php b/core/tests/Drupal/Tests/ApiRequestTrait.php index 9a5431a2c7e7..83888277415b 100644 --- a/core/tests/Drupal/Tests/ApiRequestTrait.php +++ b/core/tests/Drupal/Tests/ApiRequestTrait.php @@ -37,6 +37,12 @@ trait ApiRequestTrait { * @see \GuzzleHttp\ClientInterface::request() */ protected function makeApiRequest($method, Url $url, array $request_options) { + // HEAD requests do not have bodies. If one is specified, Guzzle will not + // ignore it and the request will be treated as GET with an overridden + // method string, and libcurl will expect to read a response body. + if ($method === 'HEAD' && array_key_exists('body', $request_options)) { + unset($request_options['body']); + } $this->refreshVariables(); $request_options[RequestOptions::HTTP_ERRORS] = FALSE; $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE; -- GitLab