Repository navigation
Android: deferred texture disposal blocks the main thread while GL pipelines link (0.2.0-dev.3) #661
Description
Submission checklist
- I have confirmed the issue is present in the latest version of the
riveFlutter 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.
- Create a Flutter app with exact dependencies
rive: 0.15.0-dev.3andrive_native: 0.2.0-dev.3. - Save the Rive source files below in one directory. Run
rive <directory> --publish=localwith Rive CLI 1.2.0 to export a signed runtime file. Register that file asassets/probe.rivand use the includedmain.dart. - 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.
- 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: ColdStartProbescene.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 }
endprobe.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