Skip to content

fix(android): make PEP 734 subinterpreters work (concurrent.interpreters / InterpreterPoolExecutor)#239

Merged
FeodorFitsner merged 3 commits into
mainfrom
fix-android-subinterpreters
Jul 20, 2026
Merged

fix(android): make PEP 734 subinterpreters work (concurrent.interpreters / InterpreterPoolExecutor)#239
FeodorFitsner merged 3 commits into
mainfrom
fix-android-subinterpreters

Conversation

@ndonkoHenri

Copy link
Copy Markdown
Collaborator

Fixes Python 3.14 subinterpreters (concurrent.interpreters / concurrent.futures.InterpreterPoolExecutor) on Android. They import fine in the main interpreter, but every subinterpreter failed to import any C extension, so the whole feature was unusable. The fix is Android-only and lives in one file (_sp_bootstrap.py); iOS and desktop already work and are unaffected by this PR.

This unlocks true multi-core CPU parallelism inside a single process — the one form of parallelism that works on mobile, where multiprocessing cannot spawn child processes.

Problem

In a subinterpreter, importing any relocated C extension fails:

ModuleNotFoundError: No module named '_struct'
ModuleNotFoundError: No module named '_interpqueues'

PEP 734 ships work between interpreters via pickle (picklestruct_struct) and its cross-interpreter queues need _interpqueues, so both the low-level API and InterpreterPoolExecutor are dead on arrival.

Root cause

On Android, C extensions are relocated (native-mmap packaging) and resolved through the custom _SorefFinder on sys.meta_path. sys.meta_path is per-interpreter, and install() only ran in the main interpreter — a freshly created subinterpreter gets a fresh meta_path without the finder, so it can resolve no relocated .so.

Verified on-device (Android 15 / arm64-v8a, Python 3.14.6):

main meta_path: ['_SorefFinder', 'BuiltinImporter', 'FrozenImporter', 'PathFinder']
sub  meta_path: ['BuiltinImporter', 'FrozenImporter', 'PathFinder']   # no _SorefFinder
sub  import _struct: ModuleNotFoundError

Fix

install() now also wraps concurrent.interpreters.create() so every new interpreter installs the finder before it is used. The install runs via Interpreter.exec() — a source string, which is pickle-free (unlike Interpreter.call()), so it works before _struct is importable in the child.

Refactor: the finder insertion moves to _install_finder(); install() = _install_finder() + _patch_subinterpreters().

Why this shape:

  • No dart-bridge / native change. The existing Android bootstrap already calls install(), which now triggers the patch. _sp_bootstrap is pure Python shipped by the plugin.
  • The pool is fixed transparently. InterpreterPoolExecutor builds its workers via concurrent.interpreters.create() (WorkerContext.initialize), so patching that one factory fixes the pool with no user-code change.
  • InterpreterPoolExecutor(initializer=...) can't fix this — the initializer is delivered over the same broken pickle path, so the finder must be installed at interpreter-creation time.
  • No sys.path seeding needed. A fresh subinterpreter already inherits the process-wide sys.path (PYTHONPATH), which resolves both _sp_bootstrap and the user's worker modules (confirmed on-device — see below).
  • No-op before 3.14 (module absent) and on iOS/desktop (this file is Android-only). Idempotent (create._sp_patched); supports nested subinterpreters.

Verification

On an Android 15 / arm64-v8a emulator (Python 3.14.6), a clean probe app with no app-level workaround:

  • low-level create() + queue round-trip (child interpreter id ≠ main) ✅
  • InterpreterPoolExecutor across 4 interpreters in a single process, ~×3.0 speedup vs sequential (one pid) ✅
  • importing a C extension (zlib) inside a subinterpreter ✅

Baseline before the fix: all three fail with ModuleNotFoundError as above. A separate build with the bare install() (no sys.path seeding) also passed all three, confirming the seeding is unnecessary.

…dules

PEP 734 subinterpreters (Python 3.14 concurrent.interpreters /
InterpreterPoolExecutor) were unusable in Android apps: the main
interpreter works, but every subinterpreter failed to import any C
extension (ModuleNotFoundError: _struct / _interpqueues / ...), which
breaks the whole feature (its cross-interpreter transport pickles ->
_struct, and its queues need _interpqueues). iOS and desktop are
unaffected.

Root cause: Android relocates C extensions (native-mmap packaging) and
resolves them through the custom _SorefFinder installed on
sys.meta_path. sys.meta_path is per-interpreter, and install() only ran
in the main interpreter, so a freshly created subinterpreter had a
meta_path without the finder and could import no relocated .so. Verified
on-device: main meta_path has _SorefFinder, a subinterpreter's does not.

Fix: teach every new subinterpreter to install the finder. Split the
finder insertion into _install_finder(), and make install() also call a
new _patch_subinterpreters() that wraps concurrent.interpreters.create()
so each new interpreter runs `import _sp_bootstrap; _sp_bootstrap.install()`.
The setup runs via Interpreter.exec() -- a source string, which is
pickle-free (unlike Interpreter.call()), so it works BEFORE _struct is
importable in the child. (A fresh subinterpreter inherits the
process-wide sys.path, so no sys.path seeding is needed --
verified on-device.)

Notes:
- No dart-bridge / C change: the existing Android bootstrap already calls
  install(), which now triggers the patch. The wrapper is tagged
  `_sp_patched` for idempotency.
- create() is the factory InterpreterPoolExecutor uses
  (WorkerContext.initialize -> interpreters.create()), so patching it
  fixes the pool transparently -- with no user code change.
- A user-supplied InterpreterPoolExecutor(initializer=...) can NOT fix
  this: the initializer is delivered over the same broken pickle path, so
  the finder must be installed at interpreter-creation time.
- No-op before 3.14 (module absent) and on iOS/desktop (this file is
  Android-only). Idempotent; supports nested subinterpreters.

Verified end-to-end on an Android 15 / arm64-v8a emulator (Python
3.14.6): the clean probe app -- with no app-level workaround -- runs a
low-level create+queue round-trip, an InterpreterPoolExecutor across 4
interpreters in one process, and imports a C extension inside a
subinterpreter.
Fold the subinterpreter fix into the existing 4.3.6 section (main jumped
4.3.4 -> 4.3.6 with the Windows UTF-8 release; our 4.3.5 draft was
rebased away): a detailed bullet in serious_python_android and a
summary bullet in serious_python (umbrella). The Apple/Windows/Linux/
interface packages already carry their 4.3.6 entries from the Windows
release and are unaffected by this Android-only change. No pubspec bump
needed — this rides in the same 4.3.6 that main is already on.
@FeodorFitsner

FeodorFitsner commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Diagnosis and fix shape are correct — sys.meta_path is per-interpreter, so wrapping the concurrent.interpreters.create() factory is the right interception point, and it transparently covers InterpreterPoolExecutor (its WorkerContext.initialize goes through that factory) with no native/bridge change. The Interpreter.exec() (source string, pickle-free) vs .call() insight is the crux and is spot on — you can't ship the fix over the very transport (_struct) that's broken, which is also why initializer=... can't self-heal. On-device verification (queue round-trip, 4-way pool ~3x, C-ext import in a subinterpreter, plus the control build showing sys.path seeding is unnecessary) is exactly what I'd want.

I pushed a small follow-up commit (e522e41) with two non-blocking polish items — take or revert as you see fit:

  1. Structural fragility documented. The attribute-patch only applies if callers resolve interpreters.create as a module attribute at call time (as InterpreterPoolExecutor does on 3.14) rather than binding it via from concurrent.interpreters import create. It works today, but it's coupled to a CPython impl detail that could shift in a future 3.14.x/3.15 and would then silently regress. Added a NOTE in the docstring so a future breakage is diagnosable.

  2. Silent except Exception: pass in the create wrapper swallowed a failed interp.exec() — the interpreter came back finder-less and you'd get the original ModuleNotFoundError with no breadcrumb. install() already writes an SP_BOOTSTRAP ... audit line to stderr; mirrored that here on failure for consistency/debuggability without changing behavior.

Cosmetic (left as-is): the wrapper doesn't functools.wraps the original, so create.__name__/__doc__ change — harmless given the _sp_patched sentinel.

Approving in principle — the notes are polish, not correctness.

@FeodorFitsner
FeodorFitsner merged commit 7e8a923 into main Jul 20, 2026
17 of 56 checks passed
@FeodorFitsner
FeodorFitsner deleted the fix-android-subinterpreters branch July 20, 2026 21:13
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.

2 participants