Skip to content

Commit 796ec70

Browse files
committed
fix(hot_reload,native_views): dedupe per-host reloads; clamp NaN frames
1 parent 9e2b699 commit 796ec70

6 files changed

Lines changed: 655 additions & 26 deletions

File tree

src/pythonnative/hot_reload.py

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,19 @@ class ModuleReloader:
196196
"""Reload changed Python modules on device and trigger a re-render.
197197
198198
Designed to be invoked from device-side glue when a hot-reload
199-
push completes. The class itself holds no state; all methods are
200-
static.
199+
push completes. All public methods are static; the class holds a
200+
single piece of process-wide state — the manifest version that
201+
has most recently been applied to ``sys.modules`` — so that
202+
multiple screen hosts polling the same manifest do not each
203+
re-execute the user-app modules. The first host to see a new
204+
version pays the ``reload_modules`` cost; subsequent hosts on the
205+
same version refresh only their own reconciler tree against the
206+
already-fresh modules.
201207
"""
202208

209+
_last_reloaded_version: Optional[str] = None
210+
_reload_lock = threading.Lock()
211+
203212
@staticmethod
204213
def reload_module(module_name: str) -> bool:
205214
"""Reload a single module by its dotted name.
@@ -254,6 +263,139 @@ def reload_modules(module_names: Sequence[str]) -> List[str]:
254263
reloaded.append(module_name)
255264
return reloaded
256265

266+
@staticmethod
267+
def reload_modules_for_version(
268+
module_names: Sequence[str],
269+
version: Optional[str],
270+
) -> List[str]:
271+
"""Reload ``module_names`` for ``version``, deduping across hosts.
272+
273+
Each native screen host on iOS / Android runs its own poll
274+
loop and would otherwise call
275+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
276+
independently for the same manifest version. That re-executes
277+
every user-app module N times (once per host) per file change,
278+
producing N different generations of the same function objects
279+
in ``sys.modules`` and leaving each host's reconciler tree
280+
pointing at a different generation. Beyond the wasted work,
281+
the inconsistent state has been observed to crash UIKit on iOS
282+
with ``CALayerInvalidGeometry`` (NaN values fed into ``setFrame_:``
283+
during the interleaved renders).
284+
285+
This helper serializes on
286+
[`_reload_lock`][pythonnative.hot_reload.ModuleReloader] and uses
287+
[`_last_reloaded_version`][pythonnative.hot_reload.ModuleReloader]
288+
to ensure only the *first* host to see a given ``version``
289+
actually re-executes the modules. Subsequent hosts on the same
290+
version get back the already-fresh entries from ``sys.modules``
291+
so their own
292+
[`refresh_in_place`][pythonnative.hot_reload.ModuleReloader.refresh_in_place]
293+
pass can still rewrite their tree against the same generation.
294+
295+
Args:
296+
module_names: Dotted module names to reload.
297+
version: Manifest version this reload is processing. When
298+
``None`` (e.g. tests calling reload directly) the call
299+
falls back to the unconditional
300+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
301+
behavior.
302+
303+
Returns:
304+
The list of module names that are currently fresh in
305+
``sys.modules`` — either freshly reloaded by this call, or
306+
already reloaded by an earlier host for the same version.
307+
"""
308+
with ModuleReloader._reload_lock:
309+
if version is not None and version == ModuleReloader._last_reloaded_version:
310+
return [name for name in module_names if name in sys.modules]
311+
reloaded = ModuleReloader.reload_modules(module_names)
312+
if reloaded and version is not None:
313+
ModuleReloader._last_reloaded_version = version
314+
return reloaded
315+
316+
@staticmethod
317+
def expand_reload_targets(changed_modules: Sequence[str], component_path: str) -> List[str]:
318+
"""Expand a manifest of changed modules into the full reload order.
319+
320+
When a user edits ``app/screens/home.py``, only that file is in
321+
the manifest. But the entry-point module ``app.main`` has
322+
bindings like ``from app.screens.home import HomeScreen`` that
323+
need to be re-evaluated against the freshly-loaded
324+
``app.screens.home``; likewise other user-app modules may carry
325+
transitive bindings (e.g. through a shared ``app/theme.py``)
326+
that go stale if only the changed file is reloaded.
327+
328+
This helper computes the full ordered reload list:
329+
330+
1. Explicitly changed modules first (in the order given), so
331+
their fresh source replaces the cached version in
332+
``sys.modules`` before any dependent modules re-execute.
333+
2. All other currently-imported modules under the entry-point's
334+
top-level package, deepest first. The depth heuristic biases
335+
toward leaves so re-executing a screen file picks up the
336+
newest shared utilities before the file that imports it does.
337+
3. The entry-point module itself, last, so its
338+
``from ... import`` bindings rebind against everything that
339+
was refreshed in steps 1 and 2.
340+
341+
Modules outside the entry-point's top-level package
342+
(``pythonnative.*``, stdlib, third-party) are never included;
343+
framework code is not reloaded.
344+
345+
Args:
346+
changed_modules: Modules reported as changed by the host
347+
file-watcher (already in dotted form).
348+
component_path: The host's entry-point identifier, either a
349+
module path (``"app.main"``) or a dotted attribute path
350+
(``"app.main.RootScreen"``).
351+
352+
Returns:
353+
The ordered list of modules to feed to
354+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules].
355+
"""
356+
entry_module: Optional[str] = None
357+
if component_path in sys.modules:
358+
entry_module = component_path
359+
elif "." in component_path:
360+
parent = component_path.rsplit(".", 1)[0]
361+
if parent in sys.modules:
362+
entry_module = parent
363+
364+
app_prefix: Optional[str] = None
365+
if entry_module:
366+
app_prefix = entry_module.split(".")[0]
367+
else:
368+
for m in changed_modules:
369+
if m:
370+
app_prefix = m.split(".")[0]
371+
break
372+
373+
app_modules: Set[str] = set()
374+
if app_prefix:
375+
for name in list(sys.modules):
376+
if name == app_prefix or name.startswith(app_prefix + "."):
377+
app_modules.add(name)
378+
379+
ordered: List[str] = []
380+
seen: Set[str] = set()
381+
for m in changed_modules:
382+
if m and m not in seen:
383+
ordered.append(m)
384+
seen.add(m)
385+
386+
others = [m for m in app_modules if m not in seen and m != entry_module]
387+
others.sort(key=lambda m: (-m.count("."), m))
388+
for m in others:
389+
ordered.append(m)
390+
seen.add(m)
391+
392+
if entry_module:
393+
if entry_module in seen:
394+
ordered.remove(entry_module)
395+
ordered.append(entry_module)
396+
397+
return ordered
398+
257399
@staticmethod
258400
def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
259401
"""Convert a file path to a dotted module name.
@@ -491,5 +633,13 @@ def reload_from_manifest(
491633
files = manifest.get("files", [])
492634
modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else [])
493635

494-
ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
636+
# Stash the version on the host so `_reload_host` can dedupe
637+
# `reload_modules` across multiple hosts polling the same
638+
# manifest. See `reload_modules_for_version`.
639+
previous_pending = getattr(screen_instance, "_hot_reload_pending_version", None)
640+
try:
641+
screen_instance._hot_reload_pending_version = version
642+
ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
643+
finally:
644+
screen_instance._hot_reload_pending_version = previous_pending
495645
return version

src/pythonnative/native_views/__init__.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,67 @@
2323
reconciler with no real native views.
2424
"""
2525

26+
import math
27+
import sys
28+
import threading
29+
import time
2630
from typing import Any, Dict, Optional, Tuple
2731

2832
from .base import ViewHandler
2933

34+
# ======================================================================
35+
# Tripwire log rate limiter
36+
# ======================================================================
37+
#
38+
# Defensive NaN/Inf guards in ``set_frame`` and ``_apply_transform`` log
39+
# a single line per occurrence. That's fine for one-off events, but
40+
# ``Animated.View`` drives transforms at ~60 Hz; once an
41+
# ``Animated.Value`` enters a stuck NaN state (e.g., a spring tick
42+
# corrupted across a Fast Refresh), the tripwire would otherwise emit
43+
# thousands of identical lines per second and drown the dev console.
44+
#
45+
# We instead log the first occurrence immediately, then suppress
46+
# further messages with the same ``label`` for
47+
# ``_TRIPWIRE_RATE_LIMIT_S`` seconds, and append a
48+
# ``(+N similar in last Xs)`` suffix to the next message that escapes
49+
# the window. The first sample plus a count is enough to diagnose; the
50+
# bounded log keeps the dev console usable.
51+
52+
_TRIPWIRE_RATE_LIMIT_S: float = 1.0
53+
_TRIPWIRE_LOG_LOCK = threading.Lock()
54+
_TRIPWIRE_LAST_LOG_TIME: Dict[str, float] = {}
55+
_TRIPWIRE_SUPPRESSED_COUNT: Dict[str, int] = {}
56+
57+
58+
def _tripwire_log(label: str, message: str) -> None:
59+
"""Emit ``message`` to stderr, rate-limited per ``label``.
60+
61+
The first call for a given ``label`` always emits. Calls within
62+
``_TRIPWIRE_RATE_LIMIT_S`` seconds are silently counted. The next
63+
call after the window appends ``(+N similar in last Xs)`` and
64+
resets the counter.
65+
"""
66+
now = time.monotonic()
67+
write = False
68+
suppressed = 0
69+
with _TRIPWIRE_LOG_LOCK:
70+
last = _TRIPWIRE_LAST_LOG_TIME.get(label)
71+
if last is None or now - last >= _TRIPWIRE_RATE_LIMIT_S:
72+
write = True
73+
suppressed = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0)
74+
_TRIPWIRE_SUPPRESSED_COUNT[label] = 0
75+
_TRIPWIRE_LAST_LOG_TIME[label] = now
76+
else:
77+
_TRIPWIRE_SUPPRESSED_COUNT[label] = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0) + 1
78+
if not write:
79+
return
80+
if suppressed > 0:
81+
message = f"{message} (+{suppressed} similar in last {_TRIPWIRE_RATE_LIMIT_S:g}s)"
82+
try:
83+
print(message, file=sys.stderr, flush=True)
84+
except Exception:
85+
pass
86+
3087

3188
class NativeViewRegistry:
3289
"""Map element type names to platform-specific view handlers.
@@ -136,6 +193,21 @@ def set_frame(
136193
coordinates computed by ``pythonnative.layout`` in points
137194
relative to the parent's content origin.
138195
"""
196+
# Tripwire: log non-finite layout values so we can diagnose
197+
# crashes like iOS `CALayerInvalidGeometry` without losing the
198+
# repro. Handlers are responsible for clamping before applying.
199+
# Rate-limited via ``_tripwire_log`` to avoid 60 Hz floods when
200+
# an animated value is stuck at NaN.
201+
try:
202+
finite = math.isfinite(x) and math.isfinite(y) and math.isfinite(width) and math.isfinite(height)
203+
except (TypeError, ValueError):
204+
finite = False
205+
if not finite:
206+
_tripwire_log(
207+
"set_frame:nan",
208+
f"[set_frame:nan] type={type_name!r} " f"x={x!r} y={y!r} w={width!r} h={height!r}",
209+
)
210+
139211
handler = self._handlers.get(type_name)
140212
if handler is not None:
141213
handler.set_frame(native_view, x, y, width, height)

src/pythonnative/native_views/ios.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,28 @@
2828

2929
from rubicon.objc import SEL, ObjCClass, objc_method
3030

31+
from . import _tripwire_log
3132
from .base import ViewHandler, _safe_max, parse_color_int
3233

34+
35+
def _safe_finite(value: Any, default: float = 0.0) -> float:
36+
"""Coerce ``value`` to a finite float, falling back to ``default``.
37+
38+
Used as a defensive guard around every call into UIKit that takes a
39+
geometry value. Without this, a single NaN or inf produced upstream
40+
(layout edge case, stale prop during a reload, etc.) crashes the
41+
process via `CALayerInvalidGeometry`. Clamping to ``default``
42+
converts that into a recoverable visual glitch and lets the
43+
`[set_frame:nan]` / `[set_transform:nan]` tripwire logs surface
44+
where the bad value came from.
45+
"""
46+
try:
47+
f = float(value)
48+
except (TypeError, ValueError):
49+
return default
50+
return f if math.isfinite(f) else default
51+
52+
3353
NSObject = ObjCClass("NSObject")
3454
UIColor = ObjCClass("UIColor")
3555
UIFont = ObjCClass("UIFont")
@@ -350,8 +370,32 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
350370
return
351371
try:
352372
transform = _make_transform(spec)
373+
a = float(transform.a)
374+
b = float(transform.b)
375+
c = float(transform.c)
376+
d = float(transform.d)
377+
tx = float(transform.tx)
378+
ty = float(transform.ty)
379+
if not (
380+
math.isfinite(a)
381+
and math.isfinite(b)
382+
and math.isfinite(c)
383+
and math.isfinite(d)
384+
and math.isfinite(tx)
385+
and math.isfinite(ty)
386+
):
387+
# Tripwire: a NaN/inf transform crashes UIKit. Log
388+
# (rate-limited to avoid 60 Hz spam from stuck Animated
389+
# values) and fall back to identity so the app keeps
390+
# running.
391+
_tripwire_log(
392+
"set_transform:nan",
393+
f"[set_transform:nan] spec={spec!r} -> " f"(a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
394+
)
395+
view.setTransform_((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
396+
return
353397
# rubicon-objc accepts the C struct as a tuple of its fields.
354-
view.setTransform_((transform.a, transform.b, transform.c, transform.d, transform.tx, transform.ty))
398+
view.setTransform_((a, b, c, d, tx, ty))
355399
except Exception:
356400
pass
357401

@@ -450,10 +494,10 @@ def set_frame(self, native_view: Any, x: float, y: float, width: float, height:
450494
if native_view is None:
451495
return
452496
try:
453-
frame_x = float(x)
454-
frame_y = float(y)
455-
frame_w = float(max(0.0, width))
456-
frame_h = float(max(0.0, height))
497+
frame_x = _safe_finite(x, 0.0)
498+
frame_y = _safe_finite(y, 0.0)
499+
frame_w = max(0.0, _safe_finite(width, 0.0))
500+
frame_h = max(0.0, _safe_finite(height, 0.0))
457501
native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
458502
native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
459503
_clamp_view_corner_radius(native_view, frame_w, frame_h)

0 commit comments

Comments
 (0)