From 5734c75056a9072b1310bb220099b2d28d9c55a4 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 10:15:19 -0700 Subject: [PATCH 1/7] fix(build): terminate built apps without running process teardown A built 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. Python 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; numpy, Pillow and Flutter's own Skia statics are torn down by the same pass. Both exit paths were affected: * Window close on desktop. The macOS and Linux runners now _exit, and the Windows runner uses TerminateProcess, since _exit and ExitProcess both still run DLL_PROCESS_DETACH and would not help. * sys.exit on every native platform, which is the worse of the two: Flet's patched sys.exit posts the exit code to Dart and returns rather than raising SystemExit, so the interpreter is fully alive and running on into Py_Finalize when Dart tears the process down. This now routes through DartBridge.hardExit, falling back to exit() against an older bridge. Exit codes are preserved. The trade-off is documented as a contract: atexit handlers, __del__ finalizers and unflushed buffered writes are not guaranteed to run on exit. Bumps serious_python to 4.7.0 and re-pins python-build to 20260908, keeping PYTHON_BUILD_RELEASE_DATE in sync with serious_python's pythonReleaseDate as that pin requires. No CPython or Pyodide versions change. Verified on macOS against the published packages: baseline 2 crashes in 6 window closes, 0 in 10 after; sys.exit(3) preserved across 5 runs with no crashes; SharedPreferences writes survive both exit paths. --- CHANGELOG.md | 1 + .../src/flet_cli/utils/python_versions.py | 2 +- .../lib/native_runtime.dart | 15 ++++++++++- .../{{cookiecutter.out_dir}}/linux/main.cc | 10 +++++++- .../macos/Runner/AppDelegate.swift | 21 ++++++++++++++++ .../{{cookiecutter.out_dir}}/pubspec.yaml | 2 +- .../windows/runner/main.cpp | 15 +++++++++-- website/docs/publish/index.md | 25 +++++++++++++++++++ website/docs/publish/linux.md | 6 +++++ website/docs/publish/macos.md | 6 +++++ website/docs/publish/windows.md | 6 +++++ 11 files changed, 103 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f845b81e..29ea6267b7 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. * **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. * `flet run --web` no longer logs `assets_dir does not exist: ...` for an app that simply has no assets directory. `assets_dir` defaults to `"assets"` whether or not the app has one, so the resolved path was handed downstream regardless — the desktop view ignored it silently while the web server complained, which is why the same app warned only with `--web`, about a directory the user never asked for. A resolved path that does not exist is now dropped at the source, making both views behave the same. A path set explicitly through `FLET_ASSETS_DIR`, or passed straight to `FletStaticFiles` when mounting on FastAPI, is always deliberate and still reports a missing directory by @FeodorFitsner. * **New Flet logo, and a fix for Android launcher icons that were silently being clipped.** Every icon in the repo is now derived from a single master by a committed generator (`.github/scripts/generate_brand_assets.py`) instead of being maintained by hand across three pipelines. That surfaced a real defect in what `flet build` shipped: the default `icon.png` framed the mark at 72.9% of the canvas, outside the 66.7% that Android guarantees is visible in an adaptive icon's foreground layer — so **every `flet build apk` produced a launcher icon cropped under circular masks**, and the Android 12 splash, which clips to a circle of the same ratio, lost its edges too. The default is now framed at 60% and both render whole. Two knock-on changes worth knowing about: `flet create` no longer ships `assets/splash_android.png`, because `icon.png` now fits the splash circle on its own and the extra file silently won the fallback chain — replacing only `icon.png` gave you your own launcher icon but kept the Flet logo on the splash; and the default PWA `theme_color` moved from `#0175C2` (Flutter's stock blue, which matched no Flet brand colour) to `#FF005F`, still overridable with `--pwa-theme-color` or `[tool.flet.web].pwa_theme_color` ([#6816](https://github.com/flet-dev/flet/pull/6816)) by @FeodorFitsner. 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/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/website/docs/publish/index.md b/website/docs/publish/index.md index caa5d8533e..295aeff387 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -191,6 +191,31 @@ 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: + +```python +async def quit(e): + await save_my_state() # finish your own work first + sys.exit(0) +``` + ## 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 206f3fd57b..0a0e7e6d9b 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -373,6 +373,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 | From 1809923a9dbc398704e4585aadeacddb95baa7b8 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 10:37:49 -0700 Subject: [PATCH 2/7] docs(publish): show how to run cleanup on window close The termination section told readers to persist state before exiting but only showed the sys.exit path, which is the one the app controls. On desktop the user closes the window and the OS initiates termination, so without a hook none of their code runs at all. Adds the prevent_close + WindowEventType.CLOSE handler as the counterpart, and notes that the handler holds up the app's exit so it should stay quick. Verified against a built macOS app with the hard exit in place: the handler runs on every close, since prevent_close intercepts well before applicationWillTerminate. --- website/docs/publish/index.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/website/docs/publish/index.md b/website/docs/publish/index.md index 295aeff387..c103ce0d1c 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -208,7 +208,8 @@ be executing inside one of them. The result was a segfault on exit, reported as 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: +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): @@ -216,6 +217,29 @@ async def quit(e): 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] From bb86f7764a1be67bf14d8395cfb6c6598dd465f1 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 10:56:30 -0700 Subject: [PATCH 3/7] fix(crocodocs): do not sync build output, and allow overwriting read-only assets Asset syncing walked source trees with a bare rglob filtered only by extension, so an example app that had been built locally leaked its build output into the docs site: a single `flet build ios` under sdk/python/examples leaves ~1.4 GB in build/, and 192 of its PNGs matched the filter, including Swift Package Manager checkouts. Those checkouts ship read-only (0444), and copy2 preserves the source mode, so the copies landed read-only too. That made the sync succeed once and fail on every later run, because copyfile opens the destination for writing: PermissionError: [Errno 13] Permission denied: .../rive_animations/build/flutter/build/ios/SourcePackages/checkouts/ DKCamera/DKCamera/DKCameraResource.bundle/Images/camera_cancel.png Prunes generated directories (build, .dart_tool, node_modules, .venv, .git, __pycache__) and unlinks the destination before copying so a read-only file is replaced rather than reopened. Locally this removed 227 build-artifact files from website/static/docs/ examples and took it from 171M to 84M, with `yarn build` then repeatable. --- tools/crocodocs/src/crocodocs/assets.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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 From 2a9f7f0fce705093ef5450c6b8173e5282ceff7e Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 11:04:27 -0700 Subject: [PATCH 4/7] test(files): stop patching sys.version_info in the rmtree tests The tests parametrized over (3, 11, 0) and (3, 12, 0) and patched sys.version_info to steer rmtree's branch, which picks shutil.rmtree's callback kwarg: onexc on 3.12+, onerror below it. Patching only steers the branch; the shutil.rmtree underneath is still the running interpreter's, and it only accepts onexc from 3.12 on. So the 3.12 parameter failed on 3.10 and 3.11 with TypeError: rmtree() got an unexpected keyword argument 'onexc' which was a limitation of the test setup, not a defect in rmtree, whose version guard is correct. Both tests assert interpreter-agnostic behaviour: that a failing deletion propagates PermissionError, and that ignore_errors swallows it. Neither needs a spoofed version, so the parametrization and the patch are dropped and each test runs against the real interpreter. Branch coverage is unchanged in aggregate, since CI spans 3.10 through 3.14: onerror is exercised on 3.10 and 3.11, onexc on 3.12+, each on an interpreter that can actually run it. --- sdk/python/packages/flet/tests/test_files.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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): From d2b9f183cd30fd3ff4e2d191fedc4c2bd085db4b Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 11:46:15 -0700 Subject: [PATCH 5/7] fix(web): update the outdated client manifest description The PWA manifest still described Flet as "the fastest way to build Flutter apps in Python". It was the last place in the repo using that wording, and it leads with Flutter rather than what Flet offers. Replaced with the tagline the site and README already use. --- client/web/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [ From e24c2acfc9b71fea838e830d7e88025f6d77b553 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 19:33:33 -0700 Subject: [PATCH 6/7] fix(charts): keep the Matplotlib receive loop alive on render errors `MatplotlibChart._receive_loop` is the sole consumer of `_receive_queue`, and it does more than move bytes: `send_message` hands a "draw" straight to Matplotlib's canvas manager, so the figure renders inside that task. Any render error therefore surfaced in the loop and killed it - no frame was ever applied again, the chart froze with nothing shown to the user, and the traceback only appeared much later as asyncio's "Task exception was never retrieved" when the dead task was garbage-collected. Move the body into `_handle_message` and guard each message: log the error and carry on. `_waiting` is cleared too, since a draw that raised never produces the frame that clears that gate, and without the reset no further draw would be requested even once the loop survived. Found while diagnosing a `FT_Render_Glyph ... raster overflow` raised from `Axes3D` tick labels, where the frozen chart gave no clue what had failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FVAHjjUvjd7owASHSzbXDE --- .../src/flet_charts/matplotlib_chart.py | 158 ++++++++++-------- .../tests/test_matplotlib_chart_canvas.py | 1 + .../test_matplotlib_chart_receive_loop.py | 122 ++++++++++++++ 3 files changed, 214 insertions(+), 67 deletions(-) create mode 100644 sdk/python/packages/flet-charts/tests/test_matplotlib_chart_receive_loop.py 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" + ) From 9b66db6e4451f7b7bf6238786c6034ddd518b970 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 9 Sep 2026 19:33:46 -0700 Subject: [PATCH 7/7] fix(examples): load Matplotlib's ft2font with RTLD_DEEPBIND A packaged Linux app embeds Python in the Flutter process, where GTK has already pulled the system libfreetype/libharfbuzz into the global symbol namespace (libflutter_linux_gtk.so links them; it exports no FT_/hb_ symbols of its own). Matplotlib's `ft2font` statically links its own copies of both but also exports their symbols, so its internal calls resolve to the system versions instead - `__freetype_version__` reports 2.13.2 rather than the 2.14.3 it was built against. The mismatched ABI yields garbage glyph metrics: a 10pt "-40" measures 5642458 x 24440358 points instead of 30 x 10. Those feed the text layout offsets, putting tick labels millions of pixels off-canvas, and every text draw then fails with "FT_Render_Glyph ... error 0x62: raster overflow". Only text is affected - panes and gridlines are vector paths - so a 3D chart renders as an empty rotating grid. RTLD_DEEPBIND makes the extension prefer its own symbols. It has to run before Matplotlib is imported, since the extension is cached after its first load, and these examples import `matplotlib.pyplot` ahead of `flet_charts` - so the guard belongs here rather than in the library. No-op wherever the flag is absent (macOS, Windows, Android, iOS, Pyodide); none of those resolve symbols through a flat global namespace, so none are affected. Verified by preloading the system libraries with RTLD_GLOBAL and importing each example: all five report freetype 2.14.3 and metrics (30.0, 10.0, 0.0), where they previously reported 2.13.2 and garbage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FVAHjjUvjd7owASHSzbXDE --- .../charts/matplotlib_chart/animate/main.py | 25 ++++++++++++++++++ .../charts/matplotlib_chart/bar_chart/main.py | 26 +++++++++++++++++++ .../matplotlib_chart/handle_events/main.py | 26 +++++++++++++++++++ .../charts/matplotlib_chart/three_d/main.py | 25 ++++++++++++++++++ .../charts/matplotlib_chart/toolbar/main.py | 26 +++++++++++++++++++ 5 files changed, 128 insertions(+) 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