Repository navigation
Fix null _nextFrame in MultiFrameImageStreamCompleter._handleAppFrame when a listener is re-added - #193575
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates MultiFrameImageStreamCompleter to handle cases where _nextFrame is null by returning early instead of asserting, which prevents crashes when a listener is re-added while a frame callback is pending. A regression test has been added to verify this behavior. The review feedback points out that calling addTearDown inside the asynchronous ImageStreamListener callback is an anti-pattern that can cause StateErrors, and suggests refactoring it to a single test-level teardown.
| final emittedImages = <ImageInfo>[]; | ||
| final listener = ImageStreamListener((ImageInfo image, bool synchronousCall) { | ||
| emittedImages.add(image); | ||
| addTearDown(image.dispose); | ||
| }); |
There was a problem hiding this comment.
Calling addTearDown inside an asynchronous callback (like the ImageStreamListener) is an anti-pattern. If the callback is triggered after the test has started tearing down, it will throw a StateError. Instead, register a single addTearDown at the test level to dispose of all collected images.
| final emittedImages = <ImageInfo>[]; | |
| final listener = ImageStreamListener((ImageInfo image, bool synchronousCall) { | |
| emittedImages.add(image); | |
| addTearDown(image.dispose); | |
| }); | |
| final emittedImages = <ImageInfo>[]; | |
| addTearDown(() { | |
| for (final ImageInfo image in emittedImages) { | |
| image.dispose(); | |
| } | |
| }); | |
| final listener = ImageStreamListener((ImageInfo image, bool synchronousCall) { | |
| emittedImages.add(image); | |
| }); |
There was a problem hiding this comment.
Done: moved image disposal to a single test-level addTearDown. (The listener only fires during pump() within the test body, so this wasn't reachable in practice, but it's cleaner.)
| imageStream.removeListener(listener); | ||
| imageStream.addListener(listener); |
There was a problem hiding this comment.
Confirmed this will indeed trigger the relevant code 👍
| // A listener can be re-added after this callback was scheduled (for | ||
| // example when TickerMode toggles during a route transition). That | ||
| // restarts decoding in addListener, which discards _nextFrame. The | ||
| // in-flight decode schedules a new callback when it completes. | ||
| if (_nextFrame == null) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Thanks for the explanation, sounds right to me.
|
looks like there is some foramt error @Jstuff36 |
… when a listener is re-added (flutter#193575) **Title:** Fix null `_nextFrame` in `MultiFrameImageStreamCompleter._handleAppFrame` when a listener is re-added --- `MultiFrameImageStreamCompleter._handleAppFrame` can run after its decoded frame has been thrown away. When that happens it dereferences a null `_nextFrame`. In debug builds this fails the `_nextFrame != null` assertion; in release builds it throws `Null check operator used on a null value`. ### How it happens 1. A frame is decoded and `_handleAppFrame` is scheduled as a frame callback. 2. Before that frame runs, the stream drops to zero listeners and a listener is added back. `_ImageState` does this whenever `TickerMode` changes, for example during a route push or pop. Something must keep the completer alive while it has no listeners, as `ImageCache` does. 3. `removeListener` cancels only `_timer`. It does not cancel the pending frame callback. 4. `addListener` calls `_decodeNextFrameAndSchedule()`. That synchronously disposes `_nextFrame` and sets it to null before it awaits the next frame. 5. The pending `_handleAppFrame` then runs with listeners present and `_nextFrame == null`. flutter#30346 fixed flutter#30340 by stopping `_handleAppFrame` from being registered twice (`_frameCallbackScheduled`). It did not handle the callback that is already pending when decoding restarts; that PR's test for this scenario was merged disabled. The same failure was reported again in flutter#122576. ### Fix `_handleAppFrame` now returns early when `_nextFrame` is null. The restarted decode sets `_nextFrame` and calls `_scheduleAppFrame()` when it completes. `_frameCallbackScheduled` has already been reset at that point, so the animation resumes on the next frame. The only visible effect is one skipped animation frame instead of an exception. This follows the same pattern as the `_codec == null` check added in flutter#165009. We see this crash in production in several apps that treat framework errors as fatal. The trigger is animated images that are on screen during route transitions. ### Tests Adds `re-adding a listener while a frame callback is pending does not crash` to `image_stream_test.dart`. It reproduces the race without depending on timing: - Without the fix it fails with `Failed assertion: line 1064 pos 12: '_nextFrame != null'`. - With the fix it passes, and the animation resumes once the restarted decode completes. - All other tests in `image_stream_test.dart` still pass. Fixes flutter#30340 Related: flutter#122576, flutter#30346 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. <!-- Keep the checklist link references from the upstream PR template. -->
Title: Fix null
_nextFrameinMultiFrameImageStreamCompleter._handleAppFramewhen a listener is re-addedMultiFrameImageStreamCompleter._handleAppFramecan run after its decodedframe has been thrown away. When that happens it dereferences a null
_nextFrame. In debug builds this fails the_nextFrame != nullassertion; inrelease builds it throws
Null check operator used on a null value.How it happens
_handleAppFrameis scheduled as a frame callback.added back.
_ImageStatedoes this wheneverTickerModechanges, forexample during a route push or pop. Something must keep the completer alive
while it has no listeners, as
ImageCachedoes.removeListenercancels only_timer. It does not cancel the pending framecallback.
addListenercalls_decodeNextFrameAndSchedule(). That synchronouslydisposes
_nextFrameand sets it to null before it awaits the next frame._handleAppFramethen runs with listeners present and_nextFrame == null.#30346 fixed #30340 by stopping
_handleAppFramefrom being registered twice(
_frameCallbackScheduled). It did not handle the callback that is alreadypending when decoding restarts; that PR's test for this scenario was merged
disabled. The same failure was reported again in #122576.
Fix
_handleAppFramenow returns early when_nextFrameis null. The restarteddecode sets
_nextFrameand calls_scheduleAppFrame()when it completes._frameCallbackScheduledhas already been reset at that point, so the animationresumes on the next frame. The only visible effect is one skipped animation
frame instead of an exception. This follows the same pattern as the
_codec == nullcheck added in #165009.We see this crash in production in several apps that treat framework errors as
fatal. The trigger is animated images that are on screen during route
transitions.
Tests
Adds
re-adding a listener while a frame callback is pending does not crashto
image_stream_test.dart. It reproduces the race without depending ontiming:
Failed assertion: line 1064 pos 12: '_nextFrame != null'.completes.
image_stream_test.dartstill pass.Fixes #30340
Related: #122576, #30346
Pre-launch Checklist
///).