tractor/tractor/spawn/_subint_fork.py

154 lines
5.4 KiB
Python

# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
`subint_fork` spawn backend — BLOCKED at CPython level.
The idea was to use a sub-interpreter purely as a launchpad
from which to call `os.fork()`, sidestepping the well-known
trio+fork issues (python-trio/trio#1614 etc.) by guaranteeing
the forking interp had never imported `trio`.
**IT DOES NOT WORK ON CURRENT CPYTHON.** The fork syscall
itself succeeds (in the parent), but the forked CHILD
process aborts immediately during CPython's post-fork
cleanup — `PyOS_AfterFork_Child()` calls
`_PyInterpreterState_DeleteExceptMain()` which refuses to
operate when the current tstate belongs to a non-main
sub-interpreter.
Full annotated walkthrough from the user-visible error
(`Fatal Python error: _PyInterpreterState_DeleteExceptMain:
not main interpreter`) down to the specific CPython source
lines that enforce this is in
`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`.
We keep this submodule as a dedicated documentation of the
attempt. If CPython ever lifts the restriction (e.g., via a
force-destroy primitive or a hook that swaps tstate to main
pre-fork), the structural sketch preserved in this file's
git history is a concrete starting point for a working impl.
See also: issue #379's "Our own thoughts, ideas for
`fork()`-workaround/hacks..." section.
'''
from __future__ import annotations
import sys
from typing import (
Any,
TYPE_CHECKING,
)
import trio
from trio import TaskStatus
from tractor.runtime._portal import Portal
from ._subint import _has_subints
if TYPE_CHECKING:
from tractor.discovery._addr import UnwrappedAddress
from tractor.runtime._runtime import Actor
from tractor.runtime._supervise import ActorNursery
async def subint_fork_proc(
name: str,
actor_nursery: ActorNursery,
subactor: Actor,
errors: dict[tuple[str, str], Exception],
bind_addrs: list[UnwrappedAddress],
parent_addr: UnwrappedAddress,
_runtime_vars: dict[str, Any],
*,
infect_asyncio: bool = False,
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
proc_kwargs: dict[str, any] = {},
) -> None:
'''
EXPERIMENTAL — currently blocked by a CPython invariant.
Attempted design
----------------
1. Parent creates a fresh legacy-config subint.
2. A worker OS-thread drives the subint through a
bootstrap that calls `os.fork()`.
3. In the forked CHILD, `os.execv()` back into
`python -m tractor._child` (fresh process).
4. In the fork-PARENT, the launchpad subint is destroyed;
parent-side trio task proceeds identically to
`trio_proc()` (wait for child connect-back, send
`SpawnSpec`, yield `Portal`, etc.).
Why it doesn't work
-------------------
CPython's `PyOS_AfterFork_Child()` (in
`Modules/posixmodule.c`) calls
`_PyInterpreterState_DeleteExceptMain()` (in
`Python/pystate.c`) as part of post-fork cleanup. That
function requires the current `PyThreadState` belong to
the **main** interpreter. When `os.fork()` is called
from within a sub-interpreter, the child wakes up with
its tstate still pointing at the (now-stale) subint, and
this check fails with `PyStatus_ERR("not main
interpreter")`, triggering a `fatal_error` goto and
aborting the child process.
CPython devs acknowledge the fragility with a
`// Ideally we could guarantee tstate is running main.`
comment right above the call site.
See
`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
for the full annotated walkthrough + upstream-report
draft.
Why we keep this stub
---------------------
- Documents the attempt in-tree so the next person who
has this idea finds the reason it doesn't work rather
than rediscovering the same CPython-level dead end.
- If CPython ever lifts the restriction (e.g., via a
force-destroy primitive or a hook that swaps tstate
to main pre-fork), this submodule's git history holds
the structural sketch of what a working impl would
look like.
'''
if not _has_subints:
raise RuntimeError(
f'The {"subint_fork"!r} spawn backend requires '
f'Python 3.14+.\n'
f'Current runtime: {sys.version}'
)
raise NotImplementedError(
'The `subint_fork` spawn backend is blocked at the '
'CPython level — `os.fork()` from a non-main '
'sub-interpreter is refused by '
'`PyOS_AfterFork_Child()` → '
'`_PyInterpreterState_DeleteExceptMain()`, which '
'aborts the child with '
'`Fatal Python error: not main interpreter`.\n'
'\n'
'See '
'`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md` '
'for the full analysis + upstream-report draft.'
)