Repository navigation
advanceAndApply() never settles after hitting max iterations (60 fps with nothing changing) #664
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
A state machine layer cycling through zero-duration transitions hits the 100-iteration cap and returns false, but StateMachineInstance::advanceAndApply() then calls tryChangeState() again, re-enters the same cycle and sets keepGoing = true. So advanceAndApply() returns true on every frame while nothing on screen changes (Artboard::didChange() stays false). FlutterRiveRenderBox only stops its ticker on false, so the app renders 60 fps of an unchanged picture forever.
The root cause in our case is the file (an Any State transition whose condition stays true targets a state that exits immediately) and we are fixing it. But the runtime makes it much worse:
- It undoes its own livelock detection: the extra
tryChangeState()re-armskeepGoingfor a cycle that cannot produce anything new, so the guard never stops anything from one frame to the next. - It waits 100 iterations for a cycle that repeats on its 2nd iteration (~200
StateInstanceallocations per frame). - It reports it only on stderr, which doesn't reach Android logcat; the editor doesn't flag the construct either.
Measurements
Device (Galaxy Z Flip6, Android 16, profile, 60 Hz), a tab bar with five Rive icons, one of them with the construct, at rest, 10 s windows:
| mode | fps | GPU busy | process CPU |
|---|---|---|---|
| stock | 59.3 | 55 % | 80 % |
| same tab bar, icons replaced by empty boxes | 0 | 0 % | 1 % |
C++ harness with a ticker that stops on false (600 frames): the icon is advanced on 483 frames with the shipped runtime vs 69 with fix A below; "exceeded max iterations" lines drop from 840 to 6; didChange and dirtied components are identical frame by frame. Across 56 files, 51 produce identical output and the 5 looping ones settle.
Root cause (runtime/src/animation/state_machine_instance.cpp, rive_native 0.1.11, unchanged in 0.2.0-dev.3)
StateMachineLayerInstance::advance():for (i = 0; updateState(); i++); atmaxIterationsit prints and returnsfalse.advanceAndApply()outer loop:if (tryChangeState()) { advance(0, false); keepGoing = true; }finds the same always-true transition again (canChangeState()only rejects the current state).
Proposed fix A (prototype, ~40 lines)
At the cap, if the cycle had no observable side effect (no event fired, no listener action, no random transition, no spilled time, no scripted condition), updateState() returns false for the rest of that advance(). Everything else still drives keepGoing (component dirt, data binding, nested artboards, other layers), and inputs / view model writes / pointer / layout already restart a stopped ticker. A cycle with side effects keeps looping as today.
Refinement A′: detect the cycle when a state is re-entered within the same frame with no side effect, instead of after 100 iterations (also removes the allocations and the log spam).
Diff against rive_native 0.1.11
--- a/runtime/src/animation/state_machine_instance.cpp
+++ b/runtime/src/animation/state_machine_instance.cpp
@@ -230,6 +230,8 @@ public:
}
m_currentState->advance(seconds, m_stateMachineInstance);
updateMix(seconds);
+ m_settledInCycle = false;
+ m_cycleIsOpaque = false;
if (m_stateFrom != nullptr && m_mix < 1.0f && !m_holdAnimationFrom)
{
@@ -249,6 +251,13 @@ public:
if (i == maxIterations)
{
+ // The layer is cycling through zero-duration transitions whose
+ // conditions stay true. When the cycle has no side effect the
+ // host can observe (no event, no listener action, no random
+ // pick, no spilled time, no scripted condition), re-running it
+ // next frame yields the same result: let the layer settle until
+ // an input, a view model property or a pointer wakes it.
+ m_settledInCycle = !m_cycleIsOpaque;
auto stateMachineName =
m_stateMachineInstance->stateMachine() == nullptr
? "[SM Not found]"
@@ -323,6 +332,10 @@ public:
bool updateState()
{
+ if (m_settledInCycle)
+ {
+ return false;
+ }
// Don't allow changing state while a transition is taking place
// (we're mixing one state onto another) if enableEarlyExit is not true.
if (isTransitioning() && !m_transition->enableEarlyExit())
@@ -347,6 +360,7 @@ public:
{
if (event->occurs() == occurs)
{
+ m_cycleIsOpaque = true;
event->perform(m_stateMachineInstance);
}
}
@@ -360,6 +374,7 @@ public:
{
if (action->matchesScheduledOccurrence(occurs))
{
+ m_cycleIsOpaque = true;
action->perform(m_stateMachineInstance,
ListenerInvocation::none());
}
@@ -409,6 +424,19 @@ public:
return;
}
+ // A scripted condition can change its answer without any input changing,
+ // so a cycle that evaluates one is never treated as settled.
+ void markOpaqueConditions(const StateTransition* transition)
+ {
+ for (size_t c = 0; c < transition->conditionCount(); c++)
+ {
+ if (transition->condition(c)->is<ScriptedTransitionCondition>())
+ {
+ m_cycleIsOpaque = true;
+ }
+ }
+ }
+
StateTransition* findRandomTransition(StateInstance* stateFromInstance)
{
uint32_t totalWeight = 0;
@@ -420,6 +448,7 @@ public:
if (canChangeState(transition->stateTo()))
{
+ markOpaqueConditions(transition);
auto allowed = transition->allowed(stateFromInstance,
m_stateMachineInstance,
this);
@@ -456,6 +485,7 @@ public:
(double)transition->evaluatedRandomWeight();
if (currentWeight + transitionWeight > randomWeight)
{
+ m_cycleIsOpaque = true;
transition->useLayerInConditions(m_stateMachineInstance,
this);
return transition;
@@ -484,6 +514,7 @@ public:
if (canChangeState(transition->stateTo()))
{
+ markOpaqueConditions(transition);
auto allowed = transition->allowed(stateFromInstance,
m_stateMachineInstance,
this);
@@ -619,6 +650,10 @@ public:
advanceTime = instance->spilledTime();
}
}
+ if (advanceTime != 0.0f)
+ {
+ m_cycleIsOpaque = true;
+ }
m_currentState->advance(advanceTime, m_stateMachineInstance);
}
m_mix = 0.0f;
@@ -705,6 +740,10 @@ private:
bool m_stateMachineChangedOnAdvance = false;
bool m_waitingForExit = false;
+ /// The layer hit maxIterations this frame on a cycle without observable
+ /// side effects; updateState() stops re-entering it until next advance.
+ bool m_settledInCycle = false;
+ bool m_cycleIsOpaque = false;
/// Used to ensure a specific animation is applied on the next apply.
const LinearAnimation* m_holdAnimation = nullptr;
float m_holdTime = 0.0f;Questions
- Is returning
trueafter the cap intended? What change would the extratryChangeState()catch that doesn't already trigger an advance? - Would you accept fix A or A′? Did we miss a source of non-determinism?
- Could "exceeded max iterations" reach the host (callback /
__android_log_print) and the editor (warning on this construct)?
Reproduction steps / code
- View model: enum
iconState(notSelected,selected), boollightMode. - Animations:
Inactive_light/Inactive_dark(static pose),Active(500 ms). - One layer:
Any State ──[iconState == notSelected && lightMode]──▶ Inactive_light
Any State ──[iconState == notSelected && !lightMode]─▶ Inactive_dark
Any State ──[iconState changed to selected]──────────▶ Active
Inactive_light ──(no condition)──▶ Exit
Inactive_dark ──(no condition)──▶ Exit
- Show it with
RiveWidget,iconState = notSelected, don't touch it.
Observed: stderr "exceeded max iterations" twice per frame, advanceAndApply() true forever, 60 fps with nothing changing. Expected: the layer settles. Removing the two → Exit transitions makes it settle today.
Rive Flutter package version
rive 0.14.11 (rive_native 0.1.11); same code in rive_native 0.2.0-dev.3.
Flutter version
Flutter 3.47.3 (stable), Dart 3.13.3
Device
Samsung Galaxy Z Flip6 (SM-F741B); C++ harness on macOS.
OS version
Android 16
Additional context
Shared C++ runtime, so every platform is affected; it hurts most on Flutter because FlutterRiveRenderBox stops its ticker as soon as the runtime answers false. A separate issue covers the Flutter draw gate on didChange (parity with iOS/web): #665
Activity
[-]State machine never settles after a layer hits the max-iterations cap: advanceAndApply() re-enters the cycle, so the Flutter ticker runs at 60 fps with nothing changing[/-][+]advanceAndApply() never settles after hitting max iterations (60 fps with nothing changing)[/+]HayesGordon commentedon Oct 20, 2026
Thanks for the report, we're looking into a fix