Problem/Motivation

Since June 9, the updated dependency environments are failing across PHP versions in
Drupal\Tests\migrate\Functional\process\DownloadFunctionalTest:: testExceptionThrow():
https://www.drupal.org/pift-ci-job/2400269

The fail is consistent across PHP versions in the 9.x branches. (It may or may not also affect D10; we can't tell at present from the jobs against HEAD because of an unrelated PHPStan failure that aborts the test early.)

Proposed resolution

There are two problems here. The first (Comments 1-24) is that the exception message from Guzzle changed. The fix is to make the test less specific.

The second problem (Comments 15 and 25ff) is that BrowserHtmlDebugTrait::getResponseLogHandler() tries to cast a Stream object to a string. This fails when the Stream object represents the destination file. The fix for that is to check isReadable().

Remaining tasks

User interface changes

N/A

API changes

N/A

Data model changes

N/A

Release notes snippet

TBD

CommentFileSizeAuthor
#45 upstream-psr7-3285230-10.0.x-44.patch2.49 KBxjm
#43 upstream-psr7-3285230-fix-only-43.patch2.5 KBbenjifisher
#43 interdiff-3285230-29-43.txt1.67 KBbenjifisher
#42 upstream-psr7-3285230-fix-only-42.patch2.7 KBbenjifisher
#40 upstream-psr7-3285230-fix-only-10.0.x-36.patch2.97 KBxjm
#31 upstream-psr7-3285230-fix-only-29.patch0 bytesxjm
#29 upstream-psr7-3285230-fix-only-29.patch2.34 KBbenjifisher
#29 interdiff-3285230-21-29.txt1.11 KBbenjifisher
#27 upstream-psr7-3285230-fix-only-27.patch116.8 KBbenjifisher
#27 interdiff-3285230-21-27.txt114.54 KBbenjifisher
#21 upstream-psr7-3285230-9.4.x-test-DO-NOT-COMMIT.patch11.86 KBxjm
#21 upstream-psr7-3285230-10.0.x-test-DO-NOT-COMMIT.patch3.59 KBxjm
#21 upstream-psr7-3285230-fix-only-21.patch1.11 KBxjm
#10 upstream-psr7-3285230-10-10.0.x-FAIL.patch2.47 KBxjm
#8 upstream-psr7-3285230-8-FAIL.patch9.87 KBxjm
#4 upstream-guzzle-3285230-4-FAIL.patch6.33 KBxjm
#2 upstream-3285230-FAIL.patch27.59 KBxjm

Issue fork drupal-3285230

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:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

xjm created an issue. See original summary.

xjm’s picture

Status: Active » Needs review
FileSize
27.59 KB

It's worth noting that, while the start of this failure is timed specifically close to the recent Guzzle release, the test does not fail on the normal environments, and the Guzzle update has been deployed already there. So this is due to something else, unless Guzzle 7.4.4 breaks D9 in a way that 6.5.7 7.4.3 don't, and that doesn't break D10.

Attached updates 9.4.x to the latest versions of dependencies allowed by core constraints. Theoretically, this should fail on the normal test environments with the same failure that's linked in the IS.

xjm’s picture

Status: Needs work » Needs review
FileSize
6.33 KB

Just noticed that the updated deps environments are passing on 9.3.x, which does implicate Guzzle 7.4.4. Here's a patch to test that hypothesis instead.

xjm’s picture

OK, so it's not Guzzle... let's proceed with the process of elimination from there.

xjm’s picture

+++ b/composer/Metapackage/CoreRecommended/composer.json
@@ -12,15 +12,15 @@
-        "guzzlehttp/psr7": "1.8.5",
+        "guzzlehttp/psr7": "2.3.0",

Wait, this doesn't look right. I guess we only have the direct constraint for it in Drupal 10, versus it being an indirect dependency in D9. Let's test that first before ruling it out.

xjm’s picture

Status: Needs work » Needs review
FileSize
9.87 KB

Upgrading both Guzzle and the PSR-7 package.

Status: Needs review » Needs work

The last submitted patch, 8: upstream-psr7-3285230-8-FAIL.patch, failed testing. View results

xjm’s picture

Title: Migrate's DownloadFunctionalTest:: testExceptionThrow() is failing when 9.x core dependencies are updated to the latest versions » Migrate's DownloadFunctionalTest:: testExceptionThrow() is failing when 9.x core dependencies are updated to guzzlehttp/psr7 2.30
Status: Needs work » Needs review
FileSize
2.47 KB

Aha! It's the PSR-7 package.

D10 is pinned to 2.2.1, so here's a patch to see whether the test is also broken on D10 with the update.

xjm’s picture

Title: Migrate's DownloadFunctionalTest:: testExceptionThrow() is failing when 9.x core dependencies are updated to guzzlehttp/psr7 2.30 » Migrate's DownloadFunctionalTest:: testExceptionThrow() is failing on guzzlehttp/psr7 2.3.0

Status: Needs review » Needs work

The last submitted patch, 10: upstream-psr7-3285230-10-10.0.x-FAIL.patch, failed testing. View results

xjm’s picture

So yep, this test is broken on D9 and D10 both with guzzlehttp/psr7 2.3.0.

xjm’s picture

The D10 test has a more comprehensible failure, which might be a good place to start:

-'62a51985bb006:uri:download: Client error: `GET http://php-apache-jenkins-drupal-patches-132707/subdirectory/not-existent-404` resulted in a `404 Not Found` response (http://php-apache-jenkins-drupal-patches-132707/subdirectory/not-existent-404)'
+'62a51985bb006:uri:download: Cannot read from non-readable stream (http://php-apache-jenkins-drupal-patches-132707/subdirectory/not-existent-404)'
quietone’s picture

This has to do with this change, Safely get stream contents. There is now a check that a stream is readable before attempting to get the contents. Well, that makes sense. What doesn't make sense is that it is failing when trying to read the destination, which should only be written to.

If I change the mode of the destination to be w+' instead of 'w', (and make adjustments to the exception message) this will pass.

Why does guzzle think it needs to read the destination?

xjm’s picture

Issue tags: +Bug Smash Initiative
phenaproxima’s picture

I haven't dug too far into this but my feeling, based on looking at the failed assertion @xjm posted in #14, is that the test is overzealous. It looks like it should be asserting a simple 404, but is asserting the entire exception message, which inherently ties it to how Guzzle internally wants to work. I'd suggest adjusting the test expectation so it's looking for a 404 status code, regardless of whatever the actual message is.

xjm’s picture

So the assertion is:

    $id = $migration->getPluginId();
    $this->assertEquals("$id:uri:download: Client error: `GET $invalid_url` res\
ulted in a `404 Not Found` response ($invalid_url)", $message->message);
    $this->assertEquals(MigrationInterface::MESSAGE_ERROR, $message->level);

From hereabouts:

    $invalid_url = "{$this->baseUrl}/not-existent-404";
    $valid_url = "{$this->baseUrl}/core/misc/favicon.ico";

    $definition = [
      'source' => [
        'plugin' => 'embedded_data',
        'data_rows' => [
          ['url' => $invalid_url, 'uri' => 'public://first.txt'],               
          ['url' => $valid_url, 'uri' => 'public://second.ico'],

Testing the specific message does seem a bit like testing Guzzle.

However, there isn't any way I can see to check the status code of the response. I didn't walk through it in a debugger or anything, but I suspect this is happening when MigrateExecutable catches \Exception and handles it thus:

  /**                                                                           
   * Takes an Exception object and both saves and displays it.                  
   *                                                                            
   * Pulls in additional information on the location triggering the exception.  
   *                                                                            
   * @param \Exception $exception                                               
   *   Object representing the exception.                                       
   * @param bool $save                                                          
   *   (optional) Whether to save the message in the migration's mapping table. 
   *   Set to FALSE in contexts where this doesn't make sense.                  
   */
  protected function handleException(\Exception $exception, $save = TRUE) {
    $result = Error::decodeException($exception);
    $message = $result['@message'] . ' (' . $result['%file'] . ':' . $result['%\
line'] . ')';
    if ($save) {
      $this->saveMessage($message);
    }
    $this->message->display($message, 'error');
  }

The full \Exception object isn't retained in any form that I can see, so there's no way to check the status code that I can tell without changing either the above method or Drupal\Core\Utility\Error.

phenaproxima’s picture

I'd probably just assert the presence of 404 Not Found in the exception message with $this->assertStringContainsStringIgnoringCase().

xjm’s picture

I'd probably just assert the presence of 404 Not Found in the exception message with $this->assertStringContainsStringIgnoringCase().

But that's not present in the message at all now.

phenaproxima’s picture

But that's not present in the message at all now.

Ah yes, that's true. I must have been malcaffeinated when I wrote #19.

Another option, then, is change handleException() to include the exception code in the logged message, along with some hard-coded prefix. For example, assuming a 404 error has a code of 404:

 protected function handleException(\Exception $exception, $save = TRUE) {
    $result = Error::decodeException($exception);
    $message = '[Guzzle] Error ' . $exception->getCode() . ': ' . $result['@message'] . ' (' . $result['%file'] . ':' . $result['%\
line'] . ')';
    if ($save) {
      $this->saveMessage($message);
    }
    $this->message->display($message, 'error');
  }

Then in the test you could have something like $this->assertStringStartsWith('[Guzzle] Error 404:', $message);.

xjm’s picture

Status: Needs review » Needs work

#22 does not work -- it is catching \Exception so the error could be anything. We could however include the type of that exception by getting the class, but that has also changed with the upstream release I think.

Meanwhile patches NW and I must've had a stale 9.4.x branch.

xjm’s picture

Plot twist...

1) Drupal\Tests\migrate\Functional\process\DownloadFunctionalTest::testExceptionThrow
Failed asserting that '3' matches expected 0.

/var/www/html/vendor/phpunit/phpunit/src/Framework/Constraint/Equality/IsEqual.php:96
/var/www/html/core/modules/migrate/tests/src/Functional/process/DownloadFunctionalTest.php:88
/var/www/html/vendor/phpunit/phpunit/src/Framework/TestResult.php:726

That integer integer-y thing means the migration actually failed once the test gets past the status message, so our approach of being more permissive with the message doesn't fix it because it's sadly apparently actually broken.

phenaproxima’s picture

Assigned: Unassigned » phenaproxima

Self-assigning to smash this bug.

benjifisher’s picture

The attached patch is obviously not the right fix, but it points out where the problem really is.

Short version: the test is broken.

We had a rollicking discussion about this in Slack today, mostly during the weekly migration meeting. I will summarize the discussion later. (Time for supper.)

Also, does someone want to open a separate issue for this? There are two very different problems here: the changed exception message because of the Guzzle update and then the broken attempt to cast a stream to a string so that we can log it.

benjifisher’s picture

Assigned: phenaproxima » Unassigned
benjifisher’s picture

xjm credited mikelutz.

xjm’s picture

Status: Needs work » Needs review
FileSize
0 bytes

Extracting fix from diff. :) And crediting folks.

Edit: And crossposting apparently.

xjm’s picture

xjm’s picture

FWIW I'm okay with including these two changes in the same issue scope.

mikelutz’s picture

Status: Needs review » Needs work
+++ b/core/tests/Drupal/Tests/BrowserHtmlDebugTrait.php
@@ -183,7 +184,12 @@ protected function getResponseLogHandler() {
               // tests should not fail if there is a problem. On PHP 7.4
               // \Drupal\Tests\migrate\Functional\process\DownloadFunctionalTest
               // fails without the usage of a silence operator.
-              $body = @(string) $response->getBody();

Hate to send a critical back for a nit, but this comment is now nonsensical
- edit Maybe not nonsensical, since the silence is still there a few lines down, but I also question if the silence is still necessary if we are no longer casting a stream.

xjm’s picture

Re: #35 is that in scope though? It's still technically true on 9.x, and out of scope on 10.x.

mikelutz’s picture

Status: Needs work » Needs review

Leaving at NR, the comment could be updated, but It's not as off as I first thought, and I really don't know whether the silence is still needed or not.

mikelutz’s picture

Status: Needs review » Reviewed & tested by the community

Yeah, I'll agree with out of scope for this critical. RTBC pending tests.

mikelutz’s picture

Looks like 10.0 needs a re-roll

xjm’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
2.97 KB

10.0.x patch, and a stab at making the comments more accurate. Turns out the bit about PHP 7.4 was already removed. No interdiff 'cause git apply --reject.

Edit: And more xposts.

benjifisher’s picture

Thanks to @quietone for #15 (and to @xjm for bringing that comment to my attention):

Why does guzzle think it needs to read the destination?

Good question!

I hacked the exception message in Guzzle, and that confirmed what @quietone said: Guzzle was calling getContents() on the destination stream. Then I hacked the download process plugin to add a stack trace to its exception message:

#0 /var/www/html/vendor/guzzlehttp/psr7/src/Stream.php(83): GuzzleHttp\Psr7\Stream->getContents()
#1 /var/www/html/core/tests/Drupal/Tests/BrowserHtmlDebugTrait.php(186): GuzzleHttp\Psr7\Stream->__toString()
#2 /var/www/html/vendor/guzzlehttp/promises/src/FulfilledPromise.php(41): Drupal\Tests\BrowserTestBase->Drupal\Tests\{closure}(Object(GuzzleHttp\Psr7\Response))

So it seems that BrowserHtmlDebugTrait::getResponseLogHandler() is trying to cast a Stream object to a string:

              // Get the response body as a string. Any errors are silenced as
              // tests should not fail if there is a problem. On PHP 7.4
              // \Drupal\Tests\migrate\Functional\process\DownloadFunctionalTest
              // fails without the usage of a silence operator.
              $body = @(string) $response->getBody();

The comment, and the cast, was added in #3086374-79: Make Drupal 8 & 9 compatible with PHP 7.4. I am adding that as a related issue.

benjifisher’s picture

While you were busy cross-posting, I was checking the return type of getBody(). Like @mikelutz, I think the comment should be removed.

benjifisher’s picture

benjifisher’s picture

Maybe we should raise an upstream issue with Guzzle. According to the API docs for Psr\Http\Message\StreamInterface::__toString,

This method MUST NOT raise an exception in order to conform with PHP's string casting operations.

xjm’s picture

Much nicer, thanks @benjifisher. D10 version of above.

lol xposts. I think an upstream issue is good too; we could add an @todo I suppose linking an upstream PSR. I would still commit this today though because HEAD is busted on the secure version of Guzzle.

mikelutz’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC pending tests.

benjifisher’s picture

Issue summary: View changes

Now that we understand the problem better, let's update the IS.

  • xjm committed cdafba4 on 10.0.x
    Issue #3285230 by xjm, benjifisher, phenaproxima, mikelutz, quietone:...

  • xjm committed c668a90 on 9.5.x
    Issue #3285230 by xjm, benjifisher, phenaproxima, mikelutz, quietone:...

  • xjm committed 48bdcdc on 9.4.x
    Issue #3285230 by xjm, benjifisher, phenaproxima, mikelutz, quietone:...
xjm’s picture

Status: Reviewed & tested by the community » Fixed

Phew!

xjm’s picture

Issue tags: +Needs followup

Tagging for follow up in case we want to file an upstream issue with guzzle.

benjifisher’s picture

Issue summary: View changes

I added #3292980: Testing system should explain why Guzzle responses can be unreadable as a follow-up issue, so I am updating the "Remaining tasks" in the issue summary. I also copied the other task to the new issue.

benjifisher’s picture

Oops:

              /* $var \Psr\Http\Message\StreamInterface $stream */

should have been

              /** @var \Psr\Http\Message\StreamInterface $stream */

Should we add a commit to this issue or make a follow-up issue?

mikelutz’s picture

Make a novice issue, let somebody get credit for it. If it survives the night I'll have let someone on my team get their first core credit tomorrow.

mikelutz’s picture

I opened #3293114: Fix var tag on recent commit, and am going to try to walk someone on my team through their first contribution this morning.

mikelutz’s picture

Issue tags: -Needs followup

Closing a few loops on this issue.

Regarding

Why does guzzle think it needs to read the destination?

I posted a fairly detailed explaination here, but the short of it is that guzzle by default takes the download stream, dumps it into a sink stream and returns that stream for $response->getBody(). There is a guzzle option ($options['stream'] = TRUE) that would return the download stream instead, but it's rarely used, and certainly not by us here. Guzzle uses a php temp stream opened 'r+' (read/write) by default, and we overrode it to a write- only file stream, so it's not doing anything unusual, we just overrode the typically readable stream with a write-only stream.

Additionally:
We discussed a follow up to report the psr 7 violation of Stream::__toString throwing an exception in violation of psr7 StreamInterface. I dug in through the history, and the short version is they had a discussion and intentionally chose to throw the exception anyway.

The slightly longer version is that psr-7 was accepted in 2015, back in the php 5.6 days. Per the php docs for __toString()

Warning
It was not possible to throw an exception from within a __toString() method prior to PHP 7.4.0. Doing so will result in a fatal error.

Guzzle chose to interpret

This method MUST NOT raise an exception in order to conform with PHP's string casting operations.

.
to mean that they weren't permitted to throw an exception prior to php 7.4 due to php throwing a fatal error, but after 7.4 they were permitted to throw an exception. There was discussion of attempting to file an errata with PHP-FIG to change psr7, not sure if they did, but they didn't have much hope of getting it updated. Hence why the code in __toString catches exceptions and only rethrows them in php 7.4.

On top of that discussion which was a couple years ago, and only threw the exception on a detached stream, https://github.com/guzzle/psr7/pull/515/files mentioned above expanded the situations where an exception is thrown, including unreadable streams.

Further reading:
https://github.com/guzzle/psr7/issues/271
https://github.com/Nyholm/psr7/issues/142

We've also opened #3293114: Fix var tag on recent commit (committed) to fix the @var typo, and #3292980: Testing system should explain why Guzzle responses can be unreadable(RTBC) for further discussion and to update some documentation

Since it seems pointless to follow up upstream on the PSR violations. and the other internal follow-ups have been filed, I'm removing the Needs Followup tag.

Status: Fixed » Closed (fixed)

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