Skip to content

Android: deferred texture disposal blocks the main thread while GL pipelines link (0.2.0-dev.3) #661

Open

Description

@Linaks

Submission checklist

  • I have confirmed the issue is present in the latest version of the rive Flutter package
  • I have searched the documentation and forums and could not find an answer
  • I have searched existing issues and this is not a duplicate

Description

Removing an Android deferred GPUCanvas texture can synchronously wait for outstanding GL replay on the platform/UI thread. A cold glLinkProgram therefore turns closing or canceling the widget into a multi-second UI freeze.

On a physical Redmi 14C (2409BRN2CY, Android 15/API 35), a Profile capture of a cold scripted GPUCanvas showed approximately 9.21 seconds of scheduled CPU in the Mali shader compiler. In the same capture, the main thread waited in native texture/session teardown. The visible freeze after a 3-second application preparation watchdog was approximately 9.6 seconds. Compiler CPU time and the wall-clock UI wait are separate measurements.

Confirmed stack groups:

GL/render worker:
ContextGL::makePipeline
  -> glLinkProgram
  -> Mali compiler

Main thread:
DeferredTextureHost::useDeferredSession
  -> AndroidRenderTexture::drainDeferred
  -> std::future<void>::get
  -> std::condition_variable::wait

The published dev.3 source also synchronizes in the Android JNI renderer destruction path. Expected: logical close returns immediately, retaining queued frames/resources until the existing render/present owners complete retirement; SurfaceProducer is released on the platform thread afterward. Actual in the measured run: main-thread teardown waited behind compilation/replay.

This is separate from #660, which concerns the Dart detach guard becoming unreachable after pointer/session state is cleared. Correcting that guard makes synchronous detach reachable but does not remove this wait.

Reproduction steps / code

This source-only fixture creates nine generic GPU pipelines and removes its widget 300ms after loading. It has no images, fonts, View Models, external data, or application material recipes.

  1. Create a Flutter app with exact dependencies rive: 0.15.0-dev.3 and rive_native: 0.2.0-dev.3.
  2. Save the Rive source files below in one directory. Run rive <directory> --publish=local with Rive CLI 1.2.0 to export a signed runtime file. Register that file as assets/probe.riv and use the included main.dart.
  3. Start Profile mode manually on a physical Mali device after a fresh process start. Capture UI and GL-worker stacks while the timer removes the first texture; compare with a warm reopen.
  4. If compilation completes before the timer, shorten its delay or increase the generic shader workload and record that alteration.

This neutral fixture has not yet been run on Android. Its Luau/WGSL verification, local Metal output and strict Dart analysis pass. Whether the generic workload overlaps removal remains to be confirmed. The measured private Card run and published source control flow are the confirmed evidence.

rive.yaml
name: android_retirement_repro
main: ColdStartProbe
scene.rml
<Rive version="1" kind="fragment">
  <Artboard width="480" height="640" name="ColdStartProbe" id="0:1" defaultStateMachineId="0:3">
    <ScriptedDrawable scriptAssetId="0:5" name="ColdStartProbe" id="0:2" />
    <StateMachine name="Probe" id="0:3" />
  </Artboard>
  <ScriptAsset name="ColdStartProbe" id="0:5" file="ColdStartProbe.luau" scriptProtocolValue="1" includeInExport="true" />
  <ShaderAsset name="probe" id="0:6" file="probe.wgsl" />
</Rive>
ColdStartProbe.luau
type Probe = {
  context: Context?, canvas: GPUCanvas?, sampler: ImageSampler?,
  vertices: GPUBuffer?, indices: GPUBuffer?, pipelines: { GPUPipeline },
  prepared: boolean,
}
function init(self: Probe, context: Context): boolean
  self.context = context
  return true
end
function advance(self: Probe, _seconds: number): boolean
  return not self.prepared
end
function draw(self: Probe, renderer: Renderer)
  local context = self.context
  if not context then return end
  if not self.canvas then
    local canvas = context:gpuCanvas({ width = 240, height = 320 })
    if not canvas then return end
    self.canvas = canvas
    self.sampler = ImageSampler("clamp", "clamp", "bilinear")
    local vertexBytes = buffer.create(32)
    for i, value in { -1, -1, 1, -1, -1, 1, 1, 1 } do
      buffer.writef32(vertexBytes, (i - 1) * 4, value)
    end
    local indexBytes = buffer.create(12)
    for i, value in { 0, 1, 2, 2, 1, 3 } do
      buffer.writeu16(indexBytes, (i - 1) * 2, value)
    end
    self.vertices = GPUBuffer.new({ size = 32, usage = "vertex", data = vertexBytes, immutable = true })
    self.indices = GPUBuffer.new({ size = 12, usage = "index", data = indexBytes, immutable = true })
    local shader = context:shader("probe")
    if not shader then return end
    for i = 1, 9 do
      table.insert(self.pipelines, GPUPipeline.new({
        vertex = { module = shader, entryPoint = "vertexMain" },
        fragment = { module = shader, entryPoint = "fragment" .. tostring(i) },
        vertexLayout = { { stride = 8, attributes = { { format = "float32x2", slot = 0, offset = 0 } } } },
        bindGroupLayouts = {}, colorTargets = { { format = canvas.format } },
        cullMode = "none", topology = "triangle-list",
      }))
    end
  end
  local canvas = self.canvas
  local vertices = self.vertices
  local indices = self.indices
  local sampler = self.sampler
  if not canvas or not vertices or not indices or not sampler then return end
  if not self.prepared then
    local pass = canvas:beginRenderPass({ color = { { loadOp = "clear", storeOp = "store", clearColor = { 0, 0, 0, 0 } } } })
    pass:setViewport(0, 0, canvas.width, canvas.height)
    pass:setPipeline(self.pipelines[9])
    pass:setVertexBuffer(0, vertices)
    pass:setIndexBuffer(indices, "uint16")
    pass:drawIndexed(6)
    pass:finish()
    self.prepared = true
  end
  renderer:save()
  renderer:transform(Mat2D.withScale(2, 2))
  renderer:drawImage(canvas.image, sampler, "srcOver", 1)
  renderer:restore()
end
return function(): Node<Probe>
  return { init = init, advance = advance, draw = draw, context = nil,
    canvas = nil, sampler = nil, vertices = nil, indices = nil,
    pipelines = {}, prepared = false }
end
probe.wgsl
struct VertexInput { @location(0) position: vec2<f32>, }
struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32>, }
@vertex fn vertexMain(input: VertexInput) -> VertexOutput {
  var result: VertexOutput;
  result.position = vec4<f32>(input.position, 0.0, 1.0);
  result.uv = input.position * 0.5 + 0.5;
  return result;
}
fn pattern(uv: vec2<f32>, variant: f32) -> vec4<f32> {
  var point = uv;
  var energy = vec3<f32>(0.0);
  for (var i: u32 = 0u; i < 32u; i = i + 1u) {
    let step = f32(i) + variant;
    point = fract(point * vec2<f32>(1.73, 2.17) + sin(point.yx * step + vec2<f32>(variant)));
    energy += vec3<f32>(sin(point.x * step), cos(point.y * step), sin((point.x + point.y) * step)) * 0.01;
  }
  return vec4<f32>(clamp(energy + vec3<f32>(0.3, 0.5, 0.7), vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
}
@fragment fn fragment1(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 1.0); }
@fragment fn fragment2(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 2.0); }
@fragment fn fragment3(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 3.0); }
@fragment fn fragment4(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 4.0); }
@fragment fn fragment5(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 5.0); }
@fragment fn fragment6(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 6.0); }
@fragment fn fragment7(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 7.0); }
@fragment fn fragment8(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 8.0); }
@fragment fn fragment9(input: VertexOutput) -> @location(0) vec4<f32> { return pattern(input.uv, 9.0); }
main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:rive/rive.dart' as rive;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await rive.RiveNative.init();
  runApp(const MaterialApp(home: RetirementProbe()));
}

class RetirementProbe extends StatefulWidget {
  const new({super.key});
  @override
  State<RetirementProbe> createState() => _RetirementProbeState();
}

class _RetirementProbeState extends State<RetirementProbe> {
  final _loader = rive.FileLoader.fromAsset(
    'assets/probe.riv',
    riveFactory: rive.Factory.rive,
  );
  Timer? _close;
  var _visible = true;

  @override
  void dispose() {
    _close?.cancel();
    _loader.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    body: Center(
      child: _visible
          ? SizedBox(
              width: 240,
              height: 320,
              child: rive.RiveWidgetBuilder(
                fileLoader: _loader,
                onLoaded: (_) {
                  _close ??= Timer(const Duration(milliseconds: 300), () {
                    if (mounted) setState(() => _visible = false);
                  });
                },
                builder: (_, state) => switch (state) {
                  rive.RiveLoaded(:final controller) => rive.RiveWidget(
                    controller: controller,
                  ),
                  _ => const SizedBox.expand(),
                },
              ),
            )
          : const Text('Texture removed; navigation should stay responsive.'),
    ),
  );
}

Upload your reproduction files / stack trace

No response

Source .riv / .rev file

No response

Screenshots / video

No response

Rive Flutter package version

rive 0.15.0-dev.3 / rive_native 0.2.0-dev.3; latest prereleases checked with the live pub.dev API on 2026-09-30

Flutter version

Flutter 3.47.5 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 6a19cca564 (13 days ago) • 2026-09-17 14:13:22 -0400
Engine • hash ab598368592da0064197e2bc15c7f5b0a2c6bb1f (revision af7e796e16) (13 days ago) • 2026-09-16 18:35:09.000Z
Tools • Dart 3.13.4 • DevTools 2.60.0

Device

Physical Redmi 14C (2409BRN2CY), ARM64, Mali driver, Profile mode

OS version

Android 15 / API 35

Additional context

The measured run used a private signed Card asset and two downstream Android rendering corrections: missing shader sampler assignments and deferred Canvas GL state restoration. Stacks were symbolized against that exact staged library. This was not an unmodified-binary experiment; those rendering corrections do not remove the published synchronous teardown path. No private photo, material shader or raw device trace is attached.

Published rive_native-0.2.0-dev.3.tar.gz source SHA-256: 0acff64554fc0973a6624daa0b0c0261ec00cde744df4b9e3daea2fa8a7b85a8. The neutral fixture sources are embedded above; no signed runtime asset is uploaded.

Regression scope: close while linking, an already running present, another texture sharing the session, repeated disposal, stale callbacks, background/resume, and engine shutdown. Queued work needs its original target identity and resource lifetime. A detached target must not be recycled into a new widget while an old job still refers to it. Retirement should use the existing render/present queues without an additional executor.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions