Skip to content

fix: Guard against double-dispose of TextBoxComponent cached image#3909

Open
Barba2k2 wants to merge 2 commits intoflame-engine:mainfrom
Barba2k2:fix/text-box-component-dispose
Open

fix: Guard against double-dispose of TextBoxComponent cached image#3909
Barba2k2 wants to merge 2 commits intoflame-engine:mainfrom
Barba2k2:fix/text-box-component-dispose

Conversation

@Barba2k2
Copy link
Copy Markdown

@Barba2k2 Barba2k2 commented May 2, 2026

Description

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 used to call Image.dispose() directly, gated only by isMounted. Two problems:

  1. If the underlying native peer has already been collected (e.g. low-memory scenarios where the engine drops the image), Image.dispose() throws and crashes the app — issue TextBoxComponent: A Dart object attempted to access a native peer... #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 with no alternative cleanup path.

This PR replaces the gate with a best-effort _safeDispose helper that catches StateError (release-mode: native peer already collected) and AssertionError (debug-mode: already disposed by a parallel path), and uses the same helper from onRemove so removal at any point is crash-safe.

A regression test was added in text_box_component_test.dart that disposes the cached image manually before removing the component, which without the fix throws and crashes the test runner.

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • [-] I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • [-] I have updated/added relevant examples in examples or docs.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

Fixes #3724

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 flame-engine#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 flame-engine#3724
Comment thread packages/flame/lib/src/components/text_box_component.dart Outdated
Comment thread packages/flame/lib/src/components/text_box_component.dart
Address review feedback on flame-engine#3909: use explicit `on StateError` /
`on AssertionError` clauses in place of the bare catch.

- StateError: production crash from issue flame-engine#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.
@Barba2k2
Copy link
Copy Markdown
Author

Barba2k2 commented May 2, 2026

Pushed f69a420 addressing both review comments:

do we know the exact exception that would happen here, and could we catch that specifically?

Yes — there are two flavours and I've split the bare catch into explicit on clauses for each, with a comment explaining when each path fires:

  • on StateError — the production crash from TextBoxComponent: A Dart object attempted to access a native peer... #3724. When Image.dispose accesses the native peer after the engine has dropped its SkImage under memory pressure, Flutter throws "Bad state: A Dart object attempted to access a native peer, but the native peer has been collected (nullptr)". This is what Crashlytics in the issue is reporting (86 events / 39 users / 24h).
  • on AssertionError — debug-mode only. Image.dispose opens with assert(!_disposed), which trips on a redundant dispose. The regression test runs in debug, so without this clause it would fail; release-mode users never see this path because the assert is stripped.

this can be for a future thought, but I wonder if we could just register it to be disposed next tick instead of a random future

Agreed that "next tick" is a cleaner mental model than the magic 100 ms — would let the GPU pipeline complete its current frame before we free the image. I left the existing scheduling alone in this PR to keep the change minimal and focused on the crash, but happy to follow up with a separate PR that swaps the Future.delayed for a WidgetsBinding.instance.addPostFrameCallback (or queues the dispose via the game's own update loop) — let me know if you'd prefer me to fold that in here instead.

@spydon
Copy link
Copy Markdown
Member

spydon commented May 3, 2026

The PR description needs to follow the PR template.

@Barba2k2
Copy link
Copy Markdown
Author

Barba2k2 commented May 3, 2026

@luanpotter both review threads addressed and resolved — StateError / AssertionError split landed in f69a420, and the Future.delayed → next-tick rework is noted as a follow-up. Also reformatted the description to follow the PR template per @spydon's note across the other PRs.

@Barba2k2 Barba2k2 requested a review from luanpotter May 3, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextBoxComponent: A Dart object attempted to access a native peer...

3 participants