#9373·kivy

Builder.sync() orphans delayed bindings added during processing

Author: mushket-re0ahCreated Sep 10, 2026Updated Sep 12, 2026
LabelsComponent: KV-langStatus: Issue ConfirmedType: BugPriority: Medium

Software Versions

  • Python: v3.14.6 (main, Jun 15 2026, 11:36:54) [GCC 16.1.1 20260430]
  • OS: arch linux desktop 7.1.2-arch3-1 #1 SMP PREEMPT_DYNAMIC Fri, 03 Jul 2026 23:25:36 +0000 x86_64 GNU/Linux
  • Kivy: 2.3.1
  • Kivy installation method: pip

also checked on

  • Python: v3.8.7 (tags/v3.8.7:6503f05, Dec 21 2020, 17:59:51) [MSC v.1928 64 bit (AMD64)]
  • OS: windows 7
  • Kivy: 2.3.1
  • Kivy installation method: pip

Describe the bug BuilderBase.sync() in kivy/lang/builder.py resets the global _delayed_start only AFTER processing the delayed-callback queue. Any delayed binding which will add to queue while that processing, like a side effect of evaluating another KV expression inside a call_fn callback is insert at the head of queue that is currently being processing, because _delayed_start still non-None at that moment. When the processing will be finished, _delayed_start = None orphans that late-added node: its args[-1] remains non-None forever so its delayed_call_fn will early do return on every next trigger.

The affected binding not cancel, not raise, not log anything, just silently stops updating. This is why bug is hard to catch: observers remain in place, delayed_call_fn still called, but early return in delayed_call_fn (if args[-1] is not None: return) prevent any next call_fn execution for that node.

Mechanism:

  1. Property a is changed -> delayed_call_fn(a_args) push a_args to _delayed_start. Sync start
  2. Inside call_fn(a_args) processing the KV expression call a function which change property b
  3. delayed_call_fn(b_args) run. b_args[-1] is None, so it not to do early return. But _delayed_start is not None (still the queue processing), so it take the else branch: b_args[-1] = _delayed_start; _delayed_start = b_args. In my case _delayed_start at this moment is b_args itself (it was drained in step 2 and its args[-1] was reset, but the global was not), so this creates a self-loop: b_args[-1] = b_args.
  4. Sync keep processing the original chain, do not see b_args
  5. Sync finish and execute _delayed_start = None. b_args is now unreachable from any global.
  6. On every next change of b, delayed_call_fn(b_args) see b_args[-1] is not None and do return. The binding for b is dead for rest of the object lifetime. b_args is now unreachable from any global, and its args[-1] points to itself (self-loop). On every next change of b, delayed_call_fn(b_args) check if args[-1] is not None - the self-loop makes this always true - and return. The binding for b is dead for the rest of the object lifetime.

self-loop happens here because _delayed_start happened to point at the same node being re-added; the bug is the orphaning, not the self-loop specifically.

What it is not

  1. It is not weakref/GC death of the args node. The args object stays alive; delayed_call_fn is still called on every source change, and get_property_observers() still contain it. The node only become unreachable from the global _delayed_start. The presence of delayed_call_fn in list of observers not help here: early return check is if args[-1] is not None - it only look at the node itself, not at the observers list. The node only become unreachable from the global _delayed_start.
  2. Not a smallowed exception. except ReferenceError inside sync() can confuse: in that scenario no exception is raised at all. The orphaned node never reach the call_fn, because its own delayed_call_fn early return before sync look to him.
  3. Other binding in the same KV rule keep working. In the attached reproduction, a second Line in the same widget, and in my real app the rgba of the same canvas block continue to update, because they were add to queue before the sync pass start, not during it. This rules out a scenario in which the entire rule become unbound and points to data loss at the level of invidual argument nodes.

Expected behavior A delayed KV binding must keep updating his target property for the lifetime of widget attached to, no matter what of when during a Builder.sync() pass the source property changes.

Specifically:

  1. If property b has a KV binding (for example points: [root.b, ...]), then any changing of b` - whether it happens from a Clock callback, from user code, or as a side effect of another KV expression being evaluated inside Builder.sync - must result in target property being recomputed on the next sync pass.
  2. Delayed callback which added to queue in moment of processing another sync pass in already processing must not be lost. His processing must be moved to next tick, but he must not remain unprocessed.
  3. No binding should silently stop update without an exception, without a warning, and without any way for the application to detect it.

In the attach reproduction after n == 180 the victim line point must keep tracking frame_victim with lag == 0 for the entire run, same as the first 3 seconds. On vanilla kivy 2.3.1 the victim line freeze and lag grows monotonically. With the proposed one-line fix in BuilderBase.sync() the expected behavior is restored.

To Reproduce

python
"""
Phases:
  1) n < 180:  tick move frames from a Clock callback (outside sync)
               victim line follow frame_victim and move to by main line
               lag == 0 frame
  2) n == 180: switch to side_effect mode. From now on frame_victim update by
               side_effect(), which call by processing on calculate kv expression
               points in main line
  3) n > 180:  on vanilla Kivy, victim line points never updates again and lag
               will be incrementing
               on patched lag stays 0
"""

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.widget import Widget
from kivy.properties import NumericProperty, BooleanProperty
from kivy.clock import Clock

KV = '''
<Root>:
    canvas:
        Line:  # main line
            width: 3
            points: [root.frame_main + root.side_effect(), 0, root.frame_main, 100]
        Line:  # victim line
            width: 1
            points: [root.frame_victim, 0, root.frame_victim, 100]
'''


BROKE_TIMEOUT = 180   # ~3 seconds of moving before broking


class Root(Widget):
    frame_main = NumericProperty(30)
    frame_victim = NumericProperty(0)
    side_effect_on = BooleanProperty(False)

    def side_effect(self):
        if self.side_effect_on:
            self.frame_victim += 1
        return 0


class Test(App):
    def build(self):
        self.root = Root()
        return self.root

    def get_victim_line_instruction(self):
        return next((
            c for c in self.root.canvas.children
            if c.__class__.__name__ == 'Line' and c.width == 1)
        )

    def on_start(self):
        self.timer = 0
        Clock.schedule_interval(self.report, 1 / 4)
        Clock.schedule_interval(self.tick, 1 / 60)

    def tick(self, _):
        self.timer += 1
        self.root.frame_main += 1
        self.root.frame_victim += 1

        if self.timer == BROKE_TIMEOUT:
            print(f"\n>>> n={self.timer}: side_effect_on=True. "
                  f"frame_victim now changing only from eval "
                  f"(inside Builder.sync)\n")
            self.root.side_effect_on = True
            return

    def report(self, _):
        victim_line = self.get_victim_line_instruction()
        pos_x = float(victim_line.points[0])
        # how much victim line lagged from main line
        lag_x = self.root.frame_victim - pos_x
        tag = "OK" if lag_x == 0 else "STUCK"
        print(f"n={self.timer:5}  frame_victim={self.root.frame_victim:5}  "
              f"victim.x={pos_x:7}  lag={lag_x:6}  [{tag}]")


if __name__ == "__main__":
    Builder.load_string(KV)
    Test().run()

Proposed fix Reset the global `_delayed_start BEFORE the processing loop, so any binding added during processing start a fresh chain for the next tick

python
def sync(self):
    global _delayed_start
    next_args = _delayed_start
    _delayed_start = None  # moved from the end of the loop
    if next_args is None:
        return

    while next_args is not StopIteration:
        try:
            call_fn(next_args[:-1], None, None)
        except ReferenceError:
            pass
        args = next_args
        next_args = args[-1]
        args[-1] = None

Why this is correct (MAYBE). _delayed_start is cleared before the processing loop, so any late binding (added during a call_fn call) see, that _delayed_start is None and go to if branch: _delayed_start = b_args; b_args[-1] = StopIteration. This start the new, self-contained chain for the next tick. The loop continue to drain the original chain, untouch the new. There is no _delayed_start = None at the end anymore, so nothing is overwritten and nothing is dropped or missed. Each tick begin with whatever chain has accumulated since the last tick, and any binding added during processing correctly ends up at the head of the next tick chain instead of being adding to a chain that is already in processing.

Price of that fix is one additional sync cycle latency for reentrant work. But exclude: corruption, recursion, silent lost updates. But it is trade-off. Exist a better solution, because one additional sync cycle is not a cheap price and can broke something. Maybe, double buffering. Maybe sync must be in while, until _delayed_start becomes None. Like:

python
def sync(self):
    global _delayed_start

    while _delayed_start is not None:
        next_args = _delayed_start
        _delayed_start = None

        while next_args is not StopIteration:
            try:
                call_fn(next_args[:-1], None, None)
            except ReferenceError:
                pass

            args = next_args
            next_args = args[-1]
            args[-1] = None

But, if:

  1. A change B
  2. B change C
  3. C change A It will create infinity recursion.

So I propose snapshot-based batch processing.

Anyway, you can check this fix by minimal runtime patch:

python
def apply_patch():
    from types import MethodType
    from kivy.lang import builder
    def patch_sync(self):
        next_args = builder._delayed_start
        builder._delayed_start = None  # moved there from end of procedure
        if next_args is None:
            return

        while next_args is not StopIteration:
            try:
                builder.call_fn(next_args[:-1], None, None)
            except ReferenceError:
                pass
            args = next_args
            next_args = args[-1]
            args[-1] = None

    builder.Builder.sync = MethodType(patch_sync, builder.Builder)

Call this before App run, restart program and you see: bug not will be happened

P.S. One additional point about the proposed fix:

In reproduction intentionally uses a side effect inside a KV expression, which is arguably not a good usage pattern. However, I think the underlaying issue is broader than whether that particular expression is considered valid.

Builder.sync() is processing a queue of callbacks, and callbacks can cause other Properties to change. As result new delayed callbacks may be scheduled reentrantly. The current implementation conflates the queue being processed ith the queue receiving newly scheduled work.

More safer model would be to treat sync() as processing a snapshot or batch:

python
next_args = _delayed_start
_delayed_start = None

followed by processing next_args.

  • callbacks queue before sync() starts belong to the current batch;
  • callbacks scheduled while the batch in processing belong to the next batch;
  • newly scheduled callbacks cannot modify or become orphaned from the queue currently being traversed

This also makes the behavior more deterministic.

In other words, even if the reproduction is uncorrect and use invalid or unrecommended usage pattern, I think the proposed change is still enhance the reliability of Builder.sync().

A framework should not necessarily make incorrect usage work correctly, but it should avoid turning it into silent corruption when the situation can be handled safely.

In complex and hard project, which use Kivy, such situations can occur unintentionally, and detecting such an error is an extremely difficult task. Yes, maybe, uncorrect usage pattern, but consequence is silent.

That is exactly what happened in my project, but situation was completely different: there was no such use of properties with side effects. Maybe. I don't found. I have a running line for dmx512 playback process display; the application is atypical for Kivy - heavy desktop app with a large number of widgets and MDI, which created dynamically (not at start app, but when MDI first opening - which triggers a massive amount of calculations, binding, deferred calls and other). And when MDI will open, the running line sometimes, not always, stopped forever. And even if such uncorrect usage pattern was, so: in complex project can be very difficult to keep track of things like that. It is just was

python
Line:
    width: dp(1)
    points: [self.player_line_x, self.top - (self.toolbar.height if self.toolbar else 0), self.player_line_x, self.y]

And problem was happened. In a large reactive UI, these interactions are not always local or obvious. A property change can indirectly trigger other bindings through several layers of widgets and deferred callbacks, making it very difficult for an application developer to reason about whether a reentrant scheduling path exist.