Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion client/web/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
158 changes: 91 additions & 67 deletions sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading