From 70349ba136168dc01f672fa0428650ed23a13f74 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 11:57:51 -0300 Subject: [PATCH 1/2] fix: Guard against double-dispose of TextBoxComponent cached image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextBoxComponent.redraw() schedules the previous cached image for disposal after a 100 ms delay (to let pending rendering pipeline frames finish using it). The delayed callback unconditionally called Image.dispose() with only an isMounted gate. Two problems: 1. If the underlying native peer was already collected (e.g. low memory or the engine dropped the image), Image.dispose() throws and crashes the app — issue #3724 reports this surfacing in production via Crashlytics, with 86 events / 39 users in 24 h. 2. The isMounted gate leaked the cached image whenever the component was removed within the 100 ms window, since dispose was simply skipped without an alternative cleanup path. Replaces the gate with a best-effort _safeDispose helper that swallows errors raised by an already-collected peer, and uses the same helper in onRemove so removal at any point is safe. Adds a regression test that disposes the cached image manually before removing the component, reproducing the original crash without the fix. Fixes #3724 --- .../src/components/text_box_component.dart | 26 ++++++++++++++++--- .../components/text_box_component_test.dart | 25 ++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index 2e1f09264e2..f81b4aaa28e 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -416,15 +416,30 @@ class TextBoxComponent extends TextComponent { // See issue #1618 for details. Future.delayed(const Duration(milliseconds: 100), () { cachedToRemove.remove(cachedImage); - if (isMounted) { - cachedImage.dispose(); - } + _safeDispose(cachedImage); }); } cache = await _fullRenderAsImage(newSize); size = newSize; } + /// Dispose of [image] best-effort, swallowing errors raised when the + /// native peer has already been collected. + /// + /// `Image.dispose` throws a `StateError` if the underlying native peer was + /// finalized while the Dart wrapper was still alive (e.g. in low-memory + /// scenarios where the engine drops cached image data). Since the image + /// resource is already released in that case, the throw provides no value + /// to callers and crashes the app — see issue #3724. + static void _safeDispose(Image image) { + try { + image.dispose(); + // ignore: avoid_catches_without_on_clauses + } catch (_) { + // Native peer already collected; nothing left to free. + } + } + @override void update(double dt) { _lifeTime += dt; @@ -448,7 +463,10 @@ class TextBoxComponent extends TextComponent { @mustCallSuper void onRemove() { super.onRemove(); - cache?.dispose(); + final cachedImage = cache; + if (cachedImage != null) { + _safeDispose(cachedImage); + } cache = null; } diff --git a/packages/flame/test/components/text_box_component_test.dart b/packages/flame/test/components/text_box_component_test.dart index f8b710619b5..b4de6881e2b 100644 --- a/packages/flame/test/components/text_box_component_test.dart +++ b/packages/flame/test/components/text_box_component_test.dart @@ -234,6 +234,31 @@ void main() { }, ); + testWithFlameGame( + 'does not throw if the cached image was already disposed ' + '(regression #3724)', + (game) async { + final c = TextBoxComponent(text: 'foo bar'); + + await game.ensureAdd(c); + final imageCache = c.cache; + expect(imageCache, isNotNull); + + // Simulate the native peer being collected before our delayed + // dispose / onRemove runs by disposing the cached image first. + imageCache!.dispose(); + expect(imageCache.debugDisposed, isTrue); + + // Removing the component must not crash even though dispose() on the + // already-disposed image would normally throw a StateError. + expect(() { + game.remove(c); + game.update(0); + }, returnsNormally); + expect(c.cache, null); + }, + ); + testWithFlameGame( 'internal image is redrawn when component is re-added', (game) async { From f69a42024e46c7eaa80f5dbf6e1675b9c53f708c Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:59:27 -0300 Subject: [PATCH 2/2] fix: Catch specific exception types in TextBoxComponent _safeDispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on #3909: use explicit `on StateError` / `on AssertionError` clauses in place of the bare catch. - StateError: production crash from issue #3724 — Flutter's Image throws "Bad state: native peer has been collected" when dispose accesses an engine-released SkImage. - AssertionError: debug-mode `assert(!_disposed)` guard fires when the image is disposed twice; test environments run in debug, so catching this keeps the safety net consistent across modes. --- .../src/components/text_box_component.dart | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index f81b4aaa28e..0450f48df49 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -423,20 +423,26 @@ class TextBoxComponent extends TextComponent { size = newSize; } - /// Dispose of [image] best-effort, swallowing errors raised when the - /// native peer has already been collected. + /// Dispose of [image] best-effort, swallowing the two errors that mean + /// "the resource is already gone, there is nothing left to free". /// - /// `Image.dispose` throws a `StateError` if the underlying native peer was - /// finalized while the Dart wrapper was still alive (e.g. in low-memory - /// scenarios where the engine drops cached image data). Since the image - /// resource is already released in that case, the throw provides no value - /// to callers and crashes the app — see issue #3724. + /// In **release** mode the production crash from issue #3724 is a + /// `StateError` ("Bad state: A Dart object attempted to access a native + /// peer, but the native peer has been collected (nullptr)") thrown when + /// `Image.dispose` accesses the native peer after the engine has dropped + /// it under memory pressure. + /// + /// In **debug** mode `Image.dispose` is guarded by `assert(!_disposed)`, + /// so a redundant dispose throws an `AssertionError`. Test environments + /// are debug-mode, hence we also catch that flavour to keep the safety + /// net consistent across modes. static void _safeDispose(Image image) { try { image.dispose(); - // ignore: avoid_catches_without_on_clauses - } catch (_) { - // Native peer already collected; nothing left to free. + } on StateError { + // Release-mode: native peer already collected; nothing left to free. + } on AssertionError { + // Debug-mode: image already disposed by a parallel path. } }