Skip to content

feat: Multiprocessing support in packaged desktop apps#6662

Open
ndonkoHenri wants to merge 20 commits into
flet-0.86from
multiprocessing
Open

feat: Multiprocessing support in packaged desktop apps#6662
ndonkoHenri wants to merge 20 commits into
flet-0.86from
multiprocessing

Conversation

@ndonkoHenri

@ndonkoHenri ndonkoHenri commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #4283. Companion PRs: flet-dev/dart-bridge#9 (the headless-interpreter entry points) and flet-dev/serious-python#228 (env fix, dead-strip keep-alives, staging fix).

Root cause

multiprocessing was broken in flet build desktop apps in four independent ways, each fatal on its own.

  • First, spawn/forkserver children re-execute sys.executable, which in a packaged app is the Flutter host binary (measured on macOS/Windows; on Linux bare Py_Initialize PATH-guesses an unrelated python3 instead, which then dies at interpreter init on the bundle's inherited PYTHONHOME pointing at a bytecode-only stdlib of a different version).
  • Second, any non-empty argv routed the re-executed binary into the template's developer mode, so the CPython -c "from multiprocessing.spawn import spawn_main;..." payload was silently swallowed while a full GUI booted, giving one stray window per worker that never computed and never exited.
  • Third, the boot script ran the app via runpy.run_module(run_name="__main__"), which executes in a scratch namespace never installed in sys.modules, so pickling any function defined in the app's main.py failed in the parent with PicklingError: not found as __main__.<name> before a child was even spawned.
  • Fourth, multiprocessing.freeze_support() cannot help (sys.frozen is unset and the child never reached Python anyway), and no python executable ships in the bundle for set_executable() to point at. Python 3.14 raised the stakes on Linux by making forkserver (which uses the same re-exec) the default start method.

Changes

Native runners (build template): all three desktop runners now check argv before any UI/engine initialization — a new macos/Runner/main.swift before NSApplicationMain, the top of wWinMain in windows/runner/main.cpp (wide-char variants, LoadLibraryW of the adjacent dart_bridge.dll), and the top of main in linux/main.cc (dlopen("libdart_bridge.so") through the runner's $ORIGIN/lib RUNPATH, with ${CMAKE_DL_LIBS} added to the link). When dart_bridge >= 1.5.0's serious_python_is_mp_invocation matches, the process runs as a headless interpreter via serious_python_main and exits with its code; resolution is dynamic with graceful fallthrough, so apps built against older dart_bridge launch unchanged (the fix just stays dormant).

Boot script (python.dart): the app module now runs with python -m semantics as the real sys.modules["__main__"] (spec/loader/__file__/__cached__ set, package case handled, clear ImportErrors for loaderless/codeless specs), which fixes parent-side pickling; it is also aliased as sys.modules["__mp_main__"], matching what multiprocessing does in children, so objects defined in the app module survive the child-to-parent pickle direction. sys.executable and sys._base_executable are set to the host binary via a new {host_executable} splice from Platform.resolvedExecutable (required for determinism on Linux, hardening elsewhere), and PYTHONINSPECT is dropped from the inherited environment. All dynamic boot-script values are now spliced via jsonEncode. Because spawn children re-import the main module (as __mp_main__) exactly like plain CPython, the standard if __name__ == "__main__": guard around ft.run() is now required for multiprocessing users — the flet create scaffold models it, and the docs call it out.

Fixes found while verifying on VMs: console.log is now opened as UTF-8 with errors="backslashreplace" (Windows previously used the locale codepage, so the first non-ASCII character an app printed raised UnicodeEncodeError inside the stdout tee), and the dev-mode git describe version fallback passes CREATE_NO_WINDOW on Windows (each multiprocessing child re-imports flet, and in a source checkout every git invocation from the windowless GUI process flashed a console window; released packages never run that path).

Docs and examples: new cookbook page (website/docs/cookbook/multiprocessing.md) covering when to use processes vs threads/async, the platform support matrix, the mandatory rules, how the packaged-app mechanism works, and practical notes (sys.executable points at the app binary by design, freeze_support() is unnecessary but harmless, no fork on Linux under a running Flutter engine), plus four runnable examples under sdk/python/examples/cookbook/multiprocessing/ (parallel sort with progress, worker progress reporting, cancelling a running task, persistent worker process). Changelog entry included. The branch also carries a small unrelated docs commit adding usage-guide links to CLI command docstrings.

Verification

Before/after apps (identical source) were packaged and run on all three desktops. macOS arm64: before shows the classic window-per-worker pile-up; after passes everything. Windows 11 (arm64 host, x64 app): after passes with an approximately 3.5x pool speedup and headless children; this run surfaced the UTF-8 and console-flash fixes above. Ubuntu 22.04 aarch64: before fails with PATH-python children dying at init (bad magic number in 'encodings', the pyc version-mismatch shape) and parent-side PicklingErrors; after passes with sys.executable correctly pointing at the app binary, under both the spawn context and the 3.14 forkserver default. In all after-runs: main.py-defined workers pickle and round-trip, pools complete with worker reuse, the resource tracker performs semaphore cleanup, no extra windows appear, and no processes leak.

Before

Screenshot 2026-07-08 at 12 09 54 AM Screenshot 2026-07-07 at 6 22 54 PM image

After

Screenshot 2026-07-08 at 1 36 25 AM image

Activation and scope

The runner interception activates once the template's serious_python pin reaches 4.3.0 (dart_bridge 1.5.0); that pin bump follows the release chain in flet-dev/dart-bridge#9 and flet-dev/serious-python#228 and is not part of this PR. Development mode (flet run) always worked (a real interpreter runs the script) and is unaffected. Mobile remains unsupported by OS design (no child process spawning on iOS/Android); web is unaffected.

Summary by Sourcery

Add full support for Python multiprocessing in packaged Flet desktop apps by intercepting child process invocations in native runners and running the embedded interpreter headlessly, while updating the Python bootstrap to behave like a real main module and tightening logging and argument handling.

New Features:

  • Support Python multiprocessing (including Process, ProcessPoolExecutor, spawn/forkserver, and resource tracker) in packaged desktop apps built with flet build on macOS, Windows, and Linux by routing child re-executions into a headless embedded interpreter.
  • Provide runnable multiprocessing cookbook examples (parallel sort, worker progress reporting, cancellable task, and persistent worker) demonstrating best practices for using processes from Flet apps.

Bug Fixes:

  • Ensure stdout console logs in packaged apps are always written as UTF-8 with robust error handling to avoid UnicodeEncodeError on Windows.
  • Prevent console windows from flashing on Windows when using the git-based version fallback during development by creating git processes without a visible window.

Enhancements:

  • Change the Python bootstrap script to execute the app module as the real sys.modules['main'] with python -m semantics and alias it as mp_main so multiprocessing can pickle and import main-module objects correctly.
  • Set sys.executable and sys._base_executable to the host app binary and sanitize inherited environment variables (such as PYTHONINSPECT) for predictable behavior in spawned child processes.
  • Improve argument and path handling in the Python bootstrap by splicing dynamic values via JSON encoding, ensuring correct escaping across platforms.
  • Update the default app template to guard ft.run(main) with if name == "main": so new apps follow multiprocessing-safe patterns by default.
  • Link the Linux desktop runner against libdl and adjust macOS/Windows runner entry points to support early interception of multiprocessing child invocations before Flutter or UI initialization.

Documentation:

  • Add a Multiprocessing cookbook page documenting platform support, required multiprocessing patterns, and guidance on using processes safely and efficiently in Flet apps.
  • Update documentation navigation and changelog to reference the new multiprocessing support and examples.

Python's multiprocessing (spawn/forkserver start methods and the resource
tracker) launches child processes by re-executing sys.executable with a
CPython command line — and in a flet-built app sys.executable is the app
binary itself. Each worker therefore booted a full Flutter GUI instance
that treated '-B' as a dev-mode page URL, never ran the spawn payload,
and never exited: one stray window per worker, hung pools, and
"resource_tracker: process died unexpectedly, relaunching" loops.

Add macos/Runner/main.swift as the explicit entry point (AppDelegate
drops @main; the xib still instantiates and wires the delegate): before
NSApplicationMain, it checks argv against dart_bridge >= 1.5.0's
serious_python_is_mp_invocation and, on a match, exits with
serious_python_main's return code — the embedded interpreter services
the child headlessly (Py_BytesMain) with no AppKit/Flutter
initialization. The child resolves the bundled stdlib/site-packages via
the PYTHONHOME/PYTHONPATH the parent already setenv'd process-wide.

Both entry points are resolved via dlsym from the current process image
(dart_bridge is a static archive force-loaded into the host binary), so
apps built against an older dart_bridge still link and launch — they
just don't get the interception.

Verified end-to-end on macOS with an instrumented probe app:
Process+Queue round-trip with a main.py-defined worker, and a
ProcessPoolExecutor run (4 workers, reused across 8 tasks) — headless
children, correct results, ~4x speedup over sequential, no leftover
processes.
Same mechanism as the macOS runner: multiprocessing spawn children (and
the resource tracker) re-execute sys.executable — the app .exe — with a
CPython command line, which previously booted one GUI window per worker
in dev-mode and never ran the spawn payload.

At the top of wWinMain, before any console/COM/window/Flutter work,
MaybeRunPythonChild() parses the wide command line
(CommandLineToArgvW), loads dart_bridge.dll (falling back to
dart_bridge_d.dll for debug-CRT builds), and resolves the wide-char
entry points serious_python_is_mp_invocation_w / serious_python_main_w
(dart_bridge >= 1.5.0; wide variants because decoding wWinMain argv
through the ANSI code page would be lossy — serious_python_main_w wraps
Py_Main). On a match, the process runs as a headless interpreter and
returns its exit code.

Resolution is dynamic (GetProcAddress with graceful fallthrough), so
apps built against an older dart_bridge launch unchanged. Eagerly
loading the DLL costs nothing on the normal startup path — the plugin
loads it moments later anyway.

Note: not yet runtime-verified on Windows (the macOS equivalent is
verified end-to-end); needs a probe run on a Windows machine.
Same mechanism as the macOS/Windows runners, with an extra reason to
care on Linux: Python 3.14 changed the default start method from fork
to forkserver (gh-84559), and the forkserver's server process is
launched through the same sys.executable re-exec as spawn — so Linux
apps that previously happened to work via raw fork break by default on
the bundled 3.14.

At the top of main(), before any GTK/Flutter initialization,
maybe_run_python_child() dlopen()s libdart_bridge.so — resolved through
the runner's $ORIGIN/lib RUNPATH, exactly the way the Dart FFI's
DynamicLibrary.open() finds it later — and resolves
serious_python_is_mp_invocation / serious_python_main (dart_bridge >=
1.5.0). On a match, the process runs as a headless interpreter and
returns its exit code.

Resolution is dynamic with graceful fallthrough, so apps built against
an older dart_bridge launch unchanged. ${CMAKE_DL_LIBS} is added to the
runner link for dlopen/dlsym (a no-op with glibc >= 2.34, where libdl
is merged into libc).

Note: not yet runtime-verified on Linux (the macOS equivalent is
verified end-to-end); needs a probe run on a Linux machine.
…ssing at the host binary

Two independent multiprocessing breakages lived in the boot script, and
either alone was fatal even with the runners' child interception in
place:

1. __main__ identity. runpy.run_module(run_name="__main__") executes the
   app module in a scratch namespace that is never installed in
   sys.modules, so pickling any function defined in the app module
   failed IN THE PARENT with "Can't pickle <fn>: it's not found as
   __main__.<name>" — before a child was ever spawned. Replace it with
   _sp_run_module_as_main(): resolve the module spec (with `python -m`
   package semantics — pkg runs pkg.__main__ — and clear ImportErrors
   for loaderless/codeless specs), build a fresh module with
   __spec__/__file__/__cached__/__loader__/__package__ set, install it
   as sys.modules["__main__"], and exec the module code in its dict.
   Spawn children then re-import the app module as __mp_main__ via the
   standard init_main_from_name path — which is why the documented
   `if __name__ == "__main__":` guard around ft.run() is now mandatory
   for multiprocessing users, exactly as in plain CPython.

   The module is also aliased as sys.modules["__mp_main__"] in the
   parent, matching what multiprocessing does in children: objects
   defined in the app module and pickled by a child carry
   __module__ == "__mp_main__", and without the alias the parent fails
   to unpickle them (ModuleNotFoundError: __mp_main__).

2. Re-exec target. sys.executable / sys._base_executable are set to the
   host binary (new {host_executable} placeholder, filled from
   Platform.resolvedExecutable in native_runtime.dart; JSON string
   literals are valid Python string literals). On macOS/Windows CPython
   already computes the host binary itself, but on Linux bare
   Py_Initialize() PATH-guesses an unrelated "python3" (or none) — this
   makes the target the runner binary, whose argv interception services
   the children, on all three desktops deterministically. Set before
   any user import because multiprocessing snapshots sys.executable at
   import time.

   PYTHONINSPECT is also dropped from the inherited environment: it did
   nothing in the embedded parent, but a real interpreter child would
   stay open in interactive mode after its -c command completed.
   (Defense in depth — serious_python >= 4.3.0 stops setting it and
   dart_bridge's serious_python_main unsets it too.)

Verified end-to-end on macOS together with the runner interception; the
rendered boot script is byte-checked to compile after all placeholder
substitutions.
Start every new project with the standard entry-point guard:

    if __name__ == "__main__":
        ft.run(main)

With multiprocessing now working in packaged desktop apps, spawn
children re-import the app's main module (as __mp_main__) exactly like
plain CPython — an unguarded ft.run() would start a whole new app
session in every worker process. The guard has always been Python best
practice; the scaffold now models it so multiprocessing users don't
learn it the hard way.

Also type the scaffold's event handler parameter
(e: ft.Event[ft.FloatingActionButton]) to model typed event handlers.
JSON string/array literals are valid Python literals, so jsonEncode gives
correct escaping for free. The previous hand-rolled escaping was uneven:
{outLogFilename} doubled backslashes but not quotes, and {argv} escaped
quotes but not backslashes — a Windows-style path in either would corrupt
the generated Python source. {host_executable} already used jsonEncode;
now all three share the one convention. Empty argv still renders as [""]
(CPython always has a sys.argv[0]).

Verified: boot script rendered through the real Dart substitution with
hostile inputs (backslashes, quotes, non-ASCII) compiles as valid Python
and every value round-trips exactly; full flet build macos + the
multiprocessing probe suite passes unchanged.
New cookbook page documenting multiprocessing support in Flet apps, now
that packaged desktop apps service the spawn re-exec protocol:

- when to reach for processes vs async/threads, and the platform/version
  support matrix (desktop-only, Flet >= 0.86.0; iOS/Android/browser
  unsupported);
- the rules that are standard Python multiprocessing discipline but
  MANDATORY in packaged apps: the `if __name__ == "__main__":` guard
  (spawn/forkserver children re-import the main module), importable and
  picklable worker functions (top-level, plain data, no controls/page/
  lambdas), and no GUI access from workers;
- how it works in a `flet build` app: the embedded interpreter, the app
  binary recognizing CPython helper command lines and running them as a
  headless interpreter, and the practical consequences — sys.executable
  points at the app binary by design (don't set_executable() over it),
  freeze_support() is unnecessary-but-harmless, worker stdout isn't the
  app console log, and forcing fork on Linux is unsafe under a running
  Flutter engine;
- a runnable ProcessPoolExecutor example (parallel chunk sorting with
  live progress) driven off the UI thread via page.run_thread.

Listed in the cookbook sidebar after Subprocess.
The boot script opened the console-log tee file without an explicit
encoding, so Windows used the locale codepage (e.g. cp1252) and the
first non-ASCII character an app printed raised UnicodeEncodeError
inside the stdout tee — crashing the printing thread. macOS/Linux never
hit it because their default filesystem encoding is UTF-8 already.

Found while verifying the multiprocessing fix on a Windows VM: the demo
app's status line contains "→" and its print() died mid-benchmark.

errors="backslashreplace" additionally guarantees that even malformed
data (e.g. lone surrogates) degrades to an escaped representation
instead of ever breaking the log.
flet/version.py resolves the package version at import time; in a
source checkout (empty baked flet_version) it falls back to
`git describe`. On Windows, git.exe is a console-subsystem program, so
spawning it from a windowless GUI process pops a visible conhost window.

In a dev-built app this meant one console flash at app start — and,
with multiprocessing now working, one more flash per spawned
worker, since every spawn child re-imports flet and re-runs the
fallback. Traced on a Windows VM via Win32_ProcessStartTrace: each
worker pid parented a git.exe (+ conhost.exe) at click time.

Pass CREATE_NO_WINDOW on Windows so the fallback stays invisible.
Released packages are unaffected either way (CI bakes flet_version, so
the git path never runs).
Enhance docstrings for `debug`, `test`, `create`, `build`, `run`, and `publish` CLI commands with links to detailed usage guides and examples from the Flet documentation.

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

We've reviewed this pull request using the Sourcery rules engine

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploying flet-website-v2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 66f6dec
Status:🚫  Build failed.

View logs

@ndonkoHenri ndonkoHenri linked an issue Jul 8, 2026 that may be closed by this pull request

Copilot AI 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.

Pull request overview

Adds end-to-end support for Python multiprocessing in packaged Flet desktop apps by intercepting multiprocessing child re-execs in the native runners and making the embedded Python bootstrap behave like a real python -m __main__ module (including __mp_main__ aliasing), plus supporting docs/examples and a couple of Windows-specific fixes found during validation.

Changes:

  • Add early “multiprocessing child invocation” detection in macOS/Windows/Linux runners to run the embedded interpreter headlessly instead of launching additional GUI instances.
  • Update the embedded Python bootstrap script to execute the app module as the real sys.modules["__main__"] (and __mp_main__), set sys.executable, sanitize env, and harden console logging.
  • Add cookbook documentation + runnable multiprocessing examples and a changelog entry.

Reviewed changes

Copilot reviewed 26 out of 35 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
website/sidebars.yml Adds cookbook nav entry for the new multiprocessing page.
website/docs/cookbook/multiprocessing.md New cookbook page describing supported platforms, required patterns, and examples.
sdk/python/templates/build/{{cookiecutter.out_dir}}/windows/runner/main.cpp Early argv-based interception for multiprocessing child re-execs via dart_bridge.dll.
sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/main.swift New explicit entry point to intercept multiprocessing child re-execs before AppKit/Flutter.
sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/AppDelegate.swift Removes @main usage to defer the entry point to main.swift.
sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/project.pbxproj Registers main.swift in the macOS Runner Xcode project.
sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/main.cc Early interception for multiprocessing child re-execs via dlopen/dlsym.
sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/CMakeLists.txt Links ${CMAKE_DL_LIBS} to support the new dlopen usage.
sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/python.dart Reworks bootstrap to run app module as real __main__/__mp_main__, set sys.executable, and make console log UTF-8.
sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/native_runtime.dart Uses jsonEncode for safer boot-script value splicing and injects host executable path.
sdk/python/templates/app/app/{{cookiecutter.out_dir}}/src/main.py Adds if __name__ == \"__main__\": guard in scaffold to be multiprocessing-safe.
sdk/python/packages/flet/src/flet/version.py Prevents console-window flashes on Windows when running git describe fallback.
sdk/python/examples/cookbook/multiprocessing/worker_progress.py New example demonstrating worker-to-UI progress reporting via multiprocessing.Queue.
sdk/python/examples/cookbook/multiprocessing/persistent_worker.py New example showing a long-lived worker process using a Pipe.
sdk/python/examples/cookbook/multiprocessing/parallel_sort.py New example demonstrating a ProcessPoolExecutor with UI progress updates.
sdk/python/examples/cookbook/multiprocessing/cancel_task.py New example showing cancellation via Process.terminate().
CHANGELOG.md Adds a release note entry for packaged-desktop multiprocessing support.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CHANGELOG.md Outdated
Comment thread sdk/python/examples/cookbook/multiprocessing/parallel_sort.py
Comment on lines +108 to +115
// JSON literals are valid Python literals, so every dynamic value is
// spliced into the boot script through jsonEncode: it correctly escapes
// backslashes (Windows paths), quotes, and non-ASCII.
var script = pythonScript
.replaceAll("{outLogFilename}", outLogFilename.replaceAll("\\", "\\\\"))
.replaceAll('{outLogFilename}', jsonEncode(outLogFilename))
.replaceAll('{module_name}', moduleName)
.replaceAll('{argv}', argv);
.replaceAll('{argv}', jsonEncode(args.isNotEmpty ? args : [""]))
.replaceAll('{host_executable}', jsonEncode(Platform.resolvedExecutable));
The jsonEncode refactor's comment promised "every dynamic value is
spliced into the boot script through jsonEncode", but {module_name} was
still inserted raw inside hand-written quotes. No live bug — the module
name must already survive the cookiecutter render and find_spec(), so
hostile values can't reach it — but the exception made the comment
wrong and left a footgun for future entry-point changes (e.g. dotted or
path-like module names).

Encode moduleName like the other values; the Python template now calls
_sp_run_module_as_main({module_name}) without surrounding quotes, since
the JSON string literal brings its own.

Addresses the review note on #6662 (discussion_r3544535152). The
rendered boot script is byte-checked to compile after all substitutions.
calc_worker still carried two print() calls from local testing. They
would also be misleading in a packaged app, where worker stdout isn't
connected to the app's console log — which the cookbook page itself
points out.
Moving the v0.85/v0.86 breaking-change pages into v0-85-0/ and v0-86-0/
subfolders (876a006) was a pure rename, so every relative link inside
them started resolving one directory too shallow and the site build
failed its broken-links check ("Docusaurus found broken links!"):

- "](.)" pointed at the (non-existent) version-folder index instead of
  the breaking-changes index -> now ../index.md
- ../release-notes.md -> ../../release-notes.md
- ../../{cli,reference,cookbook}/... -> ../../../{cli,reference,cookbook}/...

All nine moved pages updated; every relative .md link target verified to
exist, and a full local `docusaurus build` passes the broken-links check
again.
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.

Multiprocessing not compatible with Flet

2 participants