diff --git a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php index b5fc2aaf25a685b87fdd1f423ed6f2f7bfcb9e1f..8dc9bac392eac15ca673acab3b507e6851e8814f 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 1719f779143c706f18437b45574ff7132a9198f6..0be92d6ffde0db46c314eb2fea418b767b247457 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 e818522a69ffae32c87434d154a9c1f0e44bbf50..03e1d4261cd955addefbc202b17f740545a7217c 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 f88fa41416d0e5d0842a6edc8ff7e5f8a593df9e..b3cc1c0df83d1b26b8dbbc501febd5584ba10492 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 9c9f5b16c6072587c361e82b94fce5b2d263e6da..d0893201e1a875a4edcab376c89de20c2be6f05d 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 d6b71e3a36a5b5f7029d9e3dd833fbfa9c7173a2..2bd7360721c79d03336838280975fa3d96c6a0d6 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 9ba855d1f158f2fad261a856e673c085655591c3..5b43ad50bf1ab7666825b85cc705682280434ebd 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 1c8873344bee8645cc897af706197d1a47115c8f..f6ebeb9be328cdc98a55db60470b88ab0377bbe7 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 cbab5df838eb107f738039d82eb0e8145f636102..c5425e95cf488f4a467fce3d2f59e75f6f3a5da2 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 0000000000000000000000000000000000000000..ed91a24398748d56f886c784611ecf4fe2777737 --- /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 0000000000000000000000000000000000000000..51eaae2d4b7de5bf618d8fdec691c063a2a9a360 --- /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 0000000000000000000000000000000000000000..316ce275d04d7e4e4e850ea388377e20d70c6511 --- /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 0000000000000000000000000000000000000000..42b1e25def8a1ffc38f00463b55fd8f07d20d15d --- /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 0000000000000000000000000000000000000000..210adc70aca0a47e2e2a1900a01c235814f7426c --- /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 03f44fec4ba4c5bfbe4aebfc5b4a7e0ec05d618e..b806e582d6a45f3231b95d4294fd13584d35f28a 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 6d7798721c71940bbf0f8c7872a3dda98cd3d3c1..7c36abd3816010f1579485f5422eaabfa6989af7 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 0000000000000000000000000000000000000000..38cad0a6385425042b1e4e408b6fa81da98322c9 --- /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 9a5431a2c7e7e1b54c6d427ba0b70afe69caffb5..83888277415bd51312514a4be16fd6ba9ece5285 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;