From 37cfaa202b3a9c1cb079208804961579d1dffcad Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 22 Apr 2026 16:40:52 -0400 Subject: [PATCH] Add CPython-level `subint_fork` workaround smoketest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone script to validate the "main-interp worker-thread forkserver + subint-hosted trio" arch proposed as a workaround to the CPython-level refusal doc'd in `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`. Deliberately NOT a `tractor` test — zero `tractor` imports. Uses `_interpreters` (private stdlib) + `os.fork()` directly so pass/fail is a property of CPython alone, independent of our runtime. Requires py3.14+. Deats, - four scenarios via `--scenario`: - `control_subint_thread_fork` — the KNOWN-BROKEN case as a harness sanity; if the child DOESN'T abort, our analysis is wrong - `main_thread_fork` — baseline sanity, must always succeed - `worker_thread_fork` — architectural assertion: regular `threading.Thread` attached to main interp calls `os.fork()`; child should survive post-fork cleanup - `full_architecture` — end-to-end: fork from a main-interp worker thread, then in child create a subint driving a worker thread running `trio.run()` - exit code 0 on EXPECTED outcome (for `control_*` that means "child aborted", not "child succeeded") - each scenario prints a self-contained pass/fail banner; use `os.waitpid()` of the parent + per-scenario status prints to observe the child's fate Also, log NLNet provenance for this session's three-sub-phase work (py3.13 gate tightening, `pytest-timeout` + marker refactor, `subint_fork` prototype → CPython-block finding). Prompt-IO: ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../subint_fork_from_main_thread_smoketest.py | 440 ++++++++++++++++++ .../20260422T200723Z_797f57c_prompt_io.md | 155 ++++++ .../20260422T200723Z_797f57c_prompt_io.raw.md | 343 ++++++++++++++ 3 files changed, 938 insertions(+) create mode 100644 ai/conc-anal/subint_fork_from_main_thread_smoketest.py create mode 100644 ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.md create mode 100644 ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.raw.md diff --git a/ai/conc-anal/subint_fork_from_main_thread_smoketest.py b/ai/conc-anal/subint_fork_from_main_thread_smoketest.py new file mode 100644 index 00000000..eb202ef9 --- /dev/null +++ b/ai/conc-anal/subint_fork_from_main_thread_smoketest.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +''' +Standalone CPython-level feasibility check for the "main-interp +worker-thread forkserver + subint-hosted trio" architecture +proposed as a workaround to the CPython-level refusal +documented in +`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`. + +Purpose +------- +Deliberately NOT a `tractor` test. Zero `tractor` imports. +Uses `_interpreters` (private stdlib) + `os.fork()` directly so +the signal is unambiguous — pass/fail here is a property of +CPython alone, independent of our runtime. + +Run each scenario in isolation; the child's fate is observable +only via `os.waitpid()` of the parent and the scenario's own +status prints. + +Scenarios (pick one with `--scenario `) +--------------------------------------------- + +- `control_subint_thread_fork` — the KNOWN-BROKEN case we + documented in `subint_fork_blocked_by_cpython_post_fork_issue.md`: + drive a subint from a thread, call `os.fork()` inside its + `_interpreters.exec()`, watch the child abort. **Included as + a control** — if this scenario DOESN'T abort the child, our + analysis is wrong and we should re-check everything. + +- `main_thread_fork` — baseline sanity. Call `os.fork()` from + the process's main thread. Must always succeed; if this + fails something much bigger is broken. + +- `worker_thread_fork` — the architectural assertion. Spawn a + regular `threading.Thread` (attached to main interp, NOT a + subint), have IT call `os.fork()`. Child should survive + post-fork cleanup. + +- `full_architecture` — end-to-end: main-interp worker thread + forks. In the child, fork-thread (still main-interp) creates + a subint, drives a second worker thread inside it that runs + a trivial `trio.run()`. Validates the "root runtime lives in + a subint in the child" piece of the proposed arch. + +All scenarios print a self-contained pass/fail banner. Exit +code 0 on expected outcome (which for `control_*` means "child +aborted", not "child succeeded"!). + +Requires Python 3.14+. + +Usage +----- +:: + + python subint_fork_from_main_thread_smoketest.py \\ + --scenario main_thread_fork + + python subint_fork_from_main_thread_smoketest.py \\ + --scenario full_architecture + +''' +from __future__ import annotations +import argparse +import os +import signal +import sys +import threading +import time +from typing import Callable + + +# Hard-require py3.14 for the public `concurrent.interpreters` +# API (we still drop to `_interpreters` internally, same as +# `tractor.spawn._subint`). +try: + from concurrent import interpreters as _public_interpreters # noqa: F401 + import _interpreters # type: ignore +except ImportError: + print( + 'FAIL (setup): requires Python 3.14+ ' + '(missing `concurrent.interpreters`)', + file=sys.stderr, + ) + sys.exit(2) + + +# ---------------------------------------------------------------- +# small observability helpers +# ---------------------------------------------------------------- + + +def _banner(title: str) -> None: + line = '=' * 60 + print(f'\n{line}\n{title}\n{line}', flush=True) + + +def _wait_child( + pid: int, + *, + label: str, + expect_exit_ok: bool, +) -> bool: + ''' + Await a forked child's exit status and render pass/fail. + + `expect_exit_ok=True` means we expect a normal exit (code + 0 via WEXITSTATUS). `expect_exit_ok=False` means we expect + an abnormal death (WIFSIGNALED or nonzero WEXITSTATUS) — + used for the `control_*` scenario where CPython is + supposed to abort the child. + + ''' + _, status = os.waitpid(pid, 0) + exited_normally = os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + signaled = os.WIFSIGNALED(status) + sig = os.WTERMSIG(status) if signaled else None + rc = os.WEXITSTATUS(status) if os.WIFEXITED(status) else None + + if expect_exit_ok: + ok = exited_normally + expected_str = 'normal exit (rc=0)' + else: + ok = not exited_normally + expected_str = ( + 'abnormal death (signal or nonzero exit)' + ) + + verdict = 'PASS' if ok else 'FAIL' + status_str = ( + f'signal={signal.Signals(sig).name}' + if signaled + else f'rc={rc}' + ) + print( + f'[{verdict}] {label}: ' + f'expected {expected_str}; observed {status_str}', + flush=True, + ) + return ok + + +# ---------------------------------------------------------------- +# scenario: `control_subint_thread_fork` (known-broken) +# ---------------------------------------------------------------- + + +def scenario_control_subint_thread_fork() -> int: + _banner( + '[control] fork from INSIDE a subint (expected: child aborts)' + ) + interp_id = _interpreters.create('legacy') + print(f' created subint {interp_id}', flush=True) + + # Shared flag: child writes a sentinel file we can detect from + # the parent. If the child manages to write this, CPython's + # post-fork refusal is NOT happening → analysis is wrong. + sentinel = '/tmp/subint_fork_smoketest_control_child_ran' + try: + os.unlink(sentinel) + except FileNotFoundError: + pass + + bootstrap = ( + 'import os\n' + 'pid = os.fork()\n' + 'if pid == 0:\n' + # child — if CPython's refusal fires this code never runs + f' with open({sentinel!r}, "w") as f:\n' + ' f.write("ran")\n' + ' os._exit(0)\n' + 'else:\n' + # parent side (inside the launchpad subint) — stash the + # forked PID on a shareable dict so we can waitpid() + # from the outer main interp. We can't just return it; + # _interpreters.exec() returns nothing useful. + ' import builtins\n' + ' builtins._forked_child_pid = pid\n' + ) + + # NOTE, we can't easily pull state back from the subint. + # For the CONTROL scenario we just time-bound the fork + + # check the sentinel. If sentinel exists → child ran → + # analysis wrong. If not → child aborted → analysis + # confirmed. + done = threading.Event() + + def _drive() -> None: + try: + _interpreters.exec(interp_id, bootstrap) + except Exception as err: + print( + f' subint bootstrap raised (expected on some ' + f'CPython versions): {type(err).__name__}: {err}', + flush=True, + ) + finally: + done.set() + + t = threading.Thread( + target=_drive, + name='control-subint-fork-launchpad', + daemon=True, + ) + t.start() + done.wait(timeout=5.0) + t.join(timeout=2.0) + + # Give the (possibly-aborted) child a moment to die. + time.sleep(0.5) + + sentinel_present = os.path.exists(sentinel) + verdict = ( + # "PASS" for our analysis means sentinel NOT present. + 'PASS' if not sentinel_present else 'FAIL (UNEXPECTED)' + ) + print( + f'[{verdict}] control: sentinel present={sentinel_present} ' + f'(analysis predicts False — child should abort before ' + f'writing)', + flush=True, + ) + if sentinel_present: + os.unlink(sentinel) + + try: + _interpreters.destroy(interp_id) + except _interpreters.InterpreterError: + pass + + return 0 if not sentinel_present else 1 + + +# ---------------------------------------------------------------- +# scenario: `main_thread_fork` (baseline sanity) +# ---------------------------------------------------------------- + + +def scenario_main_thread_fork() -> int: + _banner( + '[baseline] fork from MAIN thread (expected: child exits normally)' + ) + + pid = os.fork() + if pid == 0: + os._exit(0) + + return 0 if _wait_child( + pid, + label='main_thread_fork', + expect_exit_ok=True, + ) else 1 + + +# ---------------------------------------------------------------- +# scenario: `worker_thread_fork` (architectural assertion) +# ---------------------------------------------------------------- + + +def _fork_from_worker_thread( + child_target: Callable[[], int] | None = None, + label: str = 'worker_thread_fork', +) -> int: + ''' + Fork from a main-interp worker thread (not a subint). + Returns the child's exit code observed by the parent. + + `child_target` is called IN THE CHILD before `os._exit`. + If omitted, the child just `_exit(0)`s immediately. + + `label` is used in the pass/fail banner so reuse of this + helper across scenarios reports the scenario name, not + just the underlying fork-mechanism name. + + ''' + # Use a simple pipe to shuttle the child PID back to main. + rfd, wfd = os.pipe() + + def _worker() -> None: + pid = os.fork() + if pid == 0: + # CHILD: close parent's pipe ends, do work, exit. + os.close(rfd) + os.close(wfd) + rc = 0 + if child_target is not None: + try: + rc = child_target() or 0 + except BaseException as err: + print( + f' CHILD: child_target raised: ' + f'{type(err).__name__}: {err}', + file=sys.stderr, flush=True, + ) + rc = 2 + os._exit(rc) + else: + # PARENT (still in worker thread): send pid to + # main thread via the pipe. + os.write(wfd, pid.to_bytes(8, 'little')) + + t = threading.Thread( + target=_worker, + name=f'worker-fork-thread[{label}]', + daemon=False, + ) + t.start() + t.join(timeout=10.0) + if t.is_alive(): + print( + f'[FAIL] {label}: worker-thread fork driver ' + f'did not return in 10s', + flush=True, + ) + return 1 + + pid_bytes = os.read(rfd, 8) + os.close(rfd) + os.close(wfd) + pid = int.from_bytes(pid_bytes, 'little') + print(f' forked child pid={pid}', flush=True) + + return 0 if _wait_child( + pid, + label=label, + expect_exit_ok=True, + ) else 1 + + +def scenario_worker_thread_fork() -> int: + _banner( + '[arch] fork from MAIN-INTERP WORKER thread ' + '(expected: child exits normally — this is the one ' + 'that matters)' + ) + return _fork_from_worker_thread( + child_target=None, + label='worker_thread_fork', + ) + + +# ---------------------------------------------------------------- +# scenario: `full_architecture` +# ---------------------------------------------------------------- + + +def _child_trio_in_subint() -> int: + ''' + CHILD-side: from fork-thread (main-interp), create a fresh + subint and run `trio.run()` in it on a dedicated worker + thread. Returns 0 on success. + ''' + child_interp = _interpreters.create('legacy') + subint_bootstrap = ( + 'import trio\n' + 'async def _main():\n' + ' await trio.sleep(0.05)\n' + ' return 42\n' + 'result = trio.run(_main)\n' + 'assert result == 42, f"trio.run returned {result}"\n' + 'print(" CHILD subint: trio.run OK, result=42", ' + 'flush=True)\n' + ) + err = None + + def _drive() -> None: + nonlocal err + try: + _interpreters.exec(child_interp, subint_bootstrap) + except BaseException as e: + err = e + + t = threading.Thread( + target=_drive, + name='child-subint-trio-thread', + daemon=False, + ) + t.start() + t.join(timeout=10.0) + + try: + _interpreters.destroy(child_interp) + except _interpreters.InterpreterError: + pass + + if t.is_alive(): + print( + ' CHILD: subint trio thread did not return in 10s', + flush=True, + ) + return 3 + if err is not None: + print( + f' CHILD: subint bootstrap raised: ' + f'{type(err).__name__}: {err}', + flush=True, + ) + return 4 + return 0 + + +def scenario_full_architecture() -> int: + _banner( + '[arch-full] worker-thread fork + child runs trio in a ' + 'subint (end-to-end proposed arch)' + ) + return _fork_from_worker_thread( + child_target=_child_trio_in_subint, + label='full_architecture', + ) + + +# ---------------------------------------------------------------- +# main +# ---------------------------------------------------------------- + + +SCENARIOS: dict[str, Callable[[], int]] = { + 'control_subint_thread_fork': scenario_control_subint_thread_fork, + 'main_thread_fork': scenario_main_thread_fork, + 'worker_thread_fork': scenario_worker_thread_fork, + 'full_architecture': scenario_full_architecture, +} + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument( + '--scenario', + choices=sorted(SCENARIOS.keys()), + required=True, + ) + args = ap.parse_args() + return SCENARIOS[args.scenario]() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.md b/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.md new file mode 100644 index 00000000..e606db8f --- /dev/null +++ b/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.md @@ -0,0 +1,155 @@ +--- +model: claude-opus-4-7[1m] +service: claude +session: subints-phase-b-hardening-and-fork-block +timestamp: 2026-04-22T20:07:23Z +git_ref: 797f57c +scope: code +substantive: true +raw_file: 20260422T200723Z_797f57c_prompt_io.raw.md +--- + +## Prompt + +Session-spanning work on the Phase B `subint` spawn-backend. +Three distinct sub-phases in one log: + +1. **Py3.13 gate tightening** — diagnose a reproducible hang + of subint spawn flow under py3.13 (works on py3.14), trace + to a private `_interpreters` module vintage issue, tighten + our feature gate from "`_interpreters` present" to "public + `concurrent.interpreters` present" (i.e. py3.14+). +2. **Test-harness hardening** — add `pytest-timeout` dep, put + `@pytest.mark.timeout(30, method='thread')` on the + three known-hanging subint tests cataloged in + `ai/conc-anal/subint_sigint_starvation_issue.md`. Separately, + code-review the user's in-flight `skipon_spawn_backend` + marker implementation; find four bugs; refactor to use + `item.iter_markers()`. +3. **`subint_fork` prototype → CPython-block finding** — draft + a WIP `subint_fork_proc` backend using a sub-interpreter as + a launchpad for `os.fork()` (to sidestep trio#1614). User + tests on py3.14, hits + `Fatal Python error: _PyInterpreterState_DeleteExceptMain: + not main interpreter`. Walk CPython sources (local clone at + `~/repos/cpython/`) to pinpoint the refusal + (`Modules/posixmodule.c:728` → `Python/pystate.c:1040`). + Revert implementation to a `NotImplementedError` stub in a + new `_subint_fork.py` submodule, document the finding in a + third `conc-anal/` doc with an upstream-report draft for + the CPython issue tracker. Finally, discuss user's proposed + workaround architecture (main-interp worker-thread + forkserver) and draft a standalone smoke-test script for + feasibility validation. + +## Response summary + +All three sub-phases landed concrete artifacts: + +**Sub-phase 1** — `_subint.py` + `_spawn.py` gates + error +messages updated to require py3.14+ via the public +`concurrent.interpreters` module presence check. Module +docstring revised to explain the empirical reason +(py3.13's private `_interpreters` vintage wedges under +multi-trio-task usage even though minimal standalone +reproducers work fine there). Test-module +`pytest.importorskip` likewise switched. + +**Sub-phase 2** — `pytest-timeout>=2.3` added to `testing` +dep group. `@pytest.mark.timeout(30, method='thread')` +applied on: +- `tests/discovery/test_registrar.py::test_stale_entry_is_deleted` +- `tests/test_cancellation.py::test_cancel_while_childs_child_in_sync_sleep` +- `tests/test_cancellation.py::test_multierror_fast_nursery` +- `tests/test_subint_cancellation.py::test_subint_non_checkpointing_child` + +`method='thread'` documented inline as load-bearing — the +GIL-starvation path that drops `SIGINT` would equally drop +`SIGALRM`, so only a watchdog-thread timeout can reliably +escape. + +`skipon_spawn_backend` plugin refactored into a single +`iter_markers`-driven loop in `pytest_collection_modifyitems` +(~30 LOC replacing ~30 LOC of nested conditionals). Four +bugs dissolved: wrong `.get()` key, module-level `pytestmark` +suppressing per-test marks, unhandled `pytestmark = [list]` +form, `pytest.Makr` typo. Marker help text updated to +document the variadic backend-list + `reason=` kwarg +surface. + +**Sub-phase 3** — Prototype drafted (then reverted): + +- `tractor/spawn/_subint_fork.py` — new dedicated submodule + housing the `subint_fork_proc` stub. Module docstring + + fn docstring explain the attempt, the CPython-level + block, and the reason for keeping the stub in-tree + (documentation of the attempt + starting point if CPython + ever lifts the restriction). +- `tractor/spawn/_spawn.py` — `'subint_fork'` registered as a + `SpawnMethodKey` literal + in `_methods`, so + `--spawn-backend=subint_fork` routes to a clean + `NotImplementedError` pointing at the analysis doc rather + than an "invalid backend" error. +- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md` — + third sibling conc-anal doc. Full annotated CPython + source walkthrough from user-visible + `Fatal Python error` → `Modules/posixmodule.c:728 + PyOS_AfterFork_Child()` → `Python/pystate.c:1040 + _PyInterpreterState_DeleteExceptMain()` gate. Includes a + copy-paste-ready upstream-report draft for the CPython + issue tracker with a two-tier ask (ideally "make it work", + minimally "cleaner error than `Fatal Python error` + aborting the child"). +- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py` — + standalone zero-tractor-import CPython-level smoke test + for the user's proposed workaround architecture + (forkserver on a main-interp worker thread). Four + argparse-driven scenarios: `control_subint_thread_fork` + (reproduces the known-broken case as a test-harness + sanity), `main_thread_fork` (baseline), `worker_thread_fork` + (architectural assertion), `full_architecture` + (end-to-end trio-in-subint in forked child). User will + run on py3.14 next. + +## Files changed + +See `git log 26fb820..HEAD --stat` for the canonical list. +New files this session: +- `tractor/spawn/_subint_fork.py` +- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md` +- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py` + +Modified (diff pointers in raw log): +- `tractor/spawn/_subint.py` (py3.14 gate) +- `tractor/spawn/_spawn.py` (`subint_fork` registration) +- `tractor/_testing/pytest.py` (`skipon_spawn_backend` refactor) +- `pyproject.toml` (`pytest-timeout` dep) +- `tests/discovery/test_registrar.py`, + `tests/test_cancellation.py`, + `tests/test_subint_cancellation.py` (timeout marks, + cross-refs to conc-anal docs) + +## Human edits + +Several back-and-forth iterations with user-driven +adjustments during the session: + +- User corrected my initial mis-classification of + `test_cancel_while_childs_child_in_sync_sleep[subint-False]` + as Ctrl-C-able — second strace showed `EAGAIN`, putting + it squarely in class A (GIL-starvation). Re-analysis + preserved in the raw log. +- User independently fixed the `.get(reason)` → `.get('reason', reason)` + bug in the marker plugin before my review; preserved their + fix. +- User suggested moving the `subint_fork_proc` stub from + the bottom of `_subint.py` into its own + `_subint_fork.py` submodule — applied. +- User asked to keep the forkserver-architecture + discussion as background for the smoke-test rather than + committing to a tractor-side refactor until the smoke + test validates the CPython-level assumptions. + +Commit messages in this range (b025c982 … 797f57c) were +drafted via `/commit-msg` + `rewrap.py --width 67`; user +landed them with the usual review. diff --git a/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.raw.md b/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.raw.md new file mode 100644 index 00000000..395523fe --- /dev/null +++ b/ai/prompt-io/claude/20260422T200723Z_797f57c_prompt_io.raw.md @@ -0,0 +1,343 @@ +--- +model: claude-opus-4-7[1m] +service: claude +timestamp: 2026-04-22T20:07:23Z +git_ref: 797f57c +diff_cmd: git log 26fb820..HEAD # all session commits since the destroy-race fix log +--- + +Session-spanning conversation covering the Phase B hardening +of the `subint` spawn-backend and an investigation into a +proposed `subint_fork` follow-up which turned out to be +blocked at the CPython level. This log is a narrative capture +of the substantive turns (not every message) and references +the concrete code + docs the session produced. Per diff-ref +mode the actual code diffs are pointed at via `git log` on +each ref rather than duplicated inline. + +## Narrative of the substantive turns + +### Py3.13 hang / gate tightening + +Diagnosed a reproducible hang of the `subint` backend under +py3.13 (test_spawning tests wedge after root-actor bringup). +Root cause: py3.13's vintage of the private `_interpreters` C +module has a latent thread/subint-interaction issue that +`_interpreters.exec()` silently fails to progress under +tractor's multi-trio usage pattern — even though a minimal +standalone `threading.Thread` + `_interpreters.exec()` +reproducer works fine on the same Python. Empirically +py3.14 fixes it. + +Fix (from this session): tighten the `_has_subints` gate in +`tractor.spawn._subint` from "private module importable" to +"public `concurrent.interpreters` present" — which is 3.14+ +only. This leaves `subint_proc()` unchanged in behavior (we +still call the *private* `_interpreters.create('legacy')` +etc. under the hood) but refuses to engage on 3.13. + +Also tightened the matching gate in +`tractor.spawn._spawn.try_set_start_method('subint')` and +rev'd the corresponding error messages from "3.13+" to +"3.14+" with a sentence explaining why. Test-module +`pytest.importorskip` switched from `_interpreters` → +`concurrent.interpreters` to match. + +### `pytest-timeout` dep + `skipon_spawn_backend` marker plumbing + +Added `pytest-timeout>=2.3` to the `testing` dep group with +an inline comment pointing at the `ai/conc-anal/*.md` docs. +Applied `@pytest.mark.timeout(30, method='thread')` (the +`method='thread'` is load-bearing — `signal`-method +`SIGALRM` suffers the same GIL-starvation path that drops +`SIGINT` in the class-A hang pattern) to the three known- +hanging subint tests cataloged in +`subint_sigint_starvation_issue.md`. + +Separately code-reviewed the user's newly-staged +`skipon_spawn_backend` pytest marker implementation in +`tractor/_testing/pytest.py`. Found four bugs: + +1. `modmark.kwargs.get(reason)` called `.get()` with the + *variable* `reason` as the dict key instead of the string + `'reason'` — user-supplied `reason=` was never picked up. + (User had already fixed this locally via `.get('reason', + reason)` by the time my review happened — preserved that + fix.) +2. The module-level `pytestmark` branch suppressed per-test + marker handling (the `else:` was an `else:` rather than + independent iteration). +3. `mod_pytestmark.mark` assumed a single + `MarkDecorator` — broke on the valid-pytest `pytestmark = + [mark, mark]` list form. +4. Typo: `pytest.Makr` → `pytest.Mark`. + +Refactored the hook to use `item.iter_markers(name=...)` +which walks function + class + module scopes uniformly and +handles both `pytestmark` forms natively. ~30 LOC replaced +the original ~30 LOC of nested conditionals, all four bugs +dissolved. Also updated the marker help string to reflect +the variadic `*start_methods` + `reason=` surface. + +### `subint_fork_proc` prototype attempt + +User's hypothesis: the known trio+`fork()` issues +(python-trio/trio#1614) could be sidestepped by using a +sub-interpreter purely as a launchpad — `os.fork()` from a +subint that has never imported trio → child is in a +trio-free context. In the child `execv()` back into +`python -m tractor._child` and the downstream handshake +matches `trio_proc()` identically. + +Drafted the prototype at `tractor/spawn/_subint.py`'s bottom +(originally — later moved to its own submod, see below): +launchpad-subint creation, bootstrap code-string with +`os.fork()` + `execv()`, driver-thread orchestration, +parent-side `ipc_server.wait_for_peer()` dance. Registered +`'subint_fork'` as a new `SpawnMethodKey` literal, added +`case 'subint' | 'subint_fork':` feature-gate arm in +`try_set_start_method()`, added entry in `_methods` dict. + +### CPython-level block discovered + +User tested on py3.14 and saw: + +``` +Fatal Python error: _PyInterpreterState_DeleteExceptMain: not main interpreter +Python runtime state: initialized + +Current thread 0x00007f6b71a456c0 [subint-fork-lau] (most recent call first): + File "