-
Notifications
You must be signed in to change notification settings - Fork 3.2k
[camera_android] prevent startImageStream OOM error when main thread hangs (flutter#166533) #8998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…read paused (flutter#166533) When streaming images using CameraController.startImageStream(), images are being post()-ed to the main looper even if it's halted. This is common when debugging. This quickly results in an OOM and the app crashes abruptly. The fix is done by counting the number of images that have been posted to the main thread but have not yet arrived. If too many images are in transit (I arbitrarily chose 3 as the maximum allowable amount) subsequent frames are dropped, until the main thread receives the pending images. A log message is emitted for each dropped frame. Fixes flutter/flutter#166533 That issue also provides a demo program.
Sorry @camsim99 is out of town. Adding team android to give a first pass review. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you this is a tricky problem and clearly impacts developers.
I am a bit worried that we are dropping frames instead of creating a frame queue but I understand that is probably a more difficult pr to author.
My largest worry is that this will cause an issue in non debug code. What do you think about renaming numImagesInTransit
to consecutiveImagesInTranset
and when any frame is delivered setting the value to zero. That would solve the debugger paused execution issue by dropping frames but remove an off by one set of conditions from causing the camera frames to stop.
This will still need @camsim99 to approve before we merge it.
// Handle "buffer is inaccessible" errors that can happen on some devices from | ||
// ImageStreamReaderUtils.yuv420ThreePlanesToNV21() | ||
final Handler handler = | ||
this.handler != null ? this.handler : new Handler(Looper.getMainLooper()); | ||
handler.post( | ||
() -> | ||
imageStreamSink.error( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldnt this also have --numImagesInTransit
otherwise if we had more than 3 images error then we would no longer update any frames.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@reidbaker Thanks for these questions. I'll address the issues one-by-one
-
As opposed to how I initially phrased the issue, this will not only happen when the debugger is paused, but any time the main loop is lagging. I see no benefit to the 'consecutive' method:
- I'm not sure what you mean by an "off-by-one set of conditions." If the main thread is lagging by one frame, it will do so regardless of this fix. Dropping frames will only happen when at least two frames have already been sent to the handler but have not yet been handled. Being less familiar with Flutter/Android workings, I'm not sure if the
EventSink.success()
call can raise an exception. If it can, it would perhaps be wish to wrap the call with atry..finally
. However, I would not move the decrement before thesuccess()
call, as decrement should happen when the image memory can readily be released, otherwise a queue build-up might still occur. - Resetting the number of frames whenever any frame reaches the main thread can have an adverse affect, too. Imagine a scenario where a frame is generated every 10ms, but handled every 20ms on the main thread itself. You will start accumulating an infinite amount of frames pending on
post()
and eventually crash the app with an out-of-memory error. Ensuring thatpost()
arrives and completes is exactly the queue capability you are talking about, as far as I understand.
- I'm not sure what you mean by an "off-by-one set of conditions." If the main thread is lagging by one frame, it will do so regardless of this fix. Dropping frames will only happen when at least two frames have already been sent to the handler but have not yet been handled. Being less familiar with Flutter/Android workings, I'm not sure if the
-
Regarding not updating frames anymore - the only scenario I see that you will no longer update frames in, is when the handler stops handling the
Runnable
s passed topost()
. If that is the case, it means the main thread is paused or hanging, in which case it should be OK to not update the frames until it releases and theRunnable
gets invoked. I see no risk of deadlock, as AFAIK there is no way for the main thread to wait on whatever callsonImageAvailable()
.
How long will it be before @camsim99 comes back?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My two cents:
- @reidbaker can you clarify the off-by-one issue? I'm also a bit confused there.
- Based on my current understanding of things (off-by-one issue aside), I agree with @kwikwag that we should not reset
numImagesInTransit
back to 0 when a frame is delivered. My understanding is that if we do, then we would no longer only be waiting on 3 images to accumulate to start closing images, but instead, would be waiting on 3 then 6 then potentially 9 and so on and so forth. - I agree with @reidbaker that in this case the comment is linked to (the case where an
IllegalStateException
is thrown streaming an image), we should also decrementnumImagesInTransit
because we have essentially "handled" that image.
I can also suggest the following alternative implementations:
I don't see a true benefit to option 1. Option 2 has the benefit of being able to drop older frames at the cost of some added complexity the benefit isn't large enough as once the main thread resumes it will handle new frames anyway. |
Yet another suggestion: keep image data as a soft/weak reference and skip frames where memory is evicted by the runtime. Only keep a hard reference to the most recent frame. Then we don't need this special case that counts the messages pending on the looper. I am not sure whether this will in fact eliminate the out of memory error, but I can check if this is a requirement. |
/** Ensure that passing in an image with padding returns one without padding */ | ||
@Test | ||
public void yuv420ThreePlanesToNV21_trimsPaddingWhenPresent() { | ||
Image mockImage = getImage(160, 120, 16); | ||
Image mockImage = ImageStreamReaderTest.getImage(160, 120, 16, ImageFormat.YUV_420_888); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: seems non-intuitive for test files to call between each other. Can you create an ImageStreamReaderTestUtils.java
or something like that and put it there?
// Handle "buffer is inaccessible" errors that can happen on some devices from | ||
// ImageStreamReaderUtils.yuv420ThreePlanesToNV21() | ||
final Handler handler = | ||
this.handler != null ? this.handler : new Handler(Looper.getMainLooper()); | ||
handler.post( | ||
() -> | ||
imageStreamSink.error( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My two cents:
- @reidbaker can you clarify the off-by-one issue? I'm also a bit confused there.
- Based on my current understanding of things (off-by-one issue aside), I agree with @kwikwag that we should not reset
numImagesInTransit
back to 0 when a frame is delivered. My understanding is that if we do, then we would no longer only be waiting on 3 images to accumulate to start closing images, but instead, would be waiting on 3 then 6 then potentially 9 and so on and so forth. - I agree with @reidbaker that in this case the comment is linked to (the case where an
IllegalStateException
is thrown streaming an image), we should also decrementnumImagesInTransit
because we have essentially "handled" that image.
@camsim99 Thank you for the review and the comments. I added fixes re the two issues mentioned. Note that the naming of the added file Rather than decreasing the Finally, I also added an extra branch with the |
@kwikwag Thanks for the updates! The changes look reasonable to me 👍 Concerning the |
When streaming images using
CameraController.startImageStream()
, images are beingpost()
-ed to the main looper in the Android implementation, even if it's hanging or paused. This will happen, for instance, when the Flutter debugger is paused or when the main thread is very busy. This can quickly result in an OOM (out-of-memory) error due to many images pending in queue, and the Android OS will kill the app abruptly.The fix is done by counting the number of images that have been posted to the main thread but have not yet arrived. If too many images are in transit (I arbitrarily chose 3 as the maximum allowable amount) subsequent frames are dropped, until the main thread receives the pending images. This is a form of back-pressure on the main looper handler.
A log message is emitted for each dropped frame.
Fixes flutter/flutter#166533
That issue also provides a demo program.
A few extra considerations for the reviewer(s):
uSize
(and incidentallyvSize
) that was hard-coded was one byte less than the value calculated by ImageStreamReaderUtilsTest.getImage(). I assumed this was insignificant, but perhaps the original author can comment? I made the method static and placed it in ImageStreamReaderTest. Is this the best place?post()
s to the main looper. Just 1 seemed like a stretch - I assume sometimes the main thread can get caught up and we don't want to drop frames too often, so it's not too noticeable. However, I assume that if the main thread is busy enough as to not be able to process apost()
within a certain interval, the app won't suffer from dropping frames either. But it's a behavior change. Of course, the main benefit here is avoiding a possible OOM crash, which is much worse. A number too high might also take up extra memory, or even trigger an OOM error, with no real benefit.handler
field (that's decorated with@VisibleForTesting(otherwise = VisibleForTesting.NONE)
to indicate that this should not be usable by non-test code; this modifier is not used anywhere else in the codebase but it made complete sense to me; of course I can remove it if need be).Pre-Review Checklist
[shared_preferences]
pubspec.yaml
with an appropriate new version according to the pub versioning philosophy, or I have commented below to indicate which version change exemption this PR falls under1.CHANGELOG.md
to add a description of the change, following repository CHANGELOG style, or I have commented below to indicate which CHANGELOG exemption this PR falls under1.///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2 ↩3