Skip to content

android: per-view swap chain bookkeeping (multi-viewer fixes)#169

Open
mushogenshin wants to merge 3 commits into
nmfisher:developfrom
mushogenshin:fix/android-per-view-swap-chains
Open

android: per-view swap chain bookkeeping (multi-viewer fixes)#169
mushogenshin wants to merge 3 commits into
nmfisher:developfrom
mushogenshin:fix/android-per-view-swap-chains

Conversation

@mushogenshin
Copy link
Copy Markdown
Contributor

Summary

Three related fixes to the Android plugin's swap-chain handling. All three are needed for multi-viewer Flutter apps on Android to mount, render, and unmount cleanly. Single-viewer behaviour is unchanged.

Background

`thermion_flutter_plugin_native.dart`'s Android branch of `createTextureAndBindToView` recreates a swap chain whenever the surface size changes — that's the documented intent (`// On Android, we recreate the swapchain whenever the size changes`). The implementation works for single-viewer apps but has three latent bugs that surface immediately when more than one viewer is mounted.

Fix 1: `getSwapChains()` returns a snapshot, not the live list

`FFIFilamentApp.getSwapChains()` returned the live `_swapChains` list reference. The plugin code does:

```dart
final swapChains = await FilamentApp.instance!.getSwapChains(); // aliases _swapChains
final swapChain = await FilamentApp.instance!.createSwapChain(...); // appends to _swapChains
if (swapChains.isNotEmpty) { // now [newSwapChain]!
await FilamentApp.instance!.destroySwapChain(swapChains.first); // destroys the NEW one
}
```

The intent reads as "snapshot existing chains, create the new one, tear down the old one" — but because `swapChains` aliased `_swapChains`, `createSwapChain` appended into the very list the plugin was about to iterate. `swapChains.first` immediately referred to the newly-created swap chain. The plugin destroyed its own new swap chain and attached the view to a freed pointer; the next render hit Filament's `endFrame:490` precondition and aborted on every viewer mount.

Returning `List.unmodifiable(_swapChains)` defends at the API layer.

Fix 2: per-view swap chain tracking (multi-viewer freeze)

After Fix 1, the snapshot is correct, but `swapChains.first` is still the first swap chain in the list — which in a multi-viewer app is another viewer's swap chain. Mounting viewer #N destroyed viewer #(N-1)'s swap chain, freezing it. The most-recently-mounted viewer always rendered fine; everyone else froze in cascade.

Track the swap chain currently bound to each view in a `<View, SwapChain>` map (mirroring the existing `_viewRenderTargets` pattern in the same file), and destroy that one — scoped to this view only. Other viewers' swap chains stay live.

Fix 3: release per-view swap chain on widget dispose (EGL_BAD_SURFACE flood)

After Fix 2, the swap chain is correctly created and reused per view, but never released when the widget unmounts. `ThermionWidget.dispose()` released the underlying SurfaceTexture (freeing the native window), while the SwapChain stayed in `_viewSwapChains` and stayed attached to the RenderManager. The next render iteration would then call `eglSwapBuffers` on the now-orphaned native window, logging:

```
E/EGL_emulation: egl_window_surface_t::swapBuffers called with NULL buffer
E/EGL_emulation: tid : swapBuffers(764): error 0x300d (EGL_BAD_SURFACE)
```

repeatedly per render. On a multi-viewer stress test, even brief widget rebuilds (toggling viewer count) flooded the log and cost visible frame budget.

Add a public `releaseTextureBindingForView(View view)` API on `ThermionFlutterPlugin`. Native impl removes the entry from `_viewSwapChains` and calls `destroySwapChain` (which detaches from RenderManager and destroys the Filament SwapChain). Web impl is a no-op — it doesn't track per-view bindings. `ThermionWidget.dispose()` calls it before tearing down the descriptor, sequenced inside an unawaited async closure so `dispose()` stays synchronous for the framework while the destroy still completes before the SurfaceTexture release.

Verification

  • Stress-test screen on Android emulator: 4-viewer mount, render, region-tap, dispose all clean.
  • 8-viewer mount works too (with companion fixes from sibling PRs covering the multi-viewer state-mutation race; this PR alone is sufficient for single-viewer + 4-viewer).
  • iOS / macOS / Windows unchanged — the affected code path is gated on `Platform.isAndroid`.

Related

…ying its own swapchain

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<Iterable<SwapChain>> 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.
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 (nmfisher#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
`<View, SwapChain>` 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.
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 <n>: 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.
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.

1 participant