Skip to content

Terminate built apps without running process teardown - #6830

Open
FeodorFitsner wants to merge 6 commits into
mainfrom
hard-exit
Open

Terminate built apps without running process teardown#6830
FeodorFitsner wants to merge 6 commits into
mainfrom
hard-exit

Conversation

@FeodorFitsner

@FeodorFitsner FeodorFitsner commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

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. 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:

Thread 0:  -[NSApplication terminate:] -> exit -> __cxa_finalize_ranges
Thread 5:  sp_worker -> ... -> ft2font: pybind11 type_caster<LoadFlags>::load
           far: 0x0 esr: 0x92000006 (Data Abort) byte read Translation fault

Nothing is matplotlib-specific. A second capture of the same crash caught thread 0 inside SkTypefaceCache::~SkTypefaceCache(), Flutter's own Skia static, on the same pass; numpy and Pillow have statics torn down by it too.

Both exit paths were affected:

  • Window close (desktop).
  • sys.exit() (every native platform), which is the worse of the two. Flet's patched sys.exit (flet_exit in python.dart) posts the exit code to Dart and returns rather than raising SystemExit, so the interpreter is fully alive, running on into sp_run_target's return and Py_Finalize(), when Dart's exit() tears the process down. Two concurrent teardowns.

Change

The desktop runners hard-exit: _exit on macOS and Linux, TerminateProcess on Windows. Windows needs the different primitive because _exit() and ExitProcess both still run DLL_PROCESS_DETACH, where the CRT's DllMain runs each DLL's static destructors, which is the thing being avoided.

native_runtime.dart routes the sys.exit path through DartBridge.hardExit, falling back to exit() against a pre-1.9.0 bridge. Exit codes are preserved: terminate: always exits 0 and carries no status, so the macOS hook's _exit(0) is not overriding a requested code; a Python-requested exit does not pass through it.

Bumps serious_python to 4.7.0 and re-pins PYTHON_BUILD_RELEASE_DATE to 20260908, keeping it in sync with serious_python's pythonReleaseDate as that pin's own comment requires. No CPython or Pyodide versions change.

Documented contract

This is a deliberate trade, so it is written down rather than left implicit, in a new How a built app terminates section with pointers from the macOS, Linux and Windows pages:

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.

Verification

macOS, against the published serious_python 4.7.0 / python-build 20260908 / dart_bridge 1.9.0 (clean flet clean rebuild resolving from pub.dev, no path or git overrides), using the matplotlib playground app whose render loop keeps the interpreter thread hot:

Check Result
Baseline, before the fix 2 crashes / 6 window closes
Window close, after 0 / 12, then 0 / 10 on the fully published stack
sys.exit(3), after 0 / 5, exit code 3 preserved every run
SharedPreferences durability, window close survives (0,1,2,3 across runs)
SharedPreferences durability, sys.exit survives (0,1,2,3 across runs)
Pre-1.9.0 native bridge builds, runs, falls back to exit()

At the observed baseline rate, twelve clean closes by chance is roughly 0.8%.

The repro only fires with the window frontmost: unfocused, Flutter throttles rendering and the interpreter sits in asyncio.sleep rather than inside a C call, which is why an initial unactivated run showed zero crashes before the fix as well.

Not verified

Linux and Windows. Both are implemented from the same analysis but were not run; I had no host for either. The Windows branch deserves a real check before it is relied on, since it is the one platform where the obvious primitive (_exit) is the wrong answer: confirm ExitProcess still crashes and TerminateProcess does not.

A narrower fallback than it looks. The soft symbol lookup covers an older native libdart_bridge at runtime. It does not cover an older serious_python Dart package, where DartBridge.hardExit does not exist and the template fails to compile.

Chain

  1. dart-bridge v1.9.0
  2. python-build 20260908
  3. serious_python 4.7.0 (flet-dev/serious-python#248)
  4. this PR

Summary by Sourcery

Make built applications terminate without running unsafe process teardown while documenting the resulting shutdown guarantees and updating supporting runtime dependencies.

Bug Fixes:

  • Prevent built applications from crashing during shutdown when Python execution is still active by bypassing normal process teardown on native platforms.
  • Preserve requested exit codes when applications terminate through Python's sys.exit().
  • Avoid syncing generated build and dependency directories into documentation assets and safely replace read-only copied files.

Enhancements:

  • Document the immediate termination behavior of built apps, including cleanup and buffered-write guarantees, and provide guidance for explicit cleanup before exit.

Build:

  • Upgrade built app templates to serious_python 4.7.0 and pin the Python build release to 20260908.

Documentation:

  • Add termination behavior guidance to the publishing documentation and platform-specific build pages.

Tests:

  • Simplify file-removal error tests by removing obsolete Python-version parameterization.

Chores:

  • Update the web manifest description.

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.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 9, 2026

Copy link
Copy Markdown

Deploying flet-website-v2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: d2b9f18
Status: ✅  Deploy successful!
Preview URL: https://aa20e5ba.flet-website-v2.pages.dev
Branch Preview URL: https://hard-exit.flet-website-v2.pages.dev

View logs

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.
…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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant