Problem/Motivation
GD toolkit and operations fail with WSOD in case of errors or exceptions.
Proposed resolution
Catch the \Throwable error and log its description in the image logger, fail gracefully.
Remaining tasks
User interface changes
None.
API changes
None.
Data model changes
None
Original issue
Executing something like this
$test = \Drupal::service('image.factory')->get(NULL, 'gd');
$test->createNew(20000, 20000);
leads to a fatal error
PHP Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 80000 bytes) in /[...]/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/CreateNew.php on line 94
and WSOD.
This is because GD imagexxx functions do not return gracefully in case of memory allocation failures.
Proposed resolution (no longer valid, kept for history reasons)
Take cues from the preliminary memory availability check done in the color module, and from this comment on PHP site, and introduce a memory checking method that can be called on load, create_new, and rotate operations before invoking imagexxx functions.
| Comment | File | Size | Author |
|---|
Issue fork drupal-2583041
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:
- 2583041-catch-throwable
changes, plain diff MR !2860
- 2583041-gd-toolkit-
changes, plain diff MR !31
Comments
Comment #2
mondrakeComment #3
mondrakeComment #4
mondrakeComment #5
fietserwinI think that returning a catchable exception is preferable over a fatal error. So this issue is worth implementing.
Below first some remarks about the patch as is, after that some remarks about some design questions.
This exception type should be docuemnted with a @throws in core\lib\Drupal\Core\ImageToolkit\ImageToolkitOperationInterface.php::apply().
$path is unknown, did you want to use $this->getSource()?
When there is insufficient memory, this method will return FALSE, not throw an exception
- memoryRequired is not a defined property, whereas requiredMeory is ...
- $size is not used: remove
- $memory_limit does the same as Environment::checkMemoryLimit() already does for you: remove and do not pass
format_size() can do a translation while exception messages should not be translated. In this case doing unformatted output of the number might be better.
typo: length
You could use floor() instead of adding 1 yourself.
Shouldn't checking the return value (FALSE) be part of the test?
These remarks are more at the design/architectural level:
Comment #6
mondrakeHere, adding some PHP Unit tests to simulate lack of memory and ensure the exception is properly thrown in such case.
#5 not addressed yet, coming next.
Comment #7
mondrakeComment #8
mondrake#5 thanks for review @fietserwin.
Part 1 -
1) done
2) yes, remainder from earlier work, now changed
3) returns an int now, if positive means not enough memory to accommodate the required memory
4) completely changed, see below,
5) good catch. Now we do not have a method that can do that w/o involving transation. Added a Bytes::toString method + tests
6) OK
7) see todo, will have to be removed eventually, did not touch
8) yes, done
Part 2-
1) added a Environment::checkAvailableMemory method + tests
2) done, removed the property and now GDToolkit::checkAvailableMemory returns -1 in case of success, the required memory in bytes if fail.
Comment #10
mondrakeFixes for unit tests.
Comment #11
fietserwinThanks for processing the remarks from my st review. This version is almost there, mostly minor remarks remain.
Document that this one is untranslated (and refer to format_size() for a translating version of this feature).
This can be simplified to something like:
Advantages:
- less cases (= less clutter).
- a value in the YB range will also be rounded to 2 digits.
(However: if this is a mere copy of format_size(), you can leave it as is).
self::KILOBYE (or even static::KILOBYTE) like in the method above? (4 usages).
2 lines with 81 characters.
To me this is a wrapper around checkMemoryLimit() for a special case: check if there is enough memory available within the current process space. So remove the optional parameters: if you need one, use checkMemoryLimit() directly.
If the parameters are removed, you can test this by using ini_get('memory_limit') and memory_get_usage(TRUE) and test a value above and below the difference.
Comment #12
mondrake#11
1, 2) Yes it's a copy of
format_size(). However at this stage I find better introduce anotherBytes::toUnitAndSize()method that does the calculation and returns an array [unit, size]. Then change bothformat_size()andBytes::toString()so that they both callBytes::toUnitAndSize()in the first place and return, respectively, a translation object and a plain string that can be used in exception messages.The calculation logic remains the one of
format_size()- the one proposed in #11.2 fails on Drupal\system\Tests\Common\SizeUnitTest.Documented that
Bytes::toString()returns a plain string and added a pointer toformat_size()for translatable objects.3) self:: to be consistent.
4) OK.
5, 6) I need to think more. Initially I was hoping to introduce a
Environment::getMemoryInUse()wrapper tomemory_get_usage(TRUE)and mock it in PHPUnit to simulate memory close to capacity. But unfortunately you cannot mock static methods so I ended up taking cues from the existing methodcheckMemoryLimit()where the optional parameter allows PHPUnit to bypassini_get('memory_limit').Comment #13
mondrakeComment #14
mondrakeComment #15
fietserwin1. The failed tests was because I forgot to round the value that will be returned. This code does pass:
Note that theoretically this code is better as the current code does rounding on rounding on rounding which may lead to false results: add 4053371676 as a test, this is 3,774996545 GB but 3,78 is currently returned...
2. Now that we are touching common.inc: format_size() has the same unnecessary if - else, this can be simplified to:
3. Regarding 11.5/6 and your 12 5/6:
What you are testing in method providerTestCheckAvailableMemory() is not checkAvailableMemory() but checkMemoryLimit(). As checkAvailableMemory() is just a wrapper, the test should check that checkMemoryLimit() does get called with the correct values. If you can't mock this, do a test like:
BTW: Does http://stackoverflow.com/questions/2357001/phpunit-mock-objects-and-stat... give some useful insights on mocking statics?
BTW2: You could: override Environment for testing purposes; change self:: into static:: (:)); and override checkMemoryLimit() to log/return its parameters. This way you can test checkAvailableMemory() in isolation.
4. testToString() has the same error: it actually tests testToUnitAndSize(): an error in testToUnitAndSize() will lead to 2 test failures, 1 of them in testToString(), even while toString() may be correct. But we have the same problem here as well: mocking statics.
EDIT: So I followed the link myself and read a bit about mocking statics. The 3 comments starting at https://github.com/sebastianbergmann/phpunit-documentation/issues/77#iss... explain everything. That last comment is what I suggested above, but we will have to use static (late static binding) instead of self ...
Comment #16
mondrakeSo, #15.1 and #15.2 are in. Added @fietserwin to commit credit message.
#15.3 - my understanding is that with PHP Unit 4+ there's no way to mock static methods, period. So here I am changing approach and introducing a
EnvironmentMemoryclass that needs to be instantiated. That also allows for proper mocking. With thisEnvironmentis kind of redundant, we may think about deprecating it, but that will have to be a follow up issue. In any case, this issue is for not earlier than 8.1.x I think.Added tests also for the point
and test for formatting a zero bytes quantity.
Also, I removed
Bytes::toStringthat was introduced in the earlier patches, to avoid possible misuse vs. translatable strings; ::toUnitAndSize gives what's needed anyway.Comment #17
fietserwinYes, we are almost there. 1 medium and the rest minor.
I did some digging into where this is used and found out that if $langcode is null, we end up doing this in core\lib\Drupal\Core\StringTranslation\Translator\StaticTranslation.php, getStringTranslation(), line 40:
$this->translations[NULL] = $this->getLanguage(NULL);So only assign if not null, otherwise make it an empty array.
Minor: Not sure here if any coding standards apply who tell the same, but personally I prefer === (over ==) whenever possible. (4 times in this file.)
Minor: I don't like too many brackets, certainly not if they are surrounding the whole expression, as they don't add any clarity in that case. (Several times in this file.)
Minor: I think it would be more intuitive to return TRUE or an array.
Minor: revers these 2 lines, you are allocating memory after querying the amount of free memory, but before using it.
Comment #18
mondrakeAdded deprecation for Environment and EnvironmentTest.
#17:
1. Is this a bug? If so, that's in HEAD right now, the patches here do not touch that bit. That's not in scope of this issue, please file a separate one. (That may have a different priority/timeline than this, which is definitely heading towards 8.1.x or later)
2. OK, but needs to cast to int as $size can be a string in that context (either passed in or returned from ini_get).
3. OK
4. Yes, I do not like it either. Now
GDToolkit::checkAvailableMemoryreturns a bool, but we need a separate property and getter to be able to get more information about the memory available/total/required.5. OK
Comment #19
mondrakeCouple of glitches.
Comment #20
fietserwinRE#18.1: Yes this is a "bug", but one that does not show as apparently PHP does not care too much when trying to set index NULL in an array. However it is the code in format_size() that sets the 'langcode" option to NULL. So, as we do touch that code, we can solve it now.
(AFAICS in Drupal the pattern is: $langcode as parameter may be null or absent, 'langcode' in $options parameter may be absent but not NULL.)
RE#18.2: I hadn't thought of -1 coming from ini, in which case a check for == would have been acceptable.
RE#18.4: I would have settled for returning array|true instead of array|null but this is even more strict.
I leave it up to you to change #17.1 here or in another issue. If the latter this one is RTBC.
As the property is typed as array[], we can type this @return as array[] as well.
And while we are at it now anyway: in theory I guess we should floor ($bits_per_pixel / 8). GD uses 7 bits for transparency, giving 31 bits per pixels, but no way that GD does not use 4 bytes per pixel.
Comment #21
mondrakeUpdated issue summary.
#17.1 and following - separate issue please, I prefer not to touch here.
Done here.
I am sorry I do not understand.
----------
I think we can leave this in NR for the time being - now the focus is on the release and this one will definitely not be there.
Comment #22
fietserwin#17.1: I don't understand, a very simple local change.
RE last point#20/#21:
GD uses 31 bits per pixel (8 + 8 + 8 +7), but it will store these 31 bits in 4 bytes and not use the 32nd bit. So, if I would pass 31 as 3rd parameter, the calculation should still use 4 not 31/8.
Actually, you want to know the number of bytes used to store 1 pixel internally, not how many bits it uses for the channels.
Comment #23
mondrake#22
re. #17.1 and following - I simply do not feel comfortable to do such a change here. That would need a test to show the failure, which I am not able to do. I think it would be better to do that in isolation from the changes in this issue, and get a separate review. It's not about the change you propose in itself, rather about the fact that it's something that is buggy in HEAD now regardless of this issue.
re. #20/#21 - I think I got the point now, made changes accordingly here
Comment #24
fietserwinPostponed on #2599302: Let translation system accept NULL and empty sting as values for langcode key in $options array parameter.
Comment #25
mondrakeimho #2599302: Let translation system accept NULL and empty sting as values for langcode key in $options array parameter is independent.
Comment #26
mondrake#1551686: Image rotate dimensions callback can calculate new dimensions for every angle has been committed. We can use the Rectangle class now to pre-determine h/w of the rotated image.
Comment #27
claudiu.cristea@mondrake, nice work, really!
I would say this must go in 8.0.x because of some good reasons but it seems that Rectangle() is only in 8.1.x, right? Anyway, some points. Some of them maybe need comments.
I would create Byte::formatSize() to deprecate format_size(). That can be wrapped in format_size(). Just an idea, opinions?
Not so sure what is better to use here:
\Drupal::translation()->formatPlural()ornew PluralTranslatableMarkup(). I see that\Drupal::translation()->formatPlural()doesn't wrap a service and calls the 2nd. I would go directly withnew PluralTranslatableMarkup(). But it's up on you.I think in such cases we return stdClass. I don't have a strong opinion, but it seems to me more intuitive and DX.
I know that it's ugly but "Use" should go one line above.
Let's create a constant for -1. The code will be more readable and that can be used also outside to to test.
It seems that we can pass also NULL? Then null should be added.
"SI or IEC" can se add some @see links to the standards?
Maybe it's good to explain in docblock what keys holds this array (required, free, total).
Why not merging checkAvailableMemory() and getMemoryInfo(). The we can drop also the protected method. We return an empty array if ok (and that cast to FALSE) and the memory info array (cast to TRUE) when error. Then we need to reverse the semantics/naming of checkAvailableMemory()
Comment #28
mondrake#27 thanks for review @claudiu.cristea.
Looking into it.
Comment #29
mondrakeRe #27,
1. It's a nice idea but not for the Bytes class though - that's a 'component' and we should not call Drupal 'core' features from there. Since we need translation we cannot do that there. Maybe a separate class but I'd leave that to a follow up.
2. OK, gone for new PluralTranslatableMarkup.
3. I do not like stdClass - how about a namespaced class with just two public properties unit, size? Not changed yet.
4. Done
5. OK, added EnvironmentMemory::UNLIMITED
6. Done
7. Added @see
8. OK
9. I need to check because I think I did like that to allow unit testing.
So, points 3 and 9 still open.
Comment #31
mondrakeWhoops
Comment #32
mondrakeAddresses #27.9
Merged the two methods in one 'memoryCheck', and dropped the protected property.
In fact this is much better, thanks.
#27.3 still open, opinions?
Comment #33
claudiu.cristeaForgot a -1 :)
if (!$memory) {...} ?
Hm. We need something reversed semantically. Something that is TRUE when there's no enough memory. memoryExhausted(), memoryLimited()... I'm out of ideas.
Comment #34
mondrake#33
1. That was on purpose, in my mind. We use the constant to track UNLIMITED within the class, but the result of
ini_get()will still be -1 even if we change the value of the constant. Added a comment.2, 3. Done
Comment #35
fietserwinPlease undo #33.3. IMO, this reduces readability. Method names should preferably be named positive, even if the result will always be checked with a ! in front of it. If you want to keep it this way, then I think, though I'm not native english, that unavailable is "uncommon", (i.e. not common), so then prefer not(Enough)Available. If you reverse then you could rename the less specific "memoryCheck" to a more explicit "isMemoryAvailable", that is IMO clearer.
Comment #36
claudiu.cristea#27.3: Let's see how it looks with a dedicated class but that would need 2 setters (with chaining) and 2 getters, not public methods. It seems to me a little bit over-engeeniring.
#27.9: So, we agree with a single method? It's more simple and clear. The only thing left is the naming. This is tricky. When reveals a positive state (has memory) it returns nothing (empty array is casting to FALSE). When reveals a negative state (not enough memory) it returns something (a non-empty array that is casting to TRUE). What about memoryLimitReached()?
Comment #37
claudiu.cristeaAlternatively we can return mixed. We can return TRUE when there is memory and array when not. Then
Comment #38
mondrakeSo, re. #27.9 and following, before any coding: are we all OK on something like
the method to be called
isMemoryAvailable, returning TRUE if memory is available, and the array with details if notthen it will be
Comment #39
claudiu.cristeaI'm OK with #38.
Comment #40
fietserwinWe could also return a bool and place the additional info in a(n optional) parameter that is passed by reference, as in preg_match()?
Comment #41
fietserwin[REMOVED: same message, double clicked on the save button]
Comment #42
mondrake#40: so something like
I like it. @claudiu.cristea?
Comment #43
claudiu.cristeaWait! Why not throwing the exception directly in isMemoryAvailable() and we're done? :)
Comment #44
claudiu.cristeaI'm marking this to provide more review tomorrow, when I'm attending Drupal Global SprintWeekend Luxembourg.
Comment #45
fietserwinisMemoryAvailable() is a helper function and should (IMO) therefore not log on its own, but leave that to the code that knows the context it's running in. E.g: I might be doing a batch conversion and only present/log overall results at the end, not 1 error message per failure. isMemoryAvailable() does not know this, but that calling code would.
Comment #46
claudiu.cristeaComment #47
mondrakeThis implements #38. Both #40/42 and #43 would break PHP unit tests AFAICS.
Comment #49
mondrakeFixes.
Comment #50
mondrakeAnd this would be my idea for #27.3
Comment #52
mondrakeFixes for PHP unit tests.
Comment #54
mondrakeRerolled.
Comment #56
mondrakeFix for the kernel test.Comment #57
mondrakePlease disregard #56.
Fix for the kernel test.
Comment #59
mondrakeComment #61
mondrakeI've split out part of the patch here into #2157945: Deprecate format_size() and use Drupal\Core\StringTranslation\ByteSizeMarkup instead. If that's committed then the patch here could be smaller and easier to review.
Comment #63
xem8vfdh commented+1, ran into this too.
Comment #64
mondrakeAdding related issues
Comment #65
mondrakePatch no longer applies.
Comment #66
mondrakeRe-rolled, on top of #2157945: Deprecate format_size() and use Drupal\Core\StringTranslation\ByteSizeMarkup instead - the do-not-test patch. Full patch against 8.4.x.
I would suggest to postpone this one on #2157945: Deprecate format_size() and use Drupal\Core\StringTranslation\ByteSizeMarkup instead.
Comment #70
lokapujyaI just got bit by this on a D7 site. The image wasn't uploaded through Drupal. The image url came from a web service, so there was no way the UI could have prevented the image. Can't catch the error because it's a memory issue, took a long time to figure out the root problem. Happy that I found this issue, but now I need to do something about it.
Comment #71
mondrakeI have spinned off the memory checking parts of the patch in #66 into an issue of its own, #3000050: Replace \Drupal\Component\Utility\Environment::checkMemoryLimit with a PHP memory checking helper class and service.
Comment #72
mondrakeA reroll of #66, with the
format_sizerefactoring expunged - that one is getting closer @ #2157945: Deprecate format_size() and use Drupal\Core\StringTranslation\ByteSizeMarkup insteadComment #73
mondrakeComment #74
mondrakeComment #75
mondrakeComment #76
mondrakeComment #77
mondrakeComment #81
kingdutchAdded postponed by issues to the issue summary since it's not readily apparent from the related issues.
Comment #83
mondrake#3000050: Replace \Drupal\Component\Utility\Environment::checkMemoryLimit with a PHP memory checking helper class and service will not happen.
In 2020 we need to see if this is still relevant as in 2015 - when there was no PHP 7 yet. Maybe now we can catch the memory error as a
\Throwable.Comment #84
mondrakeYes, \Throwable seems to do. No interdiff, new concept.
Comment #86
mondrakeClosed #3077376: ImageToolkitBase::apply should catch \Throwable to manage WSOD-leading events as a dupe.
Comment #87
mondrake#84 though works for kernel tests where, afaics, phunit handles warnings. In real sites the warning will be captured by drupal’s error handler and logged separately, and the memory failure leads to a warning in php 7. The effect is the same, anyway.
Comment #88
mondrakeAdded a test that reduces the memory available before trying an operation.
Comment #90
mondrakeSo re #83 no, atm
cannot be caught as a
\Throwable. Not sure if it'd be possible by tampering with error handlers. If not, the only alternative I see is to go back to #76 and have a method that before trying a GD operation that allocates memory, pre-calculates the memory need and fails if not enough memory is available.Comment #92
mondrakeComment #93
mondrakeComment #94
mondrakeComment #98
mondrakeComment #99
andypostCurious if the method could be packaged to trait
Comment #100
mondrakeRebased and added catching of errors thrown on image save - useful for #3202016: Let GDToolkit support AVIF image format.
Comment #101
mondrakeComment #102
andypostLooks ready for commiters
Comment #103
alexpottI think we need a change record here to tell contrib modules that provide operations about the new functionality.
Comment #104
alexpottNeeds work for #103 and @catch's and my code review.
Comment #105
mondrakeComment #106
mondrakeAdded draft CR https://www.drupal.org/node/3314199
Comment #107
andypostgood idea with limit, hope it now ready for 10.0.x as CR states
Comment #108
mondrakerebased
Comment #109
alexpottI'm really not sure about this change - or if we can make it in the way we're doing here. I've run some tests on memory usage for images on my local PHP 7.4...
How sure are we that the function here is going to always prevent fails and do the right thing. For me this is better handled by max file upload sizes. PHP settings already handle that.
Comment #110
alexpottI think maybe we should consider how we fail in image operations. Catching throwables, logging the error and returning FALSE seems like a great idea but doing memory calculations ourselves seems unnecessarily complex and handling the problem at the wrong point.
Comment #111
catchDiscussed this with @alexpott briefly in slack and after going around in circles a couple of times, I think we should do the throwables bit (i.e. what the issue title says), but not the memory calculation here. We could always move that to a follow-up if it needs more investigation/discussion.
Comment #112
mondrakeOK
Comment #113
mondrakeComment #116
mondrakeClosed MR11 to keep history and opened MR2860 for the new scope without available memory calculation.
Comment #117
smustgrave commentedFor the issue summary update requested in #113
Comment #118
mondrakeUpdated issue summary.
Comment #119
smustgrave commentedMR is up to date.
Tests are all green.
No build errors.
Tests were added for the bug.
Tests fail without the whole fix.
Looks good
Comment #120
alexpottCommitted 23edd01 and pushed to 10.1.x. Thanks!
Only committed to 10.1.x as we're changing where errors are caught so this results in subtle code flow changes. Also I think we can delete the CR as I'm not sure one is necessary and the API talked about in the CR is not there.
Comment #123
mondrakeThanks - I do not think I can delete the CR, even in draft.
Comment #124
alexpottUnfortunately this change has introduced a random fail - see https://www.drupal.org/pift-ci-job/2556244 and https://www.drupal.org/pift-ci-job/2556296 - it seems to happen on postgres which is odd as I have no idea why this change would be DB related.
Comment #126
andypostCurious why memory is limited by 12MB as both failed runs reports
Comment #127
mondrake#126 that is exactly what the test is doing, limiting memory
These failure tell us that catching a Throwable is (or may not always) be enough to prevent a fatal error when memory available is not sufficient for GD operations. Preventing that was the original intent of this issue. I start thinking this could be a problem with PHP builds, but very hard to debug.
I will drop these two tests (the first one is probably irrelevant in DrupalCI, it will be skipped if php.ini is not limiting memory), and add a new one with an operation throwing an exception. Hopefully that would be OK. But then we need a followup to keep trying to find a solution for insufficient memory issues.
Comment #129
mondrakeComment #130
daffie commentedThe testbot is not happy.
Comment #131
mondrakeAgain an out-of-sync between test run and repo update, https://drupal.slack.com/archives/C51GNJG91/p1666607286832989
Comment #132
mondrakeComment #133
mondrake:( it was passing locally...
Comment #134
mondrakeI don't know what I tested locally...but it could not have worked.
Comment #135
smustgrave commentedDoes this still need a followup?
Comment #136
mondrakeFiled #3339075: GD toolkit & operations should fail gracefully in case of memory issues for follow-up.
Comment #137
smustgrave commentedDo you have a recommended way to manually test? Tried lowering my memory and using the snippet in the IS but couldn't trigger.
Comment #138
mondrake@smustgrave I think we need to leave memory testing to the followup. The automated tests cover the base case of any throwable being thrown. Apparently the memory issues are not catchable, or at least not under some PHP configurations. See the commit that was reverted, #124, #127.
Comment #139
smustgrave commentedCan the issue summary be updated please. Since this is not about memory.
Comment #140
mondrake✅
Comment #141
smustgrave commentedThank you!
Tested this out by editing Drupal\system\Plugin\ImageToolkit\Operation\gd\Convert changing line 59 to
if ($this->getToolkit()->apply('creatfasde_new', $data)) {Without the patch when I create a media object I get 2 errors in the logs
Image convert failed using the gd toolkit onandApplied the fix
Created another media object
No errors logged.
Comment #142
mondrakeMmmm that’s not right then. Missing plugin should be logged and fail the effect - we have regression here.
Comment #143
mondrakeI repeated the steps in #141, but just saving an image style containing a convert effect through the configuration UI. Both without and with the patch, after clicking 'save' on the image style configuration form I get the two errors logged, which means to me the missing plugin is properly handled.
Back to RTBC.
Comment #144
alexpottCommitted 4e49b11 and pushed to 10.1.x. Thanks!