fix(android): make PEP 734 subinterpreters work (concurrent.interpreters / InterpreterPoolExecutor)#239
Conversation
…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.
|
Diagnosis and fix shape are correct — I pushed a small follow-up commit (e522e41) with two non-blocking polish items — take or revert as you see fit:
Cosmetic (left as-is): the wrapper doesn't Approving in principle — the notes are polish, not correctness. |
…child finder-install failure
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
multiprocessingcannot spawn child processes.Problem
In a subinterpreter, importing any relocated C extension fails:
PEP 734 ships work between interpreters via pickle (
pickle→struct→_struct) and its cross-interpreter queues need_interpqueues, so both the low-level API andInterpreterPoolExecutorare dead on arrival.Root cause
On Android, C extensions are relocated (native-mmap packaging) and resolved through the custom
_SorefFinderonsys.meta_path.sys.meta_pathis per-interpreter, andinstall()only ran in the main interpreter — a freshly created subinterpreter gets a freshmeta_pathwithout the finder, so it can resolve no relocated.so.Verified on-device (Android 15 / arm64-v8a, Python 3.14.6):
Fix
install()now also wrapsconcurrent.interpreters.create()so every new interpreter installs the finder before it is used. The install runs viaInterpreter.exec()— a source string, which is pickle-free (unlikeInterpreter.call()), so it works before_structis importable in the child.Refactor: the finder insertion moves to
_install_finder();install()=_install_finder()+_patch_subinterpreters().Why this shape:
install(), which now triggers the patch._sp_bootstrapis pure Python shipped by the plugin.InterpreterPoolExecutorbuilds its workers viaconcurrent.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.sys.pathseeding needed. A fresh subinterpreter already inherits the process-widesys.path(PYTHONPATH), which resolves both_sp_bootstrapand the user's worker modules (confirmed on-device — see below).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:
create()+ queue round-trip (child interpreter id ≠ main) ✅InterpreterPoolExecutoracross 4 interpreters in a single process, ~×3.0 speedup vs sequential (one pid) ✅zlib) inside a subinterpreter ✅Baseline before the fix: all three fail with
ModuleNotFoundErroras above. A separate build with the bareinstall()(nosys.pathseeding) also passed all three, confirming the seeding is unnecessary.