Skip to content

advanceAndApply() never settles after hitting max iterations (60 fps with nothing changing) #664

Open

Description

@Michael-Betclic

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

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:

  1. It undoes its own livelock detection: the extra tryChangeState() re-arms keepGoing for a cycle that cannot produce anything new, so the guard never stops anything from one frame to the next.
  2. It waits 100 iterations for a cycle that repeats on its 2nd iteration (~200 StateInstance allocations per frame).
  3. 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++); at maxIterations it prints and returns false.
  • 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

  1. Is returning true after the cap intended? What change would the extra tryChangeState() catch that doesn't already trigger an advance?
  2. Would you accept fix A or A′? Did we miss a source of non-determinism?
  3. Could "exceeded max iterations" reach the host (callback / __android_log_print) and the editor (warning on this construct)?

Reproduction steps / code

  1. View model: enum iconState (notSelected, selected), bool lightMode.
  2. Animations: Inactive_light / Inactive_dark (static pose), Active (500 ms).
  3. 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
  1. 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

  1. changed the title [-]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)[/+] on Oct 14, 2026
  2. added
    knownTriaged: understood and tracked on our side
    on Oct 20, 2026
  3. HayesGordon commented on Oct 20, 2026

    @HayesGordon
    Contributor

    Thanks for the report, we're looking into a fix

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

    knownTriaged: understood and tracked on our side

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions