From 57a1cf26f248b61d5a0a805262b1c2fd665616ac Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Sat, 9 May 2026 23:16:28 +0700 Subject: [PATCH 1/3] android: getSwapChains() returns a snapshot, fixes plugin self-destroying its own swapchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real cause of the SIGABRT-on-mount on Android. Not a destroy-vs- endFrame race (the previous flush() patch was wrong). It's a straight-up API bug in FFIFilamentApp.getSwapChains(): Future> getSwapChains() async { return _swapChains; // ← live reference, not a snapshot } In `thermion_flutter_plugin_native.dart` the Android branch of createTextureAndBindToView reads this list, creates a new swap chain, then destroys whatever was in the snapshot: final swapChains = await FilamentApp.instance!.getSwapChains(); final swapChain = await FilamentApp.instance!.createSwapChain(...); if (swapChains.isNotEmpty) { await FilamentApp.instance!.destroySwapChain(swapChains.first); } await FilamentApp.instance!.renderManager.attach(view, swapChain); The intent reads correctly: "snapshot existing chains; create the new one; tear down old chains". But because getSwapChains returned the live list reference rather than a copy, `swapChains` aliased `_swapChains`. createSwapChain appended to _swapChains, so swapChains.first immediately referred to the *new* swap chain we just created. The plugin then destroyed its own new swap chain and attached the view to a freed pointer. The next render hit Filament's `SwapChain must remain valid until endFrame is called` precondition and the process aborted with SIGABRT. Reproduced on every viewer mount on Android — including examples/flutter/quickstart, ARM64 emulator and physical device. iOS / macOS / Windows are unaffected because their plugin paths don't iterate getSwapChains(). Fix: return List.unmodifiable(_swapChains). Defensive at the API layer; the call-site logic in plugin_native.dart is now correct without modification. --- .../src/implementation/ffi_filament_app.dart | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart b/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart index 4c8ace91..9cfb77c9 100644 --- a/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart +++ b/thermion_dart/lib/src/filament/src/implementation/ffi_filament_app.dart @@ -638,7 +638,29 @@ class FFIFilamentApp extends FilamentApp { // Future> getSwapChains() async { - return _swapChains; + // Return a snapshot, not the live list. Returning the live list lets + // mutations from concurrent createSwapChain / destroySwapChain calls + // leak back to callers — which is exactly what happened in + // thermion_flutter's Android `createTextureAndBindToView`: + // + // final swapChains = await FilamentApp.instance!.getSwapChains(); + // final swapChain = await FilamentApp.instance!.createSwapChain(...); + // if (swapChains.isNotEmpty) { + // await FilamentApp.instance!.destroySwapChain(swapChains.first); + // } + // + // The intent was "snapshot existing chains, create the new one, + // tear down the old one", but `swapChains` aliased `_swapChains`, + // so after `createSwapChain` appended, `swapChains.first` was the + // *new* swap chain. The plugin then destroyed it and attached the + // view to a freed pointer — the next render hit Filament's + // "SwapChain must remain valid until endFrame is called" assert + // and the process aborted on every viewer mount on Android. + // + // Returning an unmodifiable snapshot fixes this at the API layer + // and makes the function safe regardless of how callers are + // structured. + return List.unmodifiable(_swapChains); } final _hooks = []; From 4e4aeaf83fa1a6fe7370674f41c343322d3470f5 Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Sat, 9 May 2026 23:32:06 +0700 Subject: [PATCH 2/3] android: per-view swap chain tracking, fix multi-viewer freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android branch of `createTextureAndBindToView` (in `thermion_flutter_plugin_native.dart`) destroys the *previous* swap chain for the size-change case so the renderer doesn't keep an old, wrong-sized surface around. The previous version chose what to destroy by iterating FilamentApp's global swap chain list and picking `swapChains.first`. After the upstream getSwapChains() snapshot fix (#167) the list is correctly snapshot, but in a multi-viewer app `swapChains.first` is whichever viewer's swap chain happens to be earliest in the list — usually a *different* viewer's, not this one's. So mounting viewer #N destroyed viewer #(N-1)'s swap chain. The renderer for #(N-1) then ran with a freed pointer; symptom on screen was middle viewers freezing while only the most recently mounted one (whose swap chain nothing destroyed) kept rendering. Fix: track the swap chain currently bound to each view in a `` map and destroy *that* one, scoped to this view only. Other viewers' swap chains stay live. Order also rearranged: attach the new swap chain *before* destroying the old one, so the view never has a window of being unattached. Other ordering inside the function is unchanged. `_viewSwapChains` mirrors the pattern of the existing `_viewRenderTargets` map a few lines above. No cleanup hook on view-destroy, same as `_viewRenderTargets`'s existing convention; that's a separate lifecycle concern. Reproduces with the spike's stress-test screen at 4 / 8 viewers on Android emulator. Single-viewer is unaffected (map starts empty, no destroy on first mount). iOS / macOS / Windows are unaffected — only the Android plugin branch ever ran the destroy logic. --- .../src/thermion_flutter_plugin_native.dart | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart index f5122c2c..a7dfd8d7 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart @@ -87,6 +87,14 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { // has been redirected to an internal RT (e.g., in composite highlight mode). static final _viewRenderTargets = {}; + // Track the SwapChain currently bound to each view (Android only). + // The Android branch of createTextureAndBindToView destroys this view's + // *previous* swap chain after creating the new one. Keying by view + // (rather than iterating FilamentApp's global swap-chain list) is what + // makes multi-viewer apps work — without this, mounting viewer #N + // would destroy viewer #(N-1)'s swap chain and freeze it. + static final _viewSwapChains = {}; + // Deferred Filament render target cleanup for Windows resize. // Old RT stays alive so native can Blit from it during the swap window. static final _deferredRenderTargets = <(RenderTarget, int)>[]; @@ -582,17 +590,27 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { // the viewport? // In fact we can probably do this for all platforms if (Platform.isAndroid) { - final swapChains = await FilamentApp.instance!.getSwapChains(); + // The OLD swap chain to destroy is the one *previously bound to + // this view* — not whatever happens to be at the front of + // FilamentApp's global list. Earlier code iterated + // `getSwapChains()` and destroyed `swapChains.first`, which in a + // multi-viewer app destroyed *another* viewer's swap chain, freezing + // its viewport. Tracking per-view in `_viewSwapChains` scopes the + // destroy to this view only. + final oldSwapChain = _viewSwapChains[view]; final swapChain = await FilamentApp.instance!.createSwapChain( Pointer.fromAddress(descriptor.windowHandle!), ); - if (swapChains.isNotEmpty) { - await FilamentApp.instance!.destroySwapChain(swapChains.first); - } - await FilamentApp.instance!.renderManager.attach(view, swapChain); + _viewSwapChains[view] = swapChain; + + // Destroy the old swap chain after the new one is attached so the + // view never has a window of being unattached. + if (oldSwapChain != null) { + await FilamentApp.instance!.destroySwapChain(oldSwapChain); + } // On other platforms, if a hardware texture ID is returned, this means // the texture is immediately available for rendering. From 81d8085349dcdbb6f0a97a5459b78c51cffe6a25 Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Sat, 9 May 2026 23:55:19 +0700 Subject: [PATCH 3/3] android: clean up per-view swap chain on widget dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the previous patch (per-view swap chain tracking) the Android plugin correctly created and reused a SwapChain bound to each View. Disposal of the chain when the widget unmounted, however, was not wired up — the SwapChain stayed in `_viewSwapChains` and stayed attached to the RenderManager, while `ThermionWidget.dispose()` released the underlying SurfaceTexture (and thus the native window). Result on the next frame: Filament's render thread iterates the attached swap chains, hits the now-orphaned one, and calls `eglSwapBuffers` on the freed native window. The emulator's EGL backend logs: E/EGL_emulation: egl_window_surface_t::swapBuffers called with NULL buffer E/EGL_emulation: tid : swapBuffers(764): error 0x300d (EGL_BAD_SURFACE) repeatedly until the next frame after the SwapChain is collected elsewhere. On the spike's stress-test screen at 4 / 8 viewers, even brief widget rebuilds (e.g. toggling viewer count) flood the log and cause visible frame drops because every render iteration attempts the bad swap. Fix: add a public `releaseTextureBindingForView(View view)` method to `ThermionFlutterPlugin` that releases plugin-side resources bound to a view (currently: the Android per-view SwapChain). The native plugin removes the entry from `_viewSwapChains` and calls `destroySwapChain` (which detaches from RenderManager and destroys the Filament SwapChain). Web plugin is a no-op — it doesn't track per-view bindings. `ThermionWidget.dispose()` now calls this BEFORE destroying the texture descriptor. The two are sequenced inside an unawaited async closure so dispose() stays synchronous for the framework but the swap chain destroy completes before the SurfaceTexture release. Other platforms aren't affected — releaseTextureBinding ForView is a no-op when no per-view binding exists. --- .../src/thermion_flutter_plugin_native.dart | 11 +++++++++++ .../platform/src/thermion_flutter_plugin_web.dart | 6 ++++++ .../lib/src/thermion_flutter_plugin.dart | 12 ++++++++++++ .../lib/src/widgets/src/thermion_widget.dart | 15 ++++++++++++++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart index a7dfd8d7..806fccb3 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_native.dart @@ -750,4 +750,15 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { return texture; } + + @override + Future releaseTextureBindingForView(View view) async { + // Only Android keeps per-view swap-chain bookkeeping in this plugin + // (see `_viewSwapChains` and the size-change branch of + // `createTextureAndBindToView`). Other platforms are no-ops. + final swapChain = _viewSwapChains.remove(view); + if (swapChain != null) { + await FilamentApp.instance!.destroySwapChain(swapChain); + } + } } diff --git a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart index 063edd93..c0913b08 100644 --- a/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart +++ b/thermion_flutter/thermion_flutter/lib/src/platform/src/thermion_flutter_plugin_web.dart @@ -144,6 +144,12 @@ class ThermionFlutterPluginImpl extends ThermionFlutterPlugin { return newTexture!; } + @override + Future releaseTextureBindingForView(View view) async { + // No per-view swap-chain bookkeeping on the web plugin. Surfaces + // are managed via the canvas + WebGL context lifecycle. + } + @override Future createTextureAndBindToView( View view, diff --git a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart index ae6199fd..fb015ff5 100644 --- a/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart +++ b/thermion_flutter/thermion_flutter/lib/src/thermion_flutter_plugin.dart @@ -57,6 +57,18 @@ abstract class ThermionFlutterPlugin { int height, ); + /// Release plugin-side resources bound to this view (e.g. the per-view + /// `SwapChain` the Android plugin tracks). Call this BEFORE destroying + /// the underlying texture / surface descriptor — otherwise on Android + /// the SurfaceTexture is released first, the swap chain's native + /// window becomes invalid, and Filament's next `eglSwapBuffers` call + /// for that swap chain returns `EGL_BAD_SURFACE` until the widget + /// finishes unmounting. + /// + /// Safe to call on platforms that don't track per-view bindings (e.g. + /// the web plugin) — it's a no-op there. + Future releaseTextureBindingForView(View view); + static Future createViewer( {bool destroySwapchain = true}) async { _logger.finest("Creating viewer"); diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart index 2c5d8d9b..6ec09dc3 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_widget.dart @@ -86,7 +86,20 @@ class _ThermionWidgetInternalState extends State { _debounceTimer?.cancel(); var texture = _texture; _texture = null; - texture?.destroy(); + // Tear down per-view plugin bindings (Android: SwapChain bound to + // this view) BEFORE releasing the underlying texture. The + // SurfaceTexture release frees the swap chain's native window, so + // if the swap chain is still attached to the RenderManager when + // that happens Filament's next render fires `eglSwapBuffers` on a + // null buffer and the emulator/EGL stack logs `EGL_BAD_SURFACE` + // until the widget unmount finishes propagating. Sequencing the + // two awaits inside an unawaited closure keeps `dispose()` synchronous + // for the framework while still ordering the calls. + final view = widget.view; + () async { + await ThermionFlutterPlugin.instance.releaseTextureBindingForView(view); + await texture?.destroy(); + }(); } @override