Skip to content

Multiprocessing child interception: run the host binary as a headless interpreter for spawn/forkserver/resource-tracker re-execs#9

Merged
FeodorFitsner merged 6 commits into
mainfrom
multiprocessing
Jul 8, 2026
Merged

Multiprocessing child interception: run the host binary as a headless interpreter for spawn/forkserver/resource-tracker re-execs#9
FeodorFitsner merged 6 commits into
mainfrom
multiprocessing

Conversation

@ndonkoHenri

@ndonkoHenri ndonkoHenri commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Part of flet-dev/flet#4283. Companion PRs: flet-dev/serious-python#228 (consumes these exports and bumps the pin) and flet-dev/flet#6662 (build-template runners that call them).

Problem

Python's multiprocessing (the spawn and forkserver start methods, plus the resource tracker) launches helper processes by re-executing sys.executable with a CPython command line. In apps embedding Python through this library, sys.executable resolves to the host app executable, so every helper re-launched the host GUI instead of running the multiprocessing protocol: one stray window per worker, tasks that never executed, hung joins, and resource_tracker: process died unexpectedly, relaunching loops. This has been broken since 2024 and is the core of flet-dev/flet#4283. Notably, Python 3.14 made forkserver the default start method on Linux, so packaged Linux apps now hit the broken re-exec path even for code that never selects a start method.

Solution

Two new stable-ABI exports in serious_python_run.c let the host binary double as a plain Python interpreter for exactly those helper invocations:

  • serious_python_is_mp_invocation(argc, argv) matches --multiprocessing-fork anywhere in argv, or a -c payload starting with from multiprocessing. or import sys; from multiprocessing., covering spawn workers, the resource tracker, and the forkserver on CPython 3.12–3.14. The match is deliberately prefix-based because the exact helper command lines are CPython implementation details that shift across minor releases, while the helpers stay under multiprocessing.*.
  • serious_python_main(argc, argv) runs a shared preflight (scrub inherited PYTHONINSPECT, which would otherwise hold a real interpreter child open in interactive mode after its -c command; verify PYTHONHOME/PYTHONPATH are present, which the parent's serious_python_run already setenv's process-wide so children inherit the embedded stdlib and site-packages; register the in-binary dart_bridge module on the inittab so inherited dart_bridge-aware imports fail soft), then delegates to Py_BytesMain for the full standard interpreter lifecycle.

Windows additionally gets wide-char variants serious_python_is_mp_invocation_w / serious_python_main_w (delegating to Py_Main), since wWinMain argv is wchar_t** and decoding through the ANSI code page in the narrow versions would be lossy.

Host contract (documented in the README): call the detector first thing in main / wWinMain / main.swift, before any UI or engine initialization, and exit with serious_python_main's return code on a match. The flet build template runners do exactly this, resolving the exports via dlsym/GetProcAddress with graceful fallthrough so hosts built against older dart_bridge keep launching unchanged.

The EXPORT macro on non-Windows now adds __attribute__((used)): on Mach-O the archive is statically linked into the host app and several exports are referenced only via dlsym/Dart FFI, and without the no-dead-strip marker the host link's -dead_strip discarded any export lacking a static call site.

CI fix: glibc floor of Linux artifacts

Artifacts are currently built directly on ubuntu-24.04 runners (glibc 2.39), so they bind modern symbol versions — e.g. strtoll__isoc23_strtoll@GLIBC_2.38. Since the flet build template links libdart_bridge.so straight into the app executable, flet build linux fails its final link on anything older (reproduced on Ubuntu 22.04 LTS: clang: error: linker command failed). This affects the released 1.4.1 Linux artifacts today, independent of new features — worth re-spinning with the next tag.

Fix

Build inside a debian:10 job container on modern runners — the same pattern (and lowest tier) as flet's own build_linux matrix (manylinux_2_28). dart-bridge ships one .so per arch, so it must clear the lowest supported tier: a glibc 2.28 floor covers Debian 10/11/12, Ubuntu 20.04/22.04/24.04, and RHEL 8+ with a single artifact.

Details (each step is commented in the workflow):

  • Debian 10 is EOL → apt pointed at archive.debian.org, Check-Valid-Until off; git/ca-certs installed before actions/checkout (the action runs inside the container).
  • Debian 10 ships CMake 3.13 (< our 3.15 minimum) → current CMake from Kitware's official binaries, which run on old glibc.
  • New assertion step: the job fails if the artifact references any glibc symbol newer than 2.28 — the floor is an enforced contract, so a future container/runner bump can't silently raise it.

Verification

Verified end-to-end inside flet build apps on macOS (arm64), Windows 11 (arm64 host, x64 app), and Ubuntu 22.04 (aarch64): a Process+Queue round-trip with a worker defined in the app's main.py, and a ProcessPoolExecutor benchmark with worker reuse, all completing with headless children, correct results, roughly cpu-count speedups, and clean exits, under both the spawn context and Linux's forkserver default. The interception was also smoke-tested directly by invoking the built app binaries with multiprocessing-shaped command lines.

Release notes

Merging this should be followed by tagging 1.5.0 (semver minor: new exports, no breaking changes), which republishes all platform artifacts including the glibc-2.35-floor Linux ones. flet-dev/python-build's manifest.json then bumps dart_bridge_version to 1.5.0, and flet-dev/serious-python#228 picks up the regenerated pin. Note that serious-python 4.3.0's darwin plugin hard-requires these exports at link time (dead-strip keep-alives), so the tag must exist before that releases.

… serious_python_main

Python's multiprocessing (spawn/forkserver start methods and the
resource tracker) launches helper processes by re-executing
sys.executable with a CPython command line. In serious_python-hosted
apps sys.executable is the host app binary, so every helper re-launched
the host GUI instead of running the multiprocessing protocol: one stray
window per worker, tasks that never executed, hung joins, and
"resource_tracker: process died unexpectedly, relaunching" loops
(flet-dev/flet#4283).

Two new stable-ABI exports let the host binary double as a plain Python
interpreter for exactly those invocations:

- serious_python_is_mp_invocation(argc, argv): matches
  --multiprocessing-fork anywhere in argv, or a -c payload starting with
  "from multiprocessing." / "import sys; from multiprocessing." —
  covering spawn workers, the resource tracker, and the forkserver on
  CPython 3.12–3.14. Intentionally prefix-based: the exact command lines
  are CPython implementation details that shift across minor releases,
  while the helpers stay under multiprocessing.*.

- serious_python_main(argc, argv): shared preflight (scrub inherited
  PYTHONINSPECT, which would hold a real interpreter child open in
  interactive mode after its -c command; verify PYTHONHOME/PYTHONPATH
  are present — the parent's serious_python_run setenv's them
  process-wide, so children inherit the embedded stdlib/site-packages
  location; register the in-binary dart_bridge module on the inittab so
  inherited dart_bridge-aware imports fail soft), then delegate to
  Py_BytesMain for the full standard interpreter lifecycle.

Windows additionally gets wide-char variants
serious_python_is_mp_invocation_w / serious_python_main_w (→ Py_Main),
since wWinMain argv is wchar_t** and decoding through the ANSI code
page in the narrow versions would be lossy.

Host contract: call the detector first thing in main/wWinMain/
main.swift, before any UI or engine initialization, and exit with
serious_python_main's return code on a match. The flet build template
runners (macOS/Windows/Linux) do exactly this; resolution is via
dlsym/GetProcAddress with graceful fallthrough, so hosts built against
older dart_bridge keep launching unchanged.

The EXPORT macro on non-Windows now adds __attribute__((used)): on
Mach-O the archive is statically linked into the host app and several
exports are referenced only via dlsym/Dart FFI — without the
no-dead-strip marker, the host link's -dead_strip discarded any export
lacking an ordinary C call site.

Verified end-to-end on macOS in a flet build app (Process+Queue
round-trip, ProcessPoolExecutor with worker reuse, headless children,
clean exits); Windows/Linux code paths reviewed but pending runtime
verification.
…contract

New "Multiprocessing child interception (1.5.0+)" section covering:

- why interception exists (multiprocessing helpers re-exec
  sys.executable, which is the host app binary in serious_python-hosted
  apps — flet-dev/flet#4283);
- the four entry points (narrow + Windows wide-char variants) and which
  CPython entry point each delegates to (Py_BytesMain / Py_Main);
- the host contract: call the detector first thing in main / wWinMain /
  main.swift, before any UI or engine initialization, and exit with
  serious_python_main's return code on a match;
- what the detector matches and why the match is deliberately
  prefix-based (the helper command lines are CPython implementation
  details that shift across minor releases);
- how the child finds the embedded stdlib/site-packages (PYTHONHOME /
  PYTHONPATH inherited from the parent's process-wide setenv);
- the Apple `used`-attribute note (statically linked archive whose
  exports are dlsym-only — dead-strip protection).

Also list the new entry points in the serious_python_run.c bullet of
"What's in here".
libdart_bridge.so is linked directly into end users' Flutter runner
executables (the flet build template links plugin bundled libraries into
the main binary), so the glibc of the BUILD runner becomes the minimum
glibc of every machine that builds or runs a flet Linux app.

Building on ubuntu-24.04 (glibc 2.39) binds C23 symbol versions — e.g.
strtoll resolves to __isoc23_strtoll@GLIBC_2.38 — making the artifact
unlinkable/unloadable on Ubuntu 22.04 LTS (glibc 2.35). Found the hard
way: `flet build linux` on a 22.04 aarch64 host failed its final link
against the 24.04-arm-built artifact.

Pin both Linux jobs to ubuntu-22.04 / ubuntu-22.04-arm so released
artifacts keep a glibc 2.35 floor.
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread .github/workflows/ci.yml

- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
@ndonkoHenri ndonkoHenri changed the title ultiprocessing child interception: run the host binary as a headless interpreter for spawn/forkserver/resource-tracker re-execs Multiprocessing child interception: run the host binary as a headless interpreter for spawn/forkserver/resource-tracker re-execs Jul 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds multiprocessing-child interception support so apps embedding Python via dart_bridge can detect when they’ve been re-exec’d by CPython’s multiprocessing helpers and run as a headless interpreter instead of re-launching the host GUI. It also tightens GitHub Actions security posture and adjusts Linux CI runner selection to keep a lower glibc baseline for published artifacts.

Changes:

  • Add new exported entry points to detect multiprocessing helper invocations and delegate to Py_BytesMain/Py_Main after a small child-process preflight.
  • Document the host-side integration contract and platform-specific notes in the README.
  • Pin workflow actions to SHAs, add a zizmor workflow, and pin Linux builds to ubuntu-22.04 runners to preserve a glibc 2.35 floor.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/serious_python_run.c Adds multiprocessing invocation detection, a child preflight, and interpreter-entry exports (including Windows wide-char variants).
README.md Documents the new multiprocessing interception API and host integration requirements.
.github/workflows/zizmor.yml Adds a zizmor Actions security analysis workflow with least-privilege permissions.
.github/workflows/ci.yml Pins action versions to SHAs, restricts default permissions, and moves Linux builds to ubuntu-22.04 runners for glibc compatibility.

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

Supersedes the ubuntu-22.04 runner pin: 22.04's glibc 2.35 still binds
pthread_create@GLIBC_2.34, while flet's own Linux client matrix builds
down to debian:10 / manylinux_2_28 — so a 22.04-built libdart_bridge.so
would have been the tightest glibc constraint in the whole stack,
breaking `flet build linux` (and its output bundles) on the very distros
flet supports.

Mirror flet's pattern instead: modern runners (ubuntu-24.04 /
ubuntu-24.04-arm) with a debian:10 job container, apt pointed at
archive.debian.org (EOL distro), and a modern CMake from Kitware's
official binaries (Debian 10 ships 3.13 < our 3.15 minimum; the official
tarballs run on old glibc). A new CI step asserts the finished artifact
references no glibc symbol newer than 2.28, so a future container bump
can't silently raise the floor again.
- Declare `shell: bash` on every run step: inside a job container the
  default shell falls back to sh (dash), which rejects
  `set -o pipefail` ("Illegal option -o pipefail"). Bare runners
  default to bash, which is why this only broke after moving into the
  container. Matches what flet's own container jobs do.
- Install `file` in the base-packages step: the artifact staging step
  uses it, and unlike the bare runner images the debian:10 base image
  doesn't ship it.
@FeodorFitsner FeodorFitsner merged commit deca68f into main Jul 8, 2026
19 checks passed
@FeodorFitsner FeodorFitsner deleted the multiprocessing branch July 8, 2026 22:21
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.

4 participants