Updated: Comment #N

Problem/Motivation

When the page cache is active, accessing a private file leads to a 500 response. The file content itself is still accessible though. The problem is likely that FinishResponseSubscriber is executed for all responses (including private file downloads) while back in Drupal 7 comparable drupal_page_footer was not because private file downloads triggered an early exit (see file_transfer).

As a consequence the page cache is active for private file downloads in Drupal 8 while it was not on D7 and therefor we need to disable it explicitely on private file downloads.

Error log

Uncaught PHP Exception LogicException: "The content cannot be set on a BinaryFileResponse instance." at /tmp/drupal/core/vendor/symfony/http-foundation/Symfony/Component/HttpFoundation/BinaryFileResponse.php line 272
#0  Symfony\Component\HttpFoundation\BinaryFileResponse->setContent() called at [core/includes/bootstrap.inc:960]
#1  drupal_serve_page_from_cache() called at [core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php:105]
#2  Drupal\Core\EventSubscriber\FinishResponseSubscriber->onRespond()
#3  call_user_func() called at [core/vendor/symfony/event-dispatcher/Symfony/Component/EventDispatcher/EventDispatcher.php:164]
#4  Symfony\Component\EventDispatcher\EventDispatcher->doDispatch() called at [core/vendor/symfony/event-dispatcher/Symfony/Component/EventDispatcher/EventDispatcher.php:53]
#5  Symfony\Component\EventDispatcher\EventDispatcher->dispatch() called at [core/vendor/symfony/event-dispatcher/Symfony/Component/EventDispatcher/ContainerAwareEventDispatcher.php:167]
#6  Symfony\Component\EventDispatcher\ContainerAwareEventDispatcher->dispatch() called at [core/vendor/symfony/http-kernel/Symfony/Component/HttpKernel/HttpKernel.php:166]
#7  Symfony\Component\HttpKernel\HttpKernel->filterResponse() called at [core/vendor/symfony/http-kernel/Symfony/Component/HttpKernel/HttpKernel.php:148]
#8  Symfony\Component\HttpKernel\HttpKernel->handleRaw() called at [core/vendor/symfony/http-kernel/Symfony/Component/HttpKernel/HttpKernel.php:66]
#9  Symfony\Component\HttpKernel\HttpKernel->handle() called at [core/lib/Drupal/Core/HttpKernel.php:65]
#10 Drupal\Core\HttpKernel->handle() called at [core/lib/Drupal/Core/DrupalKernel.php:284]
#11 Drupal\Core\DrupalKernel->handle() called at [core/includes/bootstrap.inc:1478]
#12 drupal_handle_request() called at [index.php:15]

HTTP Request/Response:

Request URL:http://localhost:3000/system/files/private.txt
Request Method:GET
Status Code:500 Internal Server Error

Request Headers

Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding:gzip,deflate,sdch
Accept-Language:de,en-US;q=0.8,en;q=0.6,fr;q=0.4
Cache-Control:max-age=0
Connection:keep-alive
Host:localhost:3000
Referer:http://localhost:3000/node/1

Response Headers

Cache-Control:must-revalidate, no-cache, post-check=0, pre-check=0, private
Connection:close
content-language:en
content-language:en
content-length:3467
Content-Type:text/plain; charset=UTF-8
date:Sat, 15 Mar 2014 15:17:34 GMT
date:Sat, 15 Mar 2014 15:17:34 GMT
etag:"1394896654"
etag:"1394896654"
Expires:Sun, 19 Nov 1978 05:00:00 GMT
last-modified:Sat, 15 Mar 2014 15:17:34 GMT
last-modified:Sat, 15 Mar 2014 15:17:34 GMT
Server:lighttpd/1.4.31
x-drupal-cache:MISS
x-generator:Drupal 8 (http://drupal.org)
X-Powered-By:PHP/5.4.26-1~dotdeb.1
x-ua-compatible:IE=edge,chrome=1
x-ua-compatible:IE=edge,chrome=1

Proposed resolution

  • Add tests for private file downloads and private image style derivative when page cache is active. Also port them to D7 after they are committed to D8.
  • Choose between two alternative approaches:
    1. Either disable the page cache selectively from within FileDownloadController and ImageStyleDownloadController (patch #7)
    2. Or prevent caching of BinaryFileResponse altogether (patch #8)

Remaining tasks

User interface changes

None

API changes

None

Comments

znerol’s picture

StatusFileSize
new3.16 KB

A patch exposing the failure condition. I noticed that Drupal\file\Tests\DownloadTest is frighteningly small. Are there more tests in some other place covering private file downloads?

znerol’s picture

Status: Active » Needs review

Trigger the test-bot.

znerol’s picture

Adding a drupal_page_is_cacheable(FALSE); to FileDownloadController::download seems to cut it. However it is necessary to fix the heavy-handed x-drupal-cache-header hack before the test will actually pass. That makes this patch bigger than it should be.

Let's see what other things break...

The last submitted patch, 1: 2218463-test-only.patch, failed testing.

Status: Needs review » Needs work
znerol’s picture

Status: Needs work » Needs review
znerol’s picture

Same for private images when an image derivative is rendered for the first time. I'm wondering whether it would be better to just disable the page cache for BinaryFileResponse.

znerol’s picture

This is the alternative approach: Just filter out BinaryFileResponse. I feel that this approach is much more future proved than trying to disable the page cache selectively on all download-controllers. Tests are identical to the ones in #7.

znerol’s picture

Issue summary: View changes
Issue tags: +Regression
znerol’s picture

Issue summary: View changes

The last submitted patch, 7: 2218463-test-only-private-files-and-page-cache.diff, failed testing.

rbayliss’s picture

Title: Private file download throws error 500 when page cache is active » Private file and image style downloads throw 500 error when page cache is active

This is also true of image style downloads, the first time they are generated. I was really hoping there was a better way to fix this than to explicitly check for certain response types before serving from the cache, but for right now, it looks like there isn't. See https://github.com/symfony/symfony/issues/9128

rbayliss’s picture

Rerolled and added an exclusion for StreamedResponse as per the Symfony issue linked above. I also moved the exclusions into drupal_set_page_cache, since that's where the determination of the page's cacheability is currently happening (drupal_page_is_cacheable()). Last but not least, I removed the previous changes to the x-drupal-cache headers, which didn't seem to be needed. Let's see if this passes.

Status: Needs review » Needs work

The last submitted patch, 13: 2218463-page-cache-filter-binary-response-13.diff, failed testing.

znerol’s picture

We are testing for the presence of X-Drupal-Cache header in some tests in order to tell whether the page cache was active during generation of the response. When it is not acceptable to get rid of the header-hack in this issue, it would be possible to remove the X-Drupal-Cache header from within drupal_page_set_cache for BinaryFileResponse and StreamedResponse. Note that currently the header is only removed from the cache-object and not from the response itself.

rbayliss’s picture

Status: Needs work » Needs review
StatusFileSize
new5.8 KB
new1.38 KB

Rerolled based on znerol's patch in comment 8 (please ignore the one I submitted in between), and just added the StreamedResponse as I mentioned in comment 13. Interdiff is against the patch in comment 8.

znerol’s picture

znerol’s picture

Reroll after #2257709: Remove the interdependence between the internal page cache and management of the Cache-Control header for external caches which removed the x-drupal-cache header hack. Sorry, no proper interdiff, but the changes to non-test-code are now down to one line (FinishResponseSubscriber).

The last submitted patch, 18: 2218463-TEST-ONLY-page-cache-filter-binary-response-18.diff, failed testing.

Status: Needs review » Needs work

The last submitted patch, 18: 2218463-page-cache-filter-binary-response-18.diff, failed testing.

znerol’s picture

StatusFileSize
new4.63 KB
new824 bytes

Forgot the use-statements...

znerol’s picture

Status: Needs work » Needs review
dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -81,7 +83,7 @@ public function onRespond(FilterResponseEvent $event) {
    -    $is_cacheable = drupal_page_is_cacheable();
    +    $is_cacheable = drupal_page_is_cacheable() && !($response instanceof BinaryFileResponse) && !($response instanceof StreamedResponse);
    

    Sadly there is no interface or method we can call but indeed have to hardcode it here. It would be great to explain why we do this here.

  2. +++ b/core/modules/file/lib/Drupal/file/Tests/DownloadTest.php
    @@ -53,7 +53,27 @@ function testPublicFileTransfer() {
    +  public function testPrivateFileTransfer() {
    ...
    +  public function testPrivateFileTransferWithPageCache() {
    

    I wonder whether we could name the first one like "testPrivteFileTransferWithoutPageCache"

znerol’s picture

damiankloip’s picture

Here is a reroll for psr-4. I also tested this manually as I just hit the same issue. I think this patch looks good.

damiankloip’s picture

25: 2218463-25.patch queued for re-testing.

Status: Needs review » Needs work

The last submitted patch, 25: 2218463-25.patch, failed testing.

damiankloip’s picture

Status: Needs work » Needs review
StatusFileSize
new4.87 KB

Reroll.

berdir’s picture

Status: Needs review » Reviewed & tested by the community

Comment makes sense, tests fail without the fix, let's get this in.

wim leers’s picture

Status: Reviewed & tested by the community » Active

The CDN module in Drupal 7 has a "Far Future expiration" setting. In short, it serves a file with optimal caching headers. Including binary files.

I agree with this:

As a consequence the page cache is active for private file downloads in Drupal 8 while it was not on D7 and therefor we need to disable it explicitely on private file downloads.

But AFAICT this patch affects *all* binary responses, not just those for private files and image styles?


I think that the overarching problem is this:

 if ($is_cacheable && $this->config->get('cache.page.max_age') > 0) {
      if (!$this->isCacheControlCustomized($response)) {
        $this->setResponseCacheable($response, $request);
      }
    }
    else {
      $this->setResponseNotCacheable($response, $request);
    }

    // Store the response in the internal page cache.
    if ($is_cacheable && $this->config->get('cache.page.use_internal')) {
      drupal_page_set_cache($response, $request);
      …

$is_cacheable controls both whether caching headers will be set and whether the response is going to be stored in the page cache. It should be possible to have caching headers without storing the response in the page cache.

damiankloip’s picture

Status: Active » Needs review
StatusFileSize
new5.52 KB
new2.37 KB

Hmm, that's a very good point. So we need to do something like this instead?

EDIT: Quick patch disclaimer.

Status: Needs review » Needs work

The last submitted patch, 31: 2218463-31.patch, failed testing.

znerol’s picture

Status: Needs work » Needs review
StatusFileSize
new6.65 KB
new4.59 KB

I feel that that restriction can be attributed to the backend. Therefore let's move the check to drupal_page_set_cache.

Tests expect that derived images should not be cacheable at all upon the first retrieval (i.e. when they were generated by ImageStyleDownloadController::deliver). Is this really what we want? In contrast private file downloads set Cache-Control: private allowing the browser to cache the response (see file_get_content_headers).

Interdiff is against #28.

damiankloip’s picture

  1. +++ b/core/includes/common.inc
    @@ -2684,8 +2686,20 @@ function _drupal_bootstrap_full($skip = FALSE) {
    + *   Returns TRUE if the response has been recorded in the cache, FALSE
    

    Not sure about using 'recorded'. Saved, stored, set.. or something.

  2. +++ b/core/includes/common.inc
    @@ -2684,8 +2686,20 @@ function _drupal_bootstrap_full($skip = FALSE) {
    +    return FALSE;
    
    @@ -2709,6 +2723,8 @@ function drupal_page_set_cache(Response $response, Request $request) {
    +  return TRUE;
    
    +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
    @@ -100,8 +94,7 @@ public function onRespond(FilterResponseEvent $event) {
    +    if ($is_cacheable && $this->config->get('cache.page.use_internal') && drupal_page_set_cache($response, $request)) {
    

    It is not the best thing relying on the return value of a function known for just setting. Works for me though.

    Although this does not really just seem like a restriction from the backend. This is more a flaw/shortcoming in how Symfony deals with binary and streamed responses? Also looks like this is not likely to change at any point soon.

znerol’s picture

Ok, then let's go with #31 plus the fix from ImageStyleDownloadController.php.

znerol’s picture

Let's not reintroduce the dependency of the internal cache to the external configuration like #31 does and just fix the 500 error.

Interdiff is against #28.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

That looks much better, thank you! :)

damiankloip’s picture

Yep +1 on the latest patch. Includes everything we have discussed from #30 onwards.

wim leers’s picture

Issue tags: +Performance
alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 0dc696d and pushed to 8.x. Thanks!

  • alexpott committed 0dc696d on 8.x
    Issue #2218463 by znerol, damiankloip, rbayliss: Fixed Private file and...

Status: Fixed » Closed (fixed)

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