diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b450ce04..176c205096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Improvements +* **Built apps no longer segfault on exit, and now terminate immediately without running process teardown.** An app whose Python code was still running when the process ended could crash with `EXC_BAD_ACCESS` - reported by the OS as an application crash even though the app had finished its work. Your Python code runs on its own thread alongside Flutter, and a normal process exit runs `__cxa_finalize` (`DLL_PROCESS_DETACH` on Windows), destroying the C++ statics inside every loaded C extension module while that thread is still executing inside one of them; the reported case died in `matplotlib`'s `ft2font` looking up a pybind11 type-caster map that had just been destructed, but `numpy`, `Pillow` and Flutter's own Skia statics are torn down by the same pass. **Both** exit paths were affected: closing the window on desktop, and `sys.exit()` on every native platform - the latter is the worse of the two, because Flet's `sys.exit` posts the exit code to the Dart side and *returns*, so the interpreter is still fully alive (running on into `Py_Finalize()`) when Dart tears the process down. The desktop runners now `_exit` (`TerminateProcess` on Windows, since `_exit`/`ExitProcess` still run `DLL_PROCESS_DETACH` and would not help), and the `sys.exit` path routes through a new `serious_python_hard_exit` in `dart_bridge` 1.9.0, reached via `serious_python` 4.7.0 with the bundled python-build snapshot re-pinned to [20260908](https://github.com/flet-dev/python-build/releases/tag/20260908) (no CPython or Pyodide versions change). Exit codes are preserved. The trade-off is now a documented contract: **Python `atexit` handlers, `__del__` finalizers and unflushed buffered writes are not guaranteed to run on exit** - persist what matters before exiting rather than relying on shutdown cleanup. `SharedPreferences` writes are unaffected and were verified to survive both paths. See [How a built app terminates](https://flet.dev/docs/publish#how-a-built-app-terminates) by @FeodorFitsner. * Bulk byte traffic from a web app's `DataChannel`s is no longer structured-cloned on its way to the Python worker. The Dart side has always built a `postMessage` transfer list from the packet's own buffer, but the JavaScript `jsSend` it calls accepted only two parameters, so the third was dropped and every frame was copied. `jsSend` now takes the transfer list and hands it to `postMessage`, making the hand-off zero-copy as intended; each packet is freshly allocated per send and never read back, so detaching the buffer is safe ([#6829](https://github.com/flet-dev/flet/pull/6829)) by @ndonkoHenri. * `flet build web` no longer compiles and ships a dart2wasm build that the page can never load. Flutter emits that output for the `skwasm` renderer only, while the generated `flutter_bootstrap.js` pins `flutterConfig.renderer` to whatever `web_renderer` resolved to — so under the default `canvaskit` the loader skipped it on every page load, leaving `main.dart.wasm` and `main.dart.mjs` (~7.3 MB) in the output as files nothing requests. `--wasm` is now passed only when the resolved renderer is `auto` or `skwasm`, the two cases where the loader can actually select it; `--no-wasm` and `[tool.flet.web] wasm = false` are unchanged ([#6828](https://github.com/flet-dev/flet/pull/6828)) by @ndonkoHenri. * **The Flet web client now shows a static loading logo instead of the breathing and zoom animation.** Dynamic websites can still replace it by supplying `icons/loading-animation.png` in the assets directory. The logo disappears on Flutter's first frame; `[tool.flet.boot_screen]` configures the subsequent startup screen ([#6824](https://github.com/flet-dev/flet/pull/6824)) by @FeodorFitsner. diff --git a/client/web/manifest.json b/client/web/manifest.json index 6ef0e3ef19..d6e450926f 100644 --- a/client/web/manifest.json +++ b/client/web/manifest.json @@ -5,7 +5,7 @@ "display": "standalone", "background_color": "#FFFFFF", "theme_color": "#FF005F", - "description": "Flet - the fastest way to build Flutter apps in Python", + "description": "Build multi-platform apps in Python", "orientation": "natural", "prefer_related_applications": false, "icons": [ diff --git a/sdk/python/examples/extensions/charts/matplotlib_chart/animate/main.py b/sdk/python/examples/extensions/charts/matplotlib_chart/animate/main.py index 24c820419a..7644833805 100644 --- a/sdk/python/examples/extensions/charts/matplotlib_chart/animate/main.py +++ b/sdk/python/examples/extensions/charts/matplotlib_chart/animate/main.py @@ -1,4 +1,29 @@ import logging +import os +import sys + +# Load Matplotlib's `ft2font` extension with RTLD_DEEPBIND, so it binds to the +# FreeType and harfbuzz it was built against. +# +# A packaged Linux app embeds Python in the Flutter process, where GTK has +# already loaded the system libfreetype/libharfbuzz into the global symbol +# namespace. `ft2font` statically links its own copies but also exports their +# symbols, so its internal calls resolve to the system ones instead. The ABI +# mismatch corrupts glyph metrics - a 10pt "-40" measures millions of points +# wide - and every text draw then fails with +# "FT_Render_Glyph ... error 0x62: raster overflow". +# +# Must run before Matplotlib is imported: the extension is cached after its +# first load. No-op wherever the flag is absent (macOS, Windows, Android, iOS, +# Pyodide) - none of those resolve symbols through a flat global namespace. +if hasattr(os, "RTLD_DEEPBIND"): + _dlopenflags = sys.getdlopenflags() + sys.setdlopenflags(_dlopenflags | os.RTLD_DEEPBIND) + try: + from matplotlib import ft2font # noqa: F401 + finally: + sys.setdlopenflags(_dlopenflags) + import matplotlib.pyplot as plt import numpy as np diff --git a/sdk/python/examples/extensions/charts/matplotlib_chart/bar_chart/main.py b/sdk/python/examples/extensions/charts/matplotlib_chart/bar_chart/main.py index edd9252dc8..2ca7c5cb94 100644 --- a/sdk/python/examples/extensions/charts/matplotlib_chart/bar_chart/main.py +++ b/sdk/python/examples/extensions/charts/matplotlib_chart/bar_chart/main.py @@ -1,3 +1,29 @@ +import os +import sys + +# Load Matplotlib's `ft2font` extension with RTLD_DEEPBIND, so it binds to the +# FreeType and harfbuzz it was built against. +# +# A packaged Linux app embeds Python in the Flutter process, where GTK has +# already loaded the system libfreetype/libharfbuzz into the global symbol +# namespace. `ft2font` statically links its own copies but also exports their +# symbols, so its internal calls resolve to the system ones instead. The ABI +# mismatch corrupts glyph metrics - a 10pt "-40" measures millions of points +# wide - and every text draw then fails with +# "FT_Render_Glyph ... error 0x62: raster overflow". +# +# Must run before Matplotlib is imported: the extension is cached after its +# first load. No-op wherever the flag is absent (macOS, Windows, Android, iOS, +# Pyodide) - none of those resolve symbols through a flat global namespace. +if hasattr(os, "RTLD_DEEPBIND"): + _dlopenflags = sys.getdlopenflags() + sys.setdlopenflags(_dlopenflags | os.RTLD_DEEPBIND) + try: + from matplotlib import ft2font # noqa: F401 + finally: + sys.setdlopenflags(_dlopenflags) + + import matplotlib.pyplot as plt import flet as ft diff --git a/sdk/python/examples/extensions/charts/matplotlib_chart/handle_events/main.py b/sdk/python/examples/extensions/charts/matplotlib_chart/handle_events/main.py index 2c0541ae5b..1fcec3fe35 100644 --- a/sdk/python/examples/extensions/charts/matplotlib_chart/handle_events/main.py +++ b/sdk/python/examples/extensions/charts/matplotlib_chart/handle_events/main.py @@ -1,3 +1,29 @@ +import os +import sys + +# Load Matplotlib's `ft2font` extension with RTLD_DEEPBIND, so it binds to the +# FreeType and harfbuzz it was built against. +# +# A packaged Linux app embeds Python in the Flutter process, where GTK has +# already loaded the system libfreetype/libharfbuzz into the global symbol +# namespace. `ft2font` statically links its own copies but also exports their +# symbols, so its internal calls resolve to the system ones instead. The ABI +# mismatch corrupts glyph metrics - a 10pt "-40" measures millions of points +# wide - and every text draw then fails with +# "FT_Render_Glyph ... error 0x62: raster overflow". +# +# Must run before Matplotlib is imported: the extension is cached after its +# first load. No-op wherever the flag is absent (macOS, Windows, Android, iOS, +# Pyodide) - none of those resolve symbols through a flat global namespace. +if hasattr(os, "RTLD_DEEPBIND"): + _dlopenflags = sys.getdlopenflags() + sys.setdlopenflags(_dlopenflags | os.RTLD_DEEPBIND) + try: + from matplotlib import ft2font # noqa: F401 + finally: + sys.setdlopenflags(_dlopenflags) + + import matplotlib.pyplot as plt import numpy as np diff --git a/sdk/python/examples/extensions/charts/matplotlib_chart/three_d/main.py b/sdk/python/examples/extensions/charts/matplotlib_chart/three_d/main.py index 88559b6bce..0dd611a08e 100644 --- a/sdk/python/examples/extensions/charts/matplotlib_chart/three_d/main.py +++ b/sdk/python/examples/extensions/charts/matplotlib_chart/three_d/main.py @@ -1,9 +1,34 @@ import asyncio import logging +import os +import sys import time from collections import deque from dataclasses import dataclass +# Load Matplotlib's `ft2font` extension with RTLD_DEEPBIND, so it binds to the +# FreeType and harfbuzz it was built against. +# +# A packaged Linux app embeds Python in the Flutter process, where GTK has +# already loaded the system libfreetype/libharfbuzz into the global symbol +# namespace. `ft2font` statically links its own copies but also exports their +# symbols, so its internal calls resolve to the system ones instead. The ABI +# mismatch corrupts glyph metrics - a 10pt "-40" measures millions of points +# wide - and every text draw then fails with +# "FT_Render_Glyph ... error 0x62: raster overflow". +# +# Must run before Matplotlib is imported: the extension is cached after its +# first load. No-op wherever the flag is absent (macOS, Windows, Android, iOS, +# Pyodide) - none of those resolve symbols through a flat global namespace. +if hasattr(os, "RTLD_DEEPBIND"): + _dlopenflags = sys.getdlopenflags() + sys.setdlopenflags(_dlopenflags | os.RTLD_DEEPBIND) + try: + from matplotlib import ft2font # noqa: F401 + finally: + sys.setdlopenflags(_dlopenflags) + + import matplotlib.pyplot as plt import flet as ft diff --git a/sdk/python/examples/extensions/charts/matplotlib_chart/toolbar/main.py b/sdk/python/examples/extensions/charts/matplotlib_chart/toolbar/main.py index b27ca768fd..aa4eb1a6a9 100644 --- a/sdk/python/examples/extensions/charts/matplotlib_chart/toolbar/main.py +++ b/sdk/python/examples/extensions/charts/matplotlib_chart/toolbar/main.py @@ -1,3 +1,29 @@ +import os +import sys + +# Load Matplotlib's `ft2font` extension with RTLD_DEEPBIND, so it binds to the +# FreeType and harfbuzz it was built against. +# +# A packaged Linux app embeds Python in the Flutter process, where GTK has +# already loaded the system libfreetype/libharfbuzz into the global symbol +# namespace. `ft2font` statically links its own copies but also exports their +# symbols, so its internal calls resolve to the system ones instead. The ABI +# mismatch corrupts glyph metrics - a 10pt "-40" measures millions of points +# wide - and every text draw then fails with +# "FT_Render_Glyph ... error 0x62: raster overflow". +# +# Must run before Matplotlib is imported: the extension is cached after its +# first load. No-op wherever the flag is absent (macOS, Windows, Android, iOS, +# Pyodide) - none of those resolve symbols through a flat global namespace. +if hasattr(os, "RTLD_DEEPBIND"): + _dlopenflags = sys.getdlopenflags() + sys.setdlopenflags(_dlopenflags | os.RTLD_DEEPBIND) + try: + from matplotlib import ft2font # noqa: F401 + finally: + sys.setdlopenflags(_dlopenflags) + + import matplotlib.pyplot as plt import numpy as np diff --git a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py index bd75bbabf6..95253d3e89 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py @@ -433,79 +433,103 @@ async def _receive_loop(self): """ Consume backend messages and apply canvas/state updates. - The loop handles both binary image frames and JSON control messages - (cursor updates, draw requests, rubber-band overlays, status text, and - toolbar history state). + Message handling is guarded: applying a frame, and Matplotlib's own + rendering (which `send_message` runs synchronously, in this task), + can both raise. Letting that escape would end the loop - nothing + would drain `_receive_queue` again, so the chart would freeze + silently and the traceback would only surface later, as an + unretrieved task exception when the task is garbage-collected. """ while True: is_binary, content = await self._receive_queue.get() - - if is_binary: - # Hand the frame to the client widget — a raw RGBA frame - # (pre-encoded 0x04 packet) or full PNG replaces the - # backbuffer, diff PNG composites onto it. `await` - # here serialises this receive loop on the Dart-side - # frame-applied ack: matplotlib "draw" notifications that - # arrive during the round-trip stay queued in - # `_receive_queue` and are processed after the ack returns, - # instead of being eagerly dropped against a stale - # `_waiting=True` gate. This is the same backpressure shape - # the 0.85 `_invoke_method` round-trip used to provide. - if isinstance(content, tuple) and content[0] == "raw": - logger.debug(f"receive_binary(raw, {len(content[1])})") - await self.mpl_canvas.apply_raw_packet(content[1]) - elif self.__image_mode == "full": - logger.debug(f"receive_binary(full, {len(content)})") - await self.mpl_canvas.apply_full(bytes(content)) - else: - logger.debug(f"receive_binary(diff, {len(content)})") - await self.mpl_canvas.apply_diff(bytes(content)) - self.img_count += 1 + try: + await self._handle_message(is_binary, content) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Error handling Matplotlib backend message") + # A draw that raised never produces the frame that clears + # this gate, so clear it here or no further draw is ever + # requested. self._waiting = False + + async def _handle_message(self, is_binary: bool, content: Any) -> None: + """ + Apply a single message from the Matplotlib backend. + + Handles both binary image frames and JSON control messages (cursor + updates, draw requests, rubber-band overlays, status text, and + toolbar history state). + + Args: + is_binary: Whether `content` is an image frame rather than JSON. + content: Frame payload or JSON control message. + """ + + if is_binary: + # Hand the frame to the client widget — a raw RGBA frame + # (pre-encoded 0x04 packet) or full PNG replaces the + # backbuffer, diff PNG composites onto it. `await` + # here serialises this receive loop on the Dart-side + # frame-applied ack: matplotlib "draw" notifications that + # arrive during the round-trip stay queued in + # `_receive_queue` and are processed after the ack returns, + # instead of being eagerly dropped against a stale + # `_waiting=True` gate. This is the same backpressure shape + # the 0.85 `_invoke_method` round-trip used to provide. + if isinstance(content, tuple) and content[0] == "raw": + logger.debug(f"receive_binary(raw, {len(content[1])})") + await self.mpl_canvas.apply_raw_packet(content[1]) + elif self.__image_mode == "full": + logger.debug(f"receive_binary(full, {len(content)})") + await self.mpl_canvas.apply_full(bytes(content)) else: - logger.debug(f"receive_json({content})") - if content["type"] == "image_mode": - self.__image_mode = content["mode"] - elif content["type"] == "cursor": - self.mouse_cursor = figure_cursors[content["cursor"]] - self.update() - elif content["type"] == "draw" and not self._waiting: - self._waiting = True - self.send_message({"type": "draw"}) - elif content["type"] == "rubberband": - if ( - content["x0"] != -1 - and content["y0"] != -1 - and content["x1"] != -1 - and content["y1"] != -1 - ): - x0 = content["x0"] / self.__dpr - y0 = self._height - content["y0"] / self.__dpr - x1 = content["x1"] / self.__dpr - y1 = self._height - content["y1"] / self.__dpr - self._rubberband.left = min(x0, x1) - self._rubberband.top = min(y0, y1) - self._rubberband.width = abs(x1 - x0) - self._rubberband.height = abs(y1 - y0) - self._rubberband.visible = True - else: - self._rubberband.visible = False - self._rubberband.update() - elif content["type"] == "resize": - self.send_message({"type": "refresh"}) - elif content["type"] == "message": - await self._trigger_event( - "message", {"message": content["message"]} - ) - elif content["type"] == "history_buttons": - await self._trigger_event( - "toolbar_buttons_update", - { - "back_enabled": content["Back"], - "forward_enabled": content["Forward"], - }, - ) + logger.debug(f"receive_binary(diff, {len(content)})") + await self.mpl_canvas.apply_diff(bytes(content)) + self.img_count += 1 + self._waiting = False + else: + logger.debug(f"receive_json({content})") + if content["type"] == "image_mode": + self.__image_mode = content["mode"] + elif content["type"] == "cursor": + self.mouse_cursor = figure_cursors[content["cursor"]] + self.update() + elif content["type"] == "draw" and not self._waiting: + self._waiting = True + self.send_message({"type": "draw"}) + elif content["type"] == "rubberband": + if ( + content["x0"] != -1 + and content["y0"] != -1 + and content["x1"] != -1 + and content["y1"] != -1 + ): + x0 = content["x0"] / self.__dpr + y0 = self._height - content["y0"] / self.__dpr + x1 = content["x1"] / self.__dpr + y1 = self._height - content["y1"] / self.__dpr + self._rubberband.left = min(x0, x1) + self._rubberband.top = min(y0, y1) + self._rubberband.width = abs(x1 - x0) + self._rubberband.height = abs(y1 - y0) + self._rubberband.visible = True + else: + self._rubberband.visible = False + self._rubberband.update() + elif content["type"] == "resize": + self.send_message({"type": "refresh"}) + elif content["type"] == "message": + await self._trigger_event("message", {"message": content["message"]}) + elif content["type"] == "history_buttons": + await self._trigger_event( + "toolbar_buttons_update", + { + "back_enabled": content["Back"], + "forward_enabled": content["Forward"], + }, + ) def send_message(self, message): """Sends a message to the figure's canvas manager.""" diff --git a/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_canvas.py b/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_canvas.py index e8ad840f82..e59e0ec336 100644 --- a/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_canvas.py +++ b/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_canvas.py @@ -70,6 +70,7 @@ def _make_chart_standin(): chart._waiting = False chart.img_count = 0 chart._MatplotlibChart__image_mode = "full" + chart._handle_message = types.MethodType(MatplotlibChart._handle_message, chart) return chart, canvas diff --git a/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_receive_loop.py b/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_receive_loop.py new file mode 100644 index 0000000000..ff9b1ccd3e --- /dev/null +++ b/sdk/python/packages/flet-charts/tests/test_matplotlib_chart_receive_loop.py @@ -0,0 +1,122 @@ +"""Fault isolation in ``MatplotlibChart._receive_loop``. + +The loop is the sole consumer of ``_receive_queue``, and it does more than +shuffle bytes: ``send_message`` hands a "draw" straight to Matplotlib's +canvas manager, so the figure renders *inside* this task. Any render error +— a bad artist, a font that fails to rasterize, a degenerate 3D view — +therefore surfaces here. Unguarded, it would end the loop: no frame would +ever be applied again, the chart would freeze with no visible error, and +the traceback would only appear much later as asyncio's "Task exception was +never retrieved" when the dead task is garbage-collected. + +These tests drive the real ``_receive_loop`` with the failures injected at +the two points that can raise — the canvas apply, and the synchronous +Matplotlib render behind ``send_message``. +""" + +import asyncio +import types + +from flet_charts.matplotlib_chart import MatplotlibChart + + +def _make_chart_standin(): + """Minimal ``self`` for the real ``_receive_loop``: the queue/state + attrs ``build()`` would create, plus a fake canvas and ``send_message`` + so a failure can be injected at either point.""" + chart = types.SimpleNamespace() + chart._receive_queue = asyncio.Queue() + chart._waiting = False + chart.img_count = 0 + chart._MatplotlibChart__image_mode = "full" + chart.applied = [] + chart.sent = [] + + async def apply_raw_packet(packet): + if packet == b"boom": + raise RuntimeError("FT_Render_Glyph failed: raster overflow") + chart.applied.append(packet) + + chart.mpl_canvas = types.SimpleNamespace(apply_raw_packet=apply_raw_packet) + + def send_message(message): + chart.sent.append(message) + if chart.render_fails: + raise RuntimeError("FT_Render_Glyph failed: raster overflow") + + chart.send_message = send_message + chart.render_fails = False + chart._handle_message = types.MethodType(MatplotlibChart._handle_message, chart) + return chart + + +def _feed_frame(chart, payload=b"frame"): + chart._receive_queue.put_nowait((True, ("raw", payload))) + + +async def _drain(chart): + """Run the real loop long enough to consume the queue, then stop it. + + Returns whether the loop was still alive at that point — a loop killed + by an exception is `done()` before anything cancels it. + """ + task = asyncio.create_task(MatplotlibChart._receive_loop(chart)) + await asyncio.sleep(0.05) + alive = not task.done() + task.cancel() + return alive + + +def test_failing_render_does_not_kill_the_loop(): + """A "draw" whose render raises must not stop frames that follow.""" + + async def scenario(): + chart = _make_chart_standin() + chart.render_fails = True + chart._receive_queue.put_nowait((False, {"type": "draw"})) + chart.render_fails = False + _feed_frame(chart) + alive = await _drain(chart) + return chart.applied, chart._waiting, alive + + applied, waiting, alive = asyncio.run(scenario()) + assert applied == [b"frame"], "loop must keep applying frames after a render error" + assert not waiting, "a draw that raised must not leave the draw gate latched" + assert alive, "the loop task must still be running" + + +def test_failing_frame_apply_does_not_kill_the_loop(): + """An error while applying one frame must not lose the next one.""" + + async def scenario(): + chart = _make_chart_standin() + _feed_frame(chart, b"boom") + _feed_frame(chart, b"good") + await _drain(chart) + return chart.applied + + assert asyncio.run(scenario()) == [b"good"] + + +def test_draw_gate_reopens_after_a_render_error(): + """``_waiting`` gates draw requests; a failed draw never produces the + frame that clears it, so the loop must clear it itself — otherwise no + further draw is ever requested and the chart stays frozen.""" + + async def scenario(): + chart = _make_chart_standin() + chart.render_fails = True + chart._receive_queue.put_nowait((False, {"type": "draw"})) + await _drain(chart) + first = list(chart.sent) + + chart.render_fails = False + chart._receive_queue.put_nowait((False, {"type": "draw"})) + await _drain(chart) + return first, chart.sent + + first, sent = asyncio.run(scenario()) + assert first == [{"type": "draw"}] + assert sent == [{"type": "draw"}, {"type": "draw"}], ( + "a later draw notification must still reach Matplotlib" + ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py index 9d787b9737..86b2cc6dff 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py @@ -28,7 +28,7 @@ # python-build release this flet pins. Keep in sync with serious_python's # `pythonReleaseDate` (lib/src/python_versions.dart) — both should track the # same python-build release. -PYTHON_BUILD_RELEASE_DATE = "20260902" +PYTHON_BUILD_RELEASE_DATE = "20260908" RELEASE_DATE_ENV = "FLET_PYTHON_BUILD_RELEASE_DATE" MANIFEST_PATH_ENV = "FLET_PYTHON_BUILD_MANIFEST" diff --git a/sdk/python/packages/flet/tests/test_files.py b/sdk/python/packages/flet/tests/test_files.py index 6b457c83e1..a67f5e9d2f 100644 --- a/sdk/python/packages/flet/tests/test_files.py +++ b/sdk/python/packages/flet/tests/test_files.py @@ -31,8 +31,7 @@ def test_rmtree_nonexistent_directory(): rmtree(nonexistent) -@pytest.mark.parametrize("py_version", [(3, 11, 0), (3, 12, 0)]) -def test_rmtree_raises_permission_error_when_deletion_fails(py_version): +def test_rmtree_raises_permission_error_when_deletion_fails(): temp_dir = tempfile.mkdtemp() test_file = os.path.join(temp_dir, "locked.txt") with open(test_file, "w") as fp: @@ -40,7 +39,6 @@ def test_rmtree_raises_permission_error_when_deletion_fails(py_version): try: with ( - patch("sys.version_info", py_version), patch("os.unlink", side_effect=PermissionError("File locked")), pytest.raises(PermissionError), ): @@ -50,18 +48,14 @@ def test_rmtree_raises_permission_error_when_deletion_fails(py_version): rmtree(temp_dir, ignore_errors=True) -@pytest.mark.parametrize("py_version", [(3, 11, 0), (3, 12, 0)]) -def test_rmtree_ignore_errors_when_deletion_fails(py_version): +def test_rmtree_ignore_errors_when_deletion_fails(): temp_dir = tempfile.mkdtemp() test_file = os.path.join(temp_dir, "locked.txt") with open(test_file, "w") as fp: fp.write("locked") try: - with ( - patch("sys.version_info", py_version), - patch("os.unlink", side_effect=PermissionError("File locked")), - ): + with patch("os.unlink", side_effect=PermissionError("File locked")): rmtree(temp_dir, ignore_errors=True) finally: if os.path.exists(temp_dir): diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/native_runtime.dart b/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/native_runtime.dart index 6455be8915..35faa675d2 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/native_runtime.dart +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/native_runtime.dart @@ -141,7 +141,12 @@ Future runPython({ // (in python.dart) to encode `code` as raw UTF-8 bytes and post them via // `dart_bridge.send_bytes(FLET_DART_BRIDGE_EXIT_PORT, ...)`. We don't need // a streaming codec here — the channel only ever carries a single short - // payload, then Python tears down. + // payload. + // + // Note the patched `flet_exit` posts the code and *returns* - it does not + // raise SystemExit. So the interpreter is still very much alive when this + // fires, running on into `sp_run_target`'s return and `Py_Finalize()`. That + // is why the exit below must not run the normal C teardown; see onExitSignal. StringBuffer pythonExitBuf = StringBuffer(); StreamSubscription? exitSub; @@ -155,6 +160,14 @@ Future runPython({ } completer.complete(out); } else { + // `dart:io`'s exit() runs the normal C teardown, destroying the C++ + // statics inside every loaded CPython extension module while the + // interpreter thread is still running (see the note above) - the same + // crash the native runners avoid on window close. hardExit skips it. + // + // Falls through to exit() against a pre-1.9.0 libdart_bridge, which does + // not export the symbol; that is the pre-existing behaviour, race and all. + DartBridge.instance.hardExit(exitCode); exit(exitCode); } } diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/main.cc b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/main.cc index f89420aa49..538a95d1d0 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/main.cc +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/main.cc @@ -1,6 +1,7 @@ #include "my_application.h" #include +#include // Python's multiprocessing spawn/forkserver paths, including the resource // tracker, create child processes by re-executing sys.executable with a @@ -48,5 +49,12 @@ int main(int argc, char** argv) { } g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); + int status = g_application_run(G_APPLICATION(app), argc, argv); + + // Returning from main() runs __cxa_finalize, which destroys the C++ statics + // inside every loaded CPython extension module. The embedded interpreter + // runs on a detached thread that is very likely still executing, and faults + // on whichever destroyed static it touches next. _exit skips the teardown; + // see the termination contract in docs/publish/linux.md. + _exit(status); } diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/AppDelegate.swift b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/AppDelegate.swift index 1c86ba9ccc..25a0506116 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/AppDelegate.swift +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/AppDelegate.swift @@ -11,4 +11,25 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + // NSApplication posts NSApplicationWillTerminateNotification immediately + // before calling exit(), which runs __cxa_finalize_ranges and destroys the + // C++ statics inside every loaded CPython extension module - pybind11 type + // caster maps in matplotlib's ft2font, numpy's internals, and so on. The + // embedded interpreter runs on a detached thread that is very likely still + // executing at this point (an app with a render loop essentially always is), + // and faults on whichever destroyed static it touches next. + // + // _exit skips the teardown entirely. Nothing here needs to be cleaned up + // that the kernel will not reclaim, and see the termination contract in + // docs/publish/macos.md: an exiting app makes no promise to run atexit + // handlers or flush pending buffered writes. + // + // 0 is not overriding a requested status: -[NSApplication terminate:] always + // exits 0, and the notification carries no exit code. A Python-requested exit + // does not come through here - it goes through native_runtime.dart, which + // hard-exits with the real code. + override func applicationWillTerminate(_ notification: Notification) { + _exit(0) + } } diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml b/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml index 37917bbbbf..8d906120a0 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flet: path: ../../../../../packages/flet - serious_python: 4.6.0 + serious_python: 4.7.0 # MsgPack codec used by the dart_bridge FletBackendChannel implementation # in lib/main.dart — matches the wire format flet's existing socket diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/windows/runner/main.cpp b/sdk/python/templates/build/{{cookiecutter.out_dir}}/windows/runner/main.cpp index 67a07c56f9..ccbf061bbd 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/windows/runner/main.cpp +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/windows/runner/main.cpp @@ -94,6 +94,17 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, ::DispatchMessage(&msg); } - ::CoUninitialize(); - return EXIT_SUCCESS; + // Returning from wWinMain hands control to the CRT's exit path, which runs + // DLL_PROCESS_DETACH for every loaded DLL - and that is where the CRT's + // DllMain runs each DLL's C++ static destructors, including those inside the + // CPython extension modules. The embedded interpreter runs on a detached + // thread that is very likely still executing, and faults on whichever + // destroyed static it touches next. + // + // _exit() and ExitProcess() both still run DLL_PROCESS_DETACH, so neither + // helps; TerminateProcess is the only primitive that skips it. COM + // uninitialization is skipped along with everything else - the process is + // going away, and see the termination contract in docs/publish/windows.md. + ::TerminateProcess(::GetCurrentProcess(), 0); + return EXIT_SUCCESS; // not reached } diff --git a/tools/crocodocs/src/crocodocs/assets.py b/tools/crocodocs/src/crocodocs/assets.py index ee126c487b..b06f749d9c 100644 --- a/tools/crocodocs/src/crocodocs/assets.py +++ b/tools/crocodocs/src/crocodocs/assets.py @@ -5,6 +5,22 @@ import shutil from pathlib import Path +# Directory names never worth syncing. These hold generated output, not authored +# assets, and an example that has been built locally carries a lot of it: a +# single `flet build ios` leaves ~1.4 GB under `build/`, including Swift Package +# Manager checkouts whose images match the include_exts filter and would +# otherwise be copied into the docs site. +SKIP_DIRS = frozenset( + { + ".dart_tool", + ".git", + ".venv", + "__pycache__", + "build", + "node_modules", + } +) + def bulk_copy_assets( source_root: Path, @@ -13,6 +29,9 @@ def bulk_copy_assets( ) -> int: """Copy all matching files from *source_root* to *dest_root*. + Directories named in :data:`SKIP_DIRS` are pruned, so build output under a + source tree is never synced. + Returns the number of files copied. """ copied = 0 @@ -22,8 +41,14 @@ def bulk_copy_assets( if include_exts and source_path.suffix.lower() not in include_exts: continue relative = source_path.relative_to(source_root) + if SKIP_DIRS.intersection(relative.parts[:-1]): + continue dest_path = dest_root / relative dest_path.parent.mkdir(parents=True, exist_ok=True) + # Replace rather than write in place: copy2 preserves the source mode, + # so a read-only source (SPM checkouts ship 0444) leaves a read-only + # destination that the next run cannot reopen for writing. + dest_path.unlink(missing_ok=True) shutil.copy2(source_path, dest_path) copied += 1 return copied diff --git a/website/docs/publish/index.md b/website/docs/publish/index.md index caa5d8533e..c103ce0d1c 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -191,6 +191,55 @@ When you run `flet build `, the pipeline is: executable or installable package. 5. Copy build outputs from Step 4 into the [output directory](#output-directory). +## How a built app terminates + +A built Flet app terminates **immediately**. Python `atexit` handlers, `__del__` finalizers, +C++ static destructors, and buffered writes that have not yet reached the operating system +are **not** guaranteed to run. Persist anything that matters before you exit, rather than +relying on cleanup at shutdown. + +This applies both when the user closes the app (desktop) and when your code calls +`sys.exit()` (every platform). + +The reason is that your Python code runs on its own thread alongside Flutter. A normal +process exit runs the teardown of every loaded library - including the C extension modules +imported by packages like `matplotlib`, `numpy` and `Pillow` - while that thread may still +be executing inside one of them. The result was a segfault on exit, reported as a crash by +the operating system even though the app had finished its work. Skipping the teardown +removes the failure entirely, at the cost of the guarantees above. + +If you need cleanup to run, do it explicitly before exiting. When your own code ends the +app, finish your work first: + +```python +async def quit(e): + await save_my_state() # finish your own work first + sys.exit(0) +``` + +On desktop the user can also close the window, which the operating system initiates - your +code is never asked. To get a chance to run first, intercept the close signal with +[`Window.prevent_close`][flet.Window.prevent_close] and destroy the window yourself once +you are done: + +```python +import flet as ft + + +async def main(page: ft.Page): + async def handle_window_event(e: ft.WindowEvent): + if e.type == ft.WindowEventType.CLOSE: + await save_my_state() # runs before the process goes away + await page.window.destroy() + + page.window.prevent_close = True + page.window.on_event = handle_window_event +``` + +Without `prevent_close`, the window close goes straight through to process termination and +nothing of yours runs. Keep the handler quick: it holds up the app's exit, and the OS may +lose patience with an app that takes too long to quit. + ## Configuration options :::note[Placeholders] diff --git a/website/docs/publish/linux.md b/website/docs/publish/linux.md index 3e683da524..4c45d3635f 100644 --- a/website/docs/publish/linux.md +++ b/website/docs/publish/linux.md @@ -179,6 +179,12 @@ categories = ["Game", "ArcadeGame"] +## App termination + +Closing the window terminates the app immediately, without running Python `atexit` +handlers, C++ static destructors, or flushing writes that have not yet reached the +operating system. See [How a built app terminates](index.md#how-a-built-app-terminates). + ## Distributing `flet build linux` leaves a **relocatable bundle directory** — an executable diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 71346d584e..56232f49a7 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -381,6 +381,12 @@ will be translated accordingly into this: ``` +## App termination + +Closing the window terminates the app immediately, without running Python `atexit` +handlers, C++ static destructors, or flushing writes that have not yet reached the +operating system. See [How a built app terminates](index.md#how-a-built-app-terminates). + ## Reading your app's output Flet redirects the app's `stdout` and `stderr` — everything it `print()`s, plus any diff --git a/website/docs/publish/windows.md b/website/docs/publish/windows.md index 024242f188..33e1568437 100644 --- a/website/docs/publish/windows.md +++ b/website/docs/publish/windows.md @@ -34,6 +34,12 @@ This command can be run on **Windows only**. Builds a Windows application. +## App termination + +Closing the window terminates the app immediately, without running Python `atexit` +handlers, C++ static destructors, or flushing writes that have not yet reached the +operating system. See [How a built app terminates](index.md#how-a-built-app-terminates). + ## Troubleshooting | Symptom | Cause and fix |