| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 9e2b699 commit 796ec70
6 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -196,10 +196,19 @@ class ModuleReloader: | |||
| 196 | 196 | """Reload changed Python modules on device and trigger a re-render. | |
| 197 | 197 | ||
| 198 | 198 | 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. | ||
| 201 | 207 | """ | |
| 202 | 208 | ||
| 209 | + _last_reloaded_version: Optional[str] = None | ||
| 210 | + _reload_lock = threading.Lock() | ||
| 211 | + | ||
| 203 | 212 | @staticmethod | |
| 204 | 213 | def reload_module(module_name: str) -> bool: | |
| 205 | 214 | """Reload a single module by its dotted name. | |
@@ -254,6 +263,139 @@ def reload_modules(module_names: Sequence[str]) -> List[str]: | |||
| 254 | 263 | reloaded.append(module_name) | |
| 255 | 264 | return reloaded | |
| 256 | 265 | ||
| 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 | + | ||
| 257 | 399 | @staticmethod | |
| 258 | 400 | def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: | |
| 259 | 401 | """Convert a file path to a dotted module name. | |
@@ -491,5 +633,13 @@ def reload_from_manifest( | |||
| 491 | 633 | files = manifest.get("files", []) | |
| 492 | 634 | modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else []) | |
| 493 | 635 | ||
| 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 | ||
| 495 | 645 | return version | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -23,10 +23,67 @@ | |||
| 23 | 23 | reconciler with no real native views. | |
| 24 | 24 | """ | |
| 25 | 25 | ||
| 26 | + import math | ||
| 27 | + import sys | ||
| 28 | + import threading | ||
| 29 | + import time | ||
| 26 | 30 | from typing import Any, Dict, Optional, Tuple | |
| 27 | 31 | ||
| 28 | 32 | from .base import ViewHandler | |
| 29 | 33 | ||
| 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 | + | ||
| 30 | 87 | ||
| 31 | 88 | class NativeViewRegistry: | |
| 32 | 89 | """Map element type names to platform-specific view handlers. | |
@@ -136,6 +193,21 @@ def set_frame( | |||
| 136 | 193 | coordinates computed by ``pythonnative.layout`` in points | |
| 137 | 194 | relative to the parent's content origin. | |
| 138 | 195 | """ | |
| 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 | + | ||
| 139 | 211 | handler = self._handlers.get(type_name) | |
| 140 | 212 | if handler is not None: | |
| 141 | 213 | handler.set_frame(native_view, x, y, width, height) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -28,8 +28,28 @@ | |||
| 28 | 28 | ||
| 29 | 29 | from rubicon.objc import SEL, ObjCClass, objc_method | |
| 30 | 30 | ||
| 31 | + from . import _tripwire_log | ||
| 31 | 32 | from .base import ViewHandler, _safe_max, parse_color_int | |
| 32 | 33 | ||
| 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 | + | ||
| 33 | 53 | NSObject = ObjCClass("NSObject") | |
| 34 | 54 | UIColor = ObjCClass("UIColor") | |
| 35 | 55 | UIFont = ObjCClass("UIFont") | |
@@ -350,8 +370,32 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None: | |||
| 350 | 370 | return | |
| 351 | 371 | try: | |
| 352 | 372 | 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 | ||
| 353 | 397 | # 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)) | ||
| 355 | 399 | except Exception: | |
| 356 | 400 | pass | |
| 357 | 401 | ||
@@ -450,10 +494,10 @@ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: | |||
| 450 | 494 | if native_view is None: | |
| 451 | 495 | return | |
| 452 | 496 | 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)) | ||
| 457 | 501 | native_view.setTranslatesAutoresizingMaskIntoConstraints_(True) | |
| 458 | 502 | native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h))) | |
| 459 | 503 | _clamp_view_corner_radius(native_view, frame_w, frame_h) | |
| Back | FazBrowse Home | New Git URL |
0 commit comments