Skip to content

Python: every recipe bundle runs in the server's own process - #8752

Draft
knutwannheden wants to merge 3 commits into
mainfrom
python-bundles-share-one-process
Draft

knutwannheden wants to merge 3 commits into
mainfrom
python-bundles-share-one-process

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

A mod run of a pip recipe bundle spawns one Python process per bundle and sends the whole LST to each. The facade deserializes the tree from Java, then re-serializes it per child — on jd/tenacity that relay is 1,303,024 messages written and as many parsed again, producing no edits.

The second process exists to keep two bundles' dependencies apart. A private module table does that in one process, so it isn't needed: sys.modules is global, so two versions of one package cannot both be sys.modules['dep'] at once, but they can take turns. Entering a bundle swaps its entries in and back out.

Which modules a bundle owns

Ownership is by where a module was loaded from, not by name. The engine is imported from the shared engine root, so it matches no bundle and every bundle sees the one instance — no list to maintain and no drift.

A dependency the engine declares is already imported and resolves to the engine's copy. That is the same answer a child gives, whose PYTHONPATH puts the engine roots first.

What it costs and what it buys

Two recipe bundles on incompatible versions of one C-extension dependency, in one process, both on the C path rather than falling back to pure Python:

openrewrite-conflict-a   msgpack (1, 0, 8)  via msgpack._cmsgpack   engine shared: True
openrewrite-conflict-b   msgpack (1, 1, 0)  via msgpack._cmsgpack   engine shared: True

On jd/tenacity with UpgradeToPython313, against a CLI built from this branch with the engine installed from a real wheel:

                 wall   fix.patch   python CPU   processes
main             167s   c0a2a2ab       51.5s     2  (facade 27.2s + child 24.4s)
this branch       49s   c0a2a2ab       26.9s     1

Installing a second bundle leaves it at one process, where main spawns two children. Single runs on a contended machine, so read the process counts rather than the timings.

The trade-off

A bundle that dies natively takes the server with it, where before it took only its own child:

main          child dies (SIGSEGV) -> server reports the error and keeps serving
this branch   server dies, exit -11, later requests fail with BrokenPipe

A venv built by another interpreter is now rebuilt rather than routed around, since there is no child on its own interpreter to fall back to — is_usable_venv checks the recorded version alongside the base it borrows its stdlib from.

What goes

facade.py, bundle_children.py, the _hub_* block in server.py and the per-child ref tables all existed to move a tree between processes. ChildConnection stays: java_rpc_client drives a Java peer over the same framing, so only the environment its bundle children needed (child_command, _child_env, _engine_roots) goes with them.

Tests

test_bundle_overlay.py pins the table itself:

  • two bundles resolving one module name to different copies
  • a module from outside every venv staying shared
  • a lazy import inside a call resolving to the calling bundle
  • the host's path and modules surviving unchanged
  • recipes interleaved as the scheduler runs them, A then B on each file in turn, since the table swaps per visit rather than per file

Run against the built wheel rather than the source tree. A module deleted from the worktree still resolves from an installed copy in site-packages, so a source-tree run cannot see deletions at all; the wheel run is what caught java_rpc_client's use of ChildConnection.

That PR hosts a bundle in-process only while it is the only one, keeping spawn-and-route from the second bundle on. This one removes the spawn path outright.

A bundle's venv isolates how pip resolves its dependencies; a private module
table isolates them at run time, so two bundles' incompatible versions take
turns in sys.modules rather than needing a process each. Ownership is by where
a module was loaded from, so the engine -- imported from the shared engine root
-- matches no bundle and every bundle sees the one instance.

With the recipes on the same objects the server already holds, the facade, the
per-bundle children and the relay that served each child its own copy of the
tree all go: on jd/tenacity that relay was 1,303,024 messages serialized and as
many parsed again, in a second process.

A venv built by another interpreter is now rebuilt rather than routed around,
since there is no child running on its own interpreter to fall back to.
Spawning a Java peer from Python uses the same Content-Length framing, so the
connection outlives the bundle children it was written for. What goes with them
is the environment they needed: child_command, _child_env and _engine_roots.
Java sends Evict as a JSON-RPC notification, and answering one puts a null-id
response on the wire, which fails every request the peer has in flight. The
server's own dispatch already guards this; the transport now matches it.

Latent until now because the peer was a bundle child, which only ever sent
requests. It matters because java_rpc_client drives a Java peer over this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant