Builder.sync() orphans delayed bindings added during processing
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
#1SMP 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:
- Property
ais changed ->delayed_call_fn(a_args)pusha_argsto_delayed_start. Sync start - Inside
call_fn(a_args)processing the KV expression call a function which change propertyb 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.- Sync keep processing the original chain, do not see
b_args - Sync finish and execute
_delayed_start = None.b_argsis now unreachable from any global. - On every next change of
b,delayed_call_fn(b_args)seeb_args[-1] is not Noneand do return. The binding forbis 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
- 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_fnin list of observers not help here: early return check isif 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. - Not a smallowed exception.
except ReferenceErrorinsidesync()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. - 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:
- If property
bhas a KV binding (for examplepoints: [root.b, ...]), then any changing ofb` - 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. - 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.
- 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
"""
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
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] = NoneWhy 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:
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] = NoneBut, if:
- A change B
- B change C
- 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:
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:
next_args = _delayed_start
_delayed_start = Nonefollowed 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
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.
Source: kivy/kivy