Compare commits
152 Commits
5e5d785b8a
...
d6e3351372
| Author | SHA1 | Date |
|---|---|---|
|
|
d6e3351372 | |
|
|
3a5ad5a4a0 | |
|
|
cf56cb60ed | |
|
|
ee79939dd3 | |
|
|
a82607f3f9 | |
|
|
70a6f61bdc | |
|
|
bcad6b090e | |
|
|
5dfb3df8f5 | |
|
|
6683e3ffa4 | |
|
|
5bad5bdd10 | |
|
|
4c186f8d5e | |
|
|
a722d535d9 | |
|
|
90466ec016 | |
|
|
2fdf3901cb | |
|
|
084f0fc404 | |
|
|
c797bcb783 | |
|
|
7b518fe4e1 | |
|
|
b0ac681245 | |
|
|
fdac157d3d | |
|
|
d28173f4c0 | |
|
|
f08a7d52b5 | |
|
|
41b5371473 | |
|
|
6d45581910 | |
|
|
6a7dea45bb | |
|
|
fa0208e65f | |
|
|
b6bf865f5e | |
|
|
8bb9df9e06 | |
|
|
944262d8a6 | |
|
|
b57d095204 | |
|
|
4b70b564c8 | |
|
|
da582a4d1b | |
|
|
30242d03fb | |
|
|
fad1227d7c | |
|
|
c8a77fb92b | |
|
|
68698afac7 | |
|
|
98522661d6 | |
|
|
dfe853e159 | |
|
|
f0a1971814 | |
|
|
46c1147f6e | |
|
|
da2d5bb3d2 | |
|
|
36ad56843e | |
|
|
acb042ec77 | |
|
|
75c87831cb | |
|
|
105f0c2944 | |
|
|
48a0f2fc49 | |
|
|
d70e003184 | |
|
|
aced458350 | |
|
|
42881b3d38 | |
|
|
f5940de5c0 | |
|
|
9f9a536fa9 | |
|
|
f0b944f8db | |
|
|
5f2e89ed1f | |
|
|
3c6ea8009b | |
|
|
338f0a1463 | |
|
|
6b14c3dbe7 | |
|
|
2cd668dcc8 | |
|
|
9426fea5bd | |
|
|
e9b63e8569 | |
|
|
4c600dc528 | |
|
|
254a46c345 | |
|
|
f500bebbe6 | |
|
|
a0456fece4 | |
|
|
0c64e76bf9 | |
|
|
84d52835ab | |
|
|
cc5be294b1 | |
|
|
d8a398f6f5 | |
|
|
e873bb6164 | |
|
|
63c6da9e82 | |
|
|
272b6686ae | |
|
|
797d6cf4fa | |
|
|
98fdaf343d | |
|
|
73c9b9ec93 | |
|
|
a4f6d893ed | |
|
|
8589a796c1 | |
|
|
afa14b01d2 | |
|
|
836270e995 | |
|
|
02177d96c4 | |
|
|
b13d06af80 | |
|
|
99098c5a14 | |
|
|
158506d8c0 | |
|
|
e1da7be4d9 | |
|
|
af72192461 | |
|
|
831cb6fcf0 | |
|
|
80048ff6ad | |
|
|
f9e64d0035 | |
|
|
5d37acb5b8 | |
|
|
d4eac06de2 | |
|
|
950b6bc6b8 | |
|
|
c31b85587a | |
|
|
1d185e4d94 | |
|
|
dacd7a7c57 | |
|
|
c603a6e1a0 | |
|
|
3954d9f527 | |
|
|
13ccbaff60 | |
|
|
ec5141b720 | |
|
|
d24ccaada1 | |
|
|
f7a58b82fe | |
|
|
149e1e1993 | |
|
|
a4554106fc | |
|
|
d5a15b4d70 | |
|
|
e5ca5bb017 | |
|
|
0b6a7aa1a9 | |
|
|
50392e6e78 | |
|
|
f633ebf5c6 | |
|
|
48ace6dd82 | |
|
|
04ffde40f3 | |
|
|
18754f2570 | |
|
|
e70ef50a0c | |
|
|
f68ae75444 | |
|
|
af0b1fb2dc | |
|
|
6bf8364c16 | |
|
|
d328177873 | |
|
|
662a34b994 | |
|
|
353fb82c36 | |
|
|
21b17b6a40 | |
|
|
d5c549a3c3 | |
|
|
239c3fb14d | |
|
|
6afeb6b6ef | |
|
|
b2cc4f502c | |
|
|
fd4caa1903 | |
|
|
253b210bcd | |
|
|
f7dfd37df4 | |
|
|
4b63b7f1cc | |
|
|
54a694d452 | |
|
|
eb3e2b3574 | |
|
|
26f2b23da2 | |
|
|
c0fdfa4c7b | |
|
|
c5feeac4ce | |
|
|
9f1a64fcf7 | |
|
|
6b0cb17c04 | |
|
|
dd651943d1 | |
|
|
cec0731253 | |
|
|
9defec4922 | |
|
|
0a3772c0c1 | |
|
|
3f18caaaac | |
|
|
3dc3f8401c | |
|
|
8c6b7fdb31 | |
|
|
4e9f392ca3 | |
|
|
cda94235f4 | |
|
|
a26619dfea | |
|
|
974cb159e8 | |
|
|
c170007ddc | |
|
|
fc3ab9561b | |
|
|
e5d4d9494b | |
|
|
47ac8c0fef | |
|
|
ba111b3042 | |
|
|
e8e4657e6d | |
|
|
c72dc8c235 | |
|
|
fae2525461 | |
|
|
dca79670aa | |
|
|
bca7f10915 | |
|
|
61f9b62d04 |
|
|
@ -32,7 +32,7 @@
|
||||||
"Bash(UV_PROJECT_ENVIRONMENT=py* uv run:*)",
|
"Bash(UV_PROJECT_ENVIRONMENT=py* uv run:*)",
|
||||||
"Bash(echo EXIT:$?:*)",
|
"Bash(echo EXIT:$?:*)",
|
||||||
"Bash(echo \"EXIT=$?\")",
|
"Bash(echo \"EXIT=$?\")",
|
||||||
"Read(//tmp/**)"
|
"Read(/tmp/**)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ allowed-tools:
|
||||||
- Bash(ls *)
|
- Bash(ls *)
|
||||||
- Bash(cat *)
|
- Bash(cat *)
|
||||||
- Bash(jq * .pytest_cache/*)
|
- Bash(jq * .pytest_cache/*)
|
||||||
|
# process inspection + SIGINT-first cleanup ladder (see
|
||||||
|
# the zombie-actor pre-flight / teardown steps below).
|
||||||
|
- Bash(ss *)
|
||||||
|
- Bash(pgrep *)
|
||||||
|
- Bash(pkill *)
|
||||||
|
- Bash(sleep *)
|
||||||
|
- Bash(rm -f /tmp/registry@*.sock)
|
||||||
- Read
|
- Read
|
||||||
- Grep
|
- Grep
|
||||||
- Glob
|
- Glob
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,12 @@ jobs:
|
||||||
run: uv build --sdist --python=3.13
|
run: uv build --sdist --python=3.13
|
||||||
|
|
||||||
- name: Install sdist from .tar.gz
|
- name: Install sdist from .tar.gz
|
||||||
run: python -m pip install dist/*.tar.gz
|
# XXX must install under py3.13 (matching the build's
|
||||||
|
# `--python=3.13`); the runner's default `python` is 3.12
|
||||||
|
# which our `requires-python = ">=3.13"` now rejects.
|
||||||
|
run: |
|
||||||
|
uv venv --python 3.13
|
||||||
|
uv pip install dist/*.tar.gz
|
||||||
|
|
||||||
# ------ type-check ------
|
# ------ type-check ------
|
||||||
# mypy:
|
# mypy:
|
||||||
|
|
@ -83,27 +88,10 @@ jobs:
|
||||||
|
|
||||||
|
|
||||||
testing:
|
testing:
|
||||||
name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }} capture=${{ matrix.capture }}'
|
name: '${{ matrix.os }} Python${{ matrix.python-version }} spawn_backend=${{ matrix.spawn_backend }} tpt_proto=${{ matrix.tpt_proto }}'
|
||||||
timeout-minutes: 20
|
timeout-minutes: 16
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
# NOTE on the matrix shape — the `capture=` mode follows
|
|
||||||
# `spawn_backend`:
|
|
||||||
#
|
|
||||||
# - `trio` / `mp_*` backends use `--capture=fd` (default)
|
|
||||||
# for per-test attribution of subactor *raw-fd* output
|
|
||||||
# in failure reports.
|
|
||||||
# - Fork-based backends (`main_thread_forkserver`,
|
|
||||||
# `subint_forkserver`) REQUIRE `--capture=sys` because
|
|
||||||
# fork-child × `--capture=fd` is a known deadlock
|
|
||||||
# pattern. See the long NOTE in `tractor._testing.pytest`'s
|
|
||||||
# `pytest_load_initial_conftests` for the mechanism +
|
|
||||||
# tradeoff write-up.
|
|
||||||
#
|
|
||||||
# If a future matrix row adds a fork-spawn backend
|
|
||||||
# WITHOUT setting `capture: 'sys'`, the
|
|
||||||
# `pytest_load_initial_conftests` hook fail-fasts on `CI=1`
|
|
||||||
# with a clear error msg. So the matrix is self-policing.
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -130,26 +118,6 @@ jobs:
|
||||||
'tcp',
|
'tcp',
|
||||||
'uds',
|
'uds',
|
||||||
]
|
]
|
||||||
capture: [
|
|
||||||
'fd', # default for non-fork backends
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fork-based backends — added via `include:` so each
|
|
||||||
# cell carries its REQUIRED `capture: 'sys'` mode.
|
|
||||||
# Linux-only for now; macOS coverage TBD pending
|
|
||||||
# local validation.
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
python-version: '3.13'
|
|
||||||
spawn_backend: 'main_thread_forkserver'
|
|
||||||
tpt_proto: 'tcp'
|
|
||||||
capture: 'sys'
|
|
||||||
- os: ubuntu-latest
|
|
||||||
python-version: '3.13'
|
|
||||||
spawn_backend: 'main_thread_forkserver'
|
|
||||||
tpt_proto: 'uds'
|
|
||||||
capture: 'sys'
|
|
||||||
|
|
||||||
# https://github.com/orgs/community/discussions/26253#discussioncomment-3250989
|
# https://github.com/orgs/community/discussions/26253#discussioncomment-3250989
|
||||||
exclude:
|
exclude:
|
||||||
# don't do UDS run on macOS (for now)
|
# don't do UDS run on macOS (for now)
|
||||||
|
|
@ -190,11 +158,7 @@ jobs:
|
||||||
-rsx
|
-rsx
|
||||||
--spawn-backend=${{ matrix.spawn_backend }}
|
--spawn-backend=${{ matrix.spawn_backend }}
|
||||||
--tpt-proto=${{ matrix.tpt_proto }}
|
--tpt-proto=${{ matrix.tpt_proto }}
|
||||||
--capture=${{ matrix.capture }}
|
--capture=fd
|
||||||
# NOTE: capture mode is matrix-driven — `fd` for
|
|
||||||
# non-fork backends (per-test fd attribution),
|
|
||||||
# `sys` for fork-based (avoids fork-child x
|
|
||||||
# capture-fd deadlock). See matrix-NOTE above.
|
|
||||||
|
|
||||||
# XXX legacy NOTE XXX
|
# XXX legacy NOTE XXX
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
# Cancel-cascade `trio.TooSlowError` flakes under `main_thread_forkserver`
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Running the full test suite under
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./py313/bin/python -m pytest tests/ \
|
|
||||||
--tpt-proto=tcp \
|
|
||||||
--spawn-backend=main_thread_forkserver
|
|
||||||
```
|
|
||||||
|
|
||||||
surfaces a single, **rotating** `trio.TooSlowError`
|
|
||||||
failure each run. The failure isn't deterministic on
|
|
||||||
test identity — different test each run — but it
|
|
||||||
ALWAYS looks like:
|
|
||||||
|
|
||||||
```
|
|
||||||
FAILED tests/<file>::test_<name> - trio.TooSlowError
|
|
||||||
==== 1 failed, 373 passed, 17 skipped, 11–12 xfailed,
|
|
||||||
0–1 xpassed, ~550 warnings in ~6min ====
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass rate: **~99.7%** (373 of 374 non-skip tests).
|
|
||||||
Wall-clock per full run: 5–6 min.
|
|
||||||
|
|
||||||
## Tests observed flaking so far
|
|
||||||
|
|
||||||
Each row was the SOLE failure in a separate run:
|
|
||||||
|
|
||||||
| run # | test |
|
|
||||||
|---|---|
|
|
||||||
| 1 | `tests/test_advanced_streaming.py::test_dynamic_pub_sub[KeyboardInterrupt]` |
|
|
||||||
| 2 | `tests/test_infected_asyncio.py::test_context_spawns_aio_task_that_errors[parent_actor_cancels_child=False]` |
|
|
||||||
|
|
||||||
Both share the same shape:
|
|
||||||
|
|
||||||
- **Cancel cascade** of N subactors back to a parent root actor.
|
|
||||||
- N ≥ `multiprocessing.cpu_count()` for `test_dynamic_pub_sub`
|
|
||||||
(it spawns `cpus - 1` consumers + publisher + dynamic-consumer).
|
|
||||||
- N ≈ 2 for `test_context_spawns_aio_task_that_errors` —
|
|
||||||
but each subactor is `infect_asyncio=True`, so each
|
|
||||||
cancel involves the trio↔asyncio guest-run unwind
|
|
||||||
which is structurally heavier than pure-trio.
|
|
||||||
- Test wraps the cascade in `trio.fail_after(N seconds)`
|
|
||||||
and the cap fires before the cascade completes.
|
|
||||||
|
|
||||||
The exact failing test rotates because each test is
|
|
||||||
independently close to the cap; whichever happens to
|
|
||||||
be unlucky in scheduling/CPU-contention on a given run
|
|
||||||
is the one that times out.
|
|
||||||
|
|
||||||
## Root-cause family
|
|
||||||
|
|
||||||
`hard_kill` (`tractor/spawn/_spawn.py:hard_kill`) runs
|
|
||||||
the SC-graceful teardown ladder per subactor:
|
|
||||||
|
|
||||||
1. `Portal.cancel_actor()` — graceful IPC cancel-req.
|
|
||||||
2. Wait `terminate_after=1.6s` for sub to exit.
|
|
||||||
3. If still alive: `proc.kill()` (SIGKILL).
|
|
||||||
4. (NEW) `_unlink_uds_bind_addrs()` — post-mortem
|
|
||||||
sock-file cleanup for UDS leaks (issue #452 fix).
|
|
||||||
|
|
||||||
For a cascade of N subactors, each pays steps 1–4. If
|
|
||||||
graceful-cancel doesn't complete within 1.6s for ANY
|
|
||||||
sub, that sub eats a full 1.6s of `move_on_after` plus
|
|
||||||
the `proc.wait()` post-SIGKILL.
|
|
||||||
|
|
||||||
Worst case under fork backend with N=cpus subs:
|
|
||||||
- N × 1.6s = 16s+ on a 10-core box just for the
|
|
||||||
graceful timeout phase
|
|
||||||
- Plus per-spawn fork-IPC handshake cost compounds
|
|
||||||
during teardown (each sub's IPC cleanup goes through
|
|
||||||
the same forkserver coordinator)
|
|
||||||
- Plus the new autouse fixtures
|
|
||||||
(`_track_orphaned_uds_per_test`,
|
|
||||||
`_detect_runaway_subactors_per_test`,
|
|
||||||
`_reap_orphaned_subactors`) all run at test
|
|
||||||
teardown, adding small (10s of ms) but cumulative
|
|
||||||
overhead
|
|
||||||
|
|
||||||
Current cap: 30s (`fail_after_s = 30 if
|
|
||||||
is_forking_spawner else 12`). Empirically fits the
|
|
||||||
median run but the tail breaks ~0.3% of the time.
|
|
||||||
|
|
||||||
## NOT regressing
|
|
||||||
|
|
||||||
To confirm this is a flake and not a regression:
|
|
||||||
|
|
||||||
- Pre-`WakeupSocketpair`-patch baseline: tests
|
|
||||||
HUNG INDEFINITELY (busy-loop never released).
|
|
||||||
- Post-patch: pass-or-fail-fast, ~99.7% pass, the
|
|
||||||
occasional cap-hit fails in bounded time (<60s for
|
|
||||||
the offending test).
|
|
||||||
- Same test PASSES under `--spawn-backend=trio`
|
|
||||||
(no fork, no hard-kill compounding).
|
|
||||||
|
|
||||||
So the suite is dramatically better than before; the
|
|
||||||
remaining flake is a known-tolerable steady-state.
|
|
||||||
|
|
||||||
## Possible mitigations (ranked)
|
|
||||||
|
|
||||||
### A. Bump the cap further
|
|
||||||
|
|
||||||
Cheapest. Change the per-test `fail_after_s` from 30
|
|
||||||
to e.g. 60 for fork backends. Pros: trivial. Cons:
|
|
||||||
masks any genuine slowness regression we'd want to
|
|
||||||
catch.
|
|
||||||
|
|
||||||
### B. CPU-count-aware cap
|
|
||||||
|
|
||||||
For tests whose N scales with `cpu_count()`, scale
|
|
||||||
the cap too:
|
|
||||||
|
|
||||||
```python
|
|
||||||
fail_after_s = (
|
|
||||||
max(30, cpu_count() * 3) # 3s/actor floor
|
|
||||||
if is_forking_spawner
|
|
||||||
else 12
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pros: scales with the actual cancel-cascade work.
|
|
||||||
Cons: still arbitrary multiplier.
|
|
||||||
|
|
||||||
### C. `pytest-rerunfailures` for these tests only
|
|
||||||
|
|
||||||
Mark the known-flaky tests with
|
|
||||||
`@pytest.mark.flaky(reruns=1)` (needs
|
|
||||||
`pytest-rerunfailures` dep). Single retry hides
|
|
||||||
genuine ~0.3% transient flakes.
|
|
||||||
|
|
||||||
Pros: no cap change, surfaces persistent failures
|
|
||||||
loudly. Cons: adds a dep, retries can mask real bugs
|
|
||||||
if used widely.
|
|
||||||
|
|
||||||
### D. Reduce `hard_kill`'s `terminate_after`
|
|
||||||
|
|
||||||
Drop from 1.6s → 0.8s. Cuts the worst-case cascade
|
|
||||||
time roughly in half. Risks: fewer subs get a chance
|
|
||||||
to run their cleanup before SIGKILL → more orphaned
|
|
||||||
state for the autouse reapers to handle (ironically,
|
|
||||||
adds back overhead elsewhere).
|
|
||||||
|
|
||||||
### E. Profile + targeted fix
|
|
||||||
|
|
||||||
Add `log.devx()` markers in `hard_kill` to time each
|
|
||||||
phase. Identify if any subactor is consistently
|
|
||||||
hitting the 1.6s cap (vs. exiting in <0.1s). If so,
|
|
||||||
that sub has a teardown bug worth fixing at source.
|
|
||||||
Pros: actually fixes the underlying slowness. Cons:
|
|
||||||
real investigation work, deferred from this round.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Land this issue-doc as the tracker. Apply **(B)** as
|
|
||||||
a small follow-up — cheap and proportional. If it
|
|
||||||
still flakes, escalate to **(E)** with a `log.devx()`
|
|
||||||
profile-pass.
|
|
||||||
|
|
||||||
`(C)` is a backstop if `(B)` doesn't quite get there
|
|
||||||
and we need green CI faster than (E) can deliver.
|
|
||||||
|
|
||||||
## Verification protocol
|
|
||||||
|
|
||||||
After applying any mitigation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the suite N times back-to-back, count failures.
|
|
||||||
# A persistent failure on the SAME test == real bug.
|
|
||||||
# Failures rotating across tests == still cap-related.
|
|
||||||
|
|
||||||
for i in $(seq 1 5); do
|
|
||||||
./py313/bin/python -m pytest tests/ \
|
|
||||||
--tpt-proto=tcp \
|
|
||||||
--spawn-backend=main_thread_forkserver \
|
|
||||||
-q 2>&1 | tail -2
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Target: 0 failures across 5 runs ⇒ ship. 1–2 failures
|
|
||||||
still rotating ⇒ apply (C). Same test failing twice
|
|
||||||
⇒ escalate to (E).
|
|
||||||
|
|
||||||
## Snapshot evidence (2026-05-13)
|
|
||||||
|
|
||||||
After landing the `fail_after_w_trace` /
|
|
||||||
`afk_alarm_w_trace` capture-on-timeout helpers
|
|
||||||
(`tractor._testing.trace`), `test_nested_multierrors`
|
|
||||||
on the `main_thread_forkserver` backend produces
|
|
||||||
**reproducible diag snapshots** at
|
|
||||||
`$XDG_CACHE_HOME/tractor/hung-dumps/test_nested_multierrors_start_method_main_thread_forkserver__<iso-ts>/`.
|
|
||||||
|
|
||||||
### Reproduction
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest \
|
|
||||||
-v --verbose --durations=10 \
|
|
||||||
--spawn-backend=main_thread_forkserver \
|
|
||||||
--tpt-proto=uds \
|
|
||||||
--capture=sys --show-capture=stderr -rxX \
|
|
||||||
tests/test_cancellation.py::test_nested_multierrors
|
|
||||||
```
|
|
||||||
|
|
||||||
The test is `xfail(strict=False)` for MTF — it RUNS
|
|
||||||
each invocation so snapshots accumulate, but doesn't
|
|
||||||
break `--lf` workflow.
|
|
||||||
|
|
||||||
### Consistent shape across runs
|
|
||||||
|
|
||||||
5+ snapshots taken back-to-back show the SAME pattern:
|
|
||||||
|
|
||||||
- **Timing:** ~10s wall-clock total. Inner
|
|
||||||
`fail_after_w_trace(10)` fires at exactly T=10s;
|
|
||||||
cascade's `nursery.__aexit__` takes ~0.6s more to
|
|
||||||
gather + propagate the resulting
|
|
||||||
`BaseExceptionGroup`. **Trio backend completes the
|
|
||||||
SAME test in <6s** — so the MTF cascade is ~2x
|
|
||||||
slower at minimum.
|
|
||||||
|
|
||||||
- **`BaseExceptionGroup` shape:** mixed
|
|
||||||
`[RemoteActorError, Cancelled]`. The first
|
|
||||||
subactor's natural error-propagation (`assert 0`
|
|
||||||
raised → `RemoteActorError` portal-result)
|
|
||||||
arrives before T=10s; the OTHER subactor's
|
|
||||||
portal-wait is still in flight at T=10s, gets
|
|
||||||
cancelled by `fail_after_w_trace`'s scope-cancel
|
|
||||||
→ returns `Cancelled` instead.
|
|
||||||
|
|
||||||
- **Orphan-spawn skew:** snapshot's `orphans` bucket
|
|
||||||
(after the `_is_tractor_subactor` cgroup-slice
|
|
||||||
override fix) consistently shows 2-4 init-adopted
|
|
||||||
procs at `depth_3` and `depth_1` levels — these
|
|
||||||
are the leaves whose parent (`depth_2` spawner)
|
|
||||||
was killed mid-cascade but who hadn't yet seen
|
|
||||||
the cancel signal themselves.
|
|
||||||
|
|
||||||
- **UDS sock-leak:** 2-6 dead-orphan socks per run
|
|
||||||
(varies with cascade timing). The
|
|
||||||
`track_orphaned_uds_per_test` fixture reaps them
|
|
||||||
post-test → contamination is isolated per-invocation.
|
|
||||||
|
|
||||||
### Capture mechanism
|
|
||||||
|
|
||||||
`fail_after_w_trace` covers two firing paths:
|
|
||||||
|
|
||||||
1. **`trio.TooSlowError`** raised at scope-exit
|
|
||||||
(body returned cleanly past deadline) — direct
|
|
||||||
`except` handler captures.
|
|
||||||
|
|
||||||
2. **Scope-cancel + body raises non-`Cancelled` exc**
|
|
||||||
(e.g. `nursery.__aexit__` wraps timeout-induced
|
|
||||||
`Cancelled` into a `BaseExceptionGroup` that
|
|
||||||
escapes before `trio.fail_after`'s exit-check
|
|
||||||
could fire `TooSlowError`) — body-raise `except`
|
|
||||||
handler checks `scope.cancel_called` and
|
|
||||||
captures if True. This path catches the
|
|
||||||
`test_nested_multierrors` shape specifically (see
|
|
||||||
"BaseExceptionGroup shape" above).
|
|
||||||
|
|
||||||
The snapshot dir contains:
|
|
||||||
- `trace.txt` — `ptree` + `hung_state` (kernel
|
|
||||||
`wchan`/`stack` + `py-spy dump --locals` when
|
|
||||||
sudo cached), with `include_strays=True`
|
|
||||||
surfacing any cross-test ghost subactor trees in
|
|
||||||
the `orphans` bucket.
|
|
||||||
- `bindspace.txt` — UDS bindspace classification
|
|
||||||
(live-active / orphaned-alive / orphaned-dead).
|
|
||||||
- `meta.json` — `{pid, label, captured_at, sudo_cached}`.
|
|
||||||
|
|
||||||
The end-of-session `pytest_terminal_summary` hook
|
|
||||||
in `tractor._testing.pytest` lists every snapshot
|
|
||||||
dir from the run so you don't have to scroll back
|
|
||||||
through captured-stderr lines:
|
|
||||||
|
|
||||||
```
|
|
||||||
========================= tractor hang-snapshot index ==========================
|
|
||||||
N `fail_after_w_trace` / `afk_alarm_w_trace` snapshot(s) captured this session:
|
|
||||||
<test-id>
|
|
||||||
→ /home/.../.cache/tractor/hung-dumps/<label>__<ts>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Caveats
|
|
||||||
|
|
||||||
The snapshot fires AFTER the body-raise (not at the
|
|
||||||
exact moment of scope-cancel), so the parent's
|
|
||||||
py-spy frames show `_do_capture_snapshot` itself
|
|
||||||
running, NOT the cancel-cascade hang frame. To see
|
|
||||||
the actual hang state, manual `acli.ptree` /
|
|
||||||
`acli.hung_dump` from a second terminal at T=10s
|
|
||||||
would be needed — **not currently possible**
|
|
||||||
because per-test reaper fixtures clean up ~0.6s
|
|
||||||
post-timeout. See follow-up TODO in
|
|
||||||
`tractor/_testing/trace.py` for a
|
|
||||||
`TRACTOR_TRACE_HOLD=1` env-var pause mode.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [#452](https://github.com/goodboy/tractor/issues/452) —
|
|
||||||
UDS sock-file leak (related — `hard_kill`'s
|
|
||||||
cleanup phase contributes to cascade time)
|
|
||||||
- `ai/conc-anal/trio_wakeup_socketpair_busy_loop_under_fork_issue.md`
|
|
||||||
— the upstream-trio fix that turned this from a
|
|
||||||
100% hang into a 0.3% flake
|
|
||||||
- `ai/conc-anal/infected_asyncio_under_main_thread_forkserver_hang_issue.md`
|
|
||||||
— the asyncio variant which contributes to one of
|
|
||||||
the rotating failures
|
|
||||||
- `tractor/spawn/_spawn.py::hard_kill` — the SIGKILL
|
|
||||||
cascade source
|
|
||||||
- `tractor/_testing/_reap.py::_track_orphaned_uds_per_test`,
|
|
||||||
`_detect_runaway_subactors_per_test`,
|
|
||||||
`_reap_orphaned_subactors` — autouse cleanup
|
|
||||||
fixtures whose cumulative teardown overhead
|
|
||||||
contributes to the cascade time
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
# `infect_asyncio` × `main_thread_forkserver` Mode-A deadlock
|
|
||||||
|
|
||||||
## Reproducer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./py313/bin/python -m pytest \
|
|
||||||
tests/test_infected_asyncio.py::test_aio_simple_error \
|
|
||||||
--tpt-proto=tcp \
|
|
||||||
--spawn-backend=main_thread_forkserver \
|
|
||||||
-v --capture=sys
|
|
||||||
```
|
|
||||||
|
|
||||||
Hangs indefinitely. Mode-A signature — both processes
|
|
||||||
parked in `epoll_wait`, **neither burning CPU**.
|
|
||||||
|
|
||||||
## Empirical observations (caught alive)
|
|
||||||
|
|
||||||
### Outer pytest (parent)
|
|
||||||
|
|
||||||
`py-spy dump` on the test runner pid shows the trio
|
|
||||||
event loop parked at the bottom of `trio.run()`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Thread <pid> (idle): "MainThread"
|
|
||||||
get_events (trio/_core/_io_epoll.py:245)
|
|
||||||
self: <EpollIOManager at 0x...>
|
|
||||||
timeout: 86400
|
|
||||||
run (trio/_core/_run.py:2415)
|
|
||||||
next_send: []
|
|
||||||
timeout: 86400
|
|
||||||
test_aio_simple_error (tests/test_infected_asyncio.py:175)
|
|
||||||
```
|
|
||||||
|
|
||||||
`timeout: 86400` is trio's "no scheduled work, just wait
|
|
||||||
for I/O forever" sentinel. `next_send: []` confirms
|
|
||||||
nothing is queued. The parent is stuck inside
|
|
||||||
`tractor.open_nursery(...).run_in_actor(...)` waiting
|
|
||||||
for `ipc_server.wait_for_peer(uid)` to fire — i.e.
|
|
||||||
waiting for the spawned subactor to connect back.
|
|
||||||
|
|
||||||
### Subactor (forked child)
|
|
||||||
|
|
||||||
`/proc/<pid>/stack`:
|
|
||||||
|
|
||||||
```
|
|
||||||
do_epoll_wait+0x4c0/0x500
|
|
||||||
__x64_sys_epoll_wait+0x70/0x120
|
|
||||||
do_syscall_64+0xef/0x1540
|
|
||||||
entry_SYSCALL_64_after_hwframe+0x77/0x7f
|
|
||||||
```
|
|
||||||
|
|
||||||
`strace -p <pid> -f`:
|
|
||||||
|
|
||||||
```
|
|
||||||
[pid <child-A>] epoll_wait(6 <unfinished ...>
|
|
||||||
[pid <child-B>] epoll_wait(3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two threads**, both parked in `epoll_wait` on
|
|
||||||
distinct epoll fds. Both blocked, neither making
|
|
||||||
progress.
|
|
||||||
|
|
||||||
### Subactor file-descriptor table
|
|
||||||
|
|
||||||
```
|
|
||||||
fd=0,1,2 stdio
|
|
||||||
fd=3 eventpoll [watches fd 4]
|
|
||||||
fd=4 ↔ fd=5 unix STREAM (CONNECTED) — internal pair
|
|
||||||
fd=6 eventpoll [watches fds 7, 9]
|
|
||||||
fd=7 ↔ fd=8 unix STREAM (CONNECTED) — internal pair
|
|
||||||
fd=9 ↔ fd=10 unix STREAM (CONNECTED) — internal pair
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirmed via `ss -xp` peer-inode lookup: **all 6 unix
|
|
||||||
sockets are internal socketpairs** (peer in same pid).
|
|
||||||
|
|
||||||
**Critical**: zero TCP/IPv4/IPv6 sockets, despite
|
|
||||||
`--tpt-proto=tcp`:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ sudo lsof -p <subactor> | grep -iE 'TCP|IPv'
|
|
||||||
(empty)
|
|
||||||
$ sudo ss -tnp | grep <subactor>
|
|
||||||
(empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
**The subactor never opened a TCP connection back to
|
|
||||||
the parent.**
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
The subactor reaches `_actor_child_main` →
|
|
||||||
`_trio_main(actor)` →
|
|
||||||
`run_as_asyncio_guest(trio_main)`. Code path
|
|
||||||
(`tractor.spawn._entry`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if infect_asyncio:
|
|
||||||
actor._infected_aio = True
|
|
||||||
run_as_asyncio_guest(trio_main) # ← this branch
|
|
||||||
else:
|
|
||||||
trio.run(trio_main)
|
|
||||||
```
|
|
||||||
|
|
||||||
`run_as_asyncio_guest` (`tractor.to_asyncio`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def run_as_asyncio_guest(trio_main, ...):
|
|
||||||
async def aio_main(trio_main):
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
trio_done_fute = asyncio.Future()
|
|
||||||
...
|
|
||||||
trio.lowlevel.start_guest_run(
|
|
||||||
trio_main,
|
|
||||||
run_sync_soon_threadsafe=loop.call_soon_threadsafe,
|
|
||||||
done_callback=trio_done_callback,
|
|
||||||
)
|
|
||||||
out = await asyncio.shield(trio_done_fute)
|
|
||||||
return out.unwrap()
|
|
||||||
...
|
|
||||||
return asyncio.run(aio_main(trio_main))
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected flow:
|
|
||||||
1. `asyncio.run(aio_main(...))` — boots fresh asyncio
|
|
||||||
loop in calling thread.
|
|
||||||
2. `aio_main` calls `trio.lowlevel.start_guest_run(...)`
|
|
||||||
— initializes trio's I/O manager, schedules first
|
|
||||||
trio slice via `loop.call_soon_threadsafe`.
|
|
||||||
3. asyncio loop dispatches the callback → trio runs a
|
|
||||||
slice → yields back via `call_soon_threadsafe`.
|
|
||||||
4. Trio's `async_main` (the user function) runs →
|
|
||||||
`Channel.from_addr(parent_addr)` → TCP connect to
|
|
||||||
parent.
|
|
||||||
|
|
||||||
What we observe instead:
|
|
||||||
- 2 threads in `epoll_wait` (one trio epoll, one
|
|
||||||
asyncio epoll, both inactive)
|
|
||||||
- 6 unix-socket fds (3 socketpairs: trio
|
|
||||||
wakeup-fd-pair, asyncio wakeup-fd-pair, trio kicker
|
|
||||||
socketpair)
|
|
||||||
- ZERO TCP — `Channel.from_addr` never ran
|
|
||||||
|
|
||||||
Most likely cause: **trio's guest-run scheduling
|
|
||||||
callback didn't get dispatched by asyncio's loop in
|
|
||||||
the forked child**, so trio's `async_main` never
|
|
||||||
executes past trio bootstrap, and the
|
|
||||||
parent-IPC-connect step is never reached.
|
|
||||||
|
|
||||||
## Fork-survival risk surface (hypothesis)
|
|
||||||
|
|
||||||
`trio.lowlevel.start_guest_run` builds Python-level
|
|
||||||
closures + signal handlers + wakeup-fd registrations
|
|
||||||
that depend on:
|
|
||||||
|
|
||||||
- The asyncio event loop's `call_soon_threadsafe`
|
|
||||||
thread-id matching the loop owner thread.
|
|
||||||
- Process-wide signal-wakeup-fd state
|
|
||||||
(`signal.set_wakeup_fd`).
|
|
||||||
- Trio's `KIManager` SIGINT handler.
|
|
||||||
|
|
||||||
Under `main_thread_forkserver`, the fork happens from
|
|
||||||
a worker thread that has **never entered trio**
|
|
||||||
(intentional — trio-free launchpad). But the FORKED
|
|
||||||
child then tries to bring up BOTH asyncio AND
|
|
||||||
trio-as-guest fresh from this trio-free thread. The
|
|
||||||
asyncio loop boots fine; trio's `start_guest_run`
|
|
||||||
initializes BUT the cross-loop dispatch (asyncio
|
|
||||||
queue → trio slice) appears to silently fail to wire
|
|
||||||
up.
|
|
||||||
|
|
||||||
Two more hypotheses worth probing:
|
|
||||||
|
|
||||||
1. **Wakeup-fd contention**: asyncio installs
|
|
||||||
`signal.set_wakeup_fd(<own_pair>)`. trio's
|
|
||||||
guest-run also wants a wakeup-fd. Whoever installs
|
|
||||||
second wins; the loser's `epoll_wait` no longer
|
|
||||||
wakes on signals. Combined with the `asyncio.shield(
|
|
||||||
trio_done_fute)` + `asyncio.CancelledError`
|
|
||||||
handling in `run_as_asyncio_guest`, a missed signal
|
|
||||||
delivery could explain the indefinite park.
|
|
||||||
|
|
||||||
2. **Trio kicker socketpair race**: trio's I/O manager
|
|
||||||
uses an internal `socket.socketpair()` to "kick"
|
|
||||||
itself out of `epoll_wait` when a non-IO task needs
|
|
||||||
scheduling. In guest mode, the kicker is still
|
|
||||||
present but is supposed to be triggered via the
|
|
||||||
asyncio dispatch. If the kicker write never gets
|
|
||||||
issued by asyncio's callback, trio's epoll never
|
|
||||||
wakes.
|
|
||||||
|
|
||||||
## Confirmed via py-spy (live capture)
|
|
||||||
|
|
||||||
After detaching `strace` (ptrace is exclusive — that's
|
|
||||||
why `py-spy` returns EPERM if strace is attached):
|
|
||||||
|
|
||||||
```
|
|
||||||
Thread <pid> (idle): "main-thread-forkserver[asyncio_actor]"
|
|
||||||
select (selectors.py:452) # asyncio epoll
|
|
||||||
_run_once (asyncio/base_events.py:2012)
|
|
||||||
run_forever (asyncio/base_events.py:683)
|
|
||||||
run_until_complete (asyncio/base_events.py:712)
|
|
||||||
run (asyncio/runners.py:118)
|
|
||||||
run (asyncio/runners.py:195)
|
|
||||||
run_as_asyncio_guest (tractor/to_asyncio.py:1770)
|
|
||||||
_trio_main (tractor/spawn/_entry.py:160)
|
|
||||||
_actor_child_main (tractor/_child.py:72)
|
|
||||||
_child_target (tractor/spawn/_main_thread_forkserver.py:910)
|
|
||||||
_worker (tractor/spawn/_main_thread_forkserver.py:605)
|
|
||||||
[thread bootstrap]
|
|
||||||
|
|
||||||
Thread <pid+1> (idle): "Trio thread 14"
|
|
||||||
get_events (trio/_core/_io_epoll.py:245) # trio epoll
|
|
||||||
get_events (trio/_core/_run.py:1678)
|
|
||||||
capture (outcome/_impl.py:67)
|
|
||||||
_handle_job (trio/_core/_thread_cache.py:173)
|
|
||||||
_work (trio/_core/_thread_cache.py:196)
|
|
||||||
[thread bootstrap]
|
|
||||||
```
|
|
||||||
|
|
||||||
This data **rewrites the diagnosis**: trio guest-run
|
|
||||||
isn't broken across the fork — it's working as designed.
|
|
||||||
The two threads ARE the canonical guest-run architecture:
|
|
||||||
|
|
||||||
1. **Asyncio main loop** runs in the lead thread. Parked
|
|
||||||
in `selectors.EpollSelector.select(timeout=-1)` —
|
|
||||||
waiting indefinitely for ANY callback to be queued.
|
|
||||||
2. **Trio's I/O manager** offloads `get_events`
|
|
||||||
(`epoll_wait`) onto a `trio._core._thread_cache`
|
|
||||||
worker thread. The worker calls
|
|
||||||
`outcome.capture(get_events)` and parks in
|
|
||||||
`epoll_wait(timeout=86400)`.
|
|
||||||
3. When trio I/O fires (or its kicker socketpair gets a
|
|
||||||
write), the worker returns from `epoll_wait`,
|
|
||||||
delivers the result via `_handle_job`'s `deliver`
|
|
||||||
callback, which schedules the next trio slice on
|
|
||||||
asyncio via `loop.call_soon_threadsafe`.
|
|
||||||
|
|
||||||
The fact that the trio thread is *already* in
|
|
||||||
`_thread_cache._handle_job` doing `capture(get_events)`
|
|
||||||
means **trio's scheduler HAS started** — the bridge
|
|
||||||
asyncio↔trio is wired correctly post-fork.
|
|
||||||
|
|
||||||
So `async_main` DID run far enough to register some
|
|
||||||
trio task that's now awaiting I/O. The question
|
|
||||||
becomes: **what is `async_main` waiting on?**
|
|
||||||
|
|
||||||
Process state confirms it's NOT waiting on the TCP
|
|
||||||
connect to parent:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ sudo lsof -p <subactor> | grep -iE 'TCP|IPv'
|
|
||||||
(empty)
|
|
||||||
$ sudo ss -tnp | grep <subactor>
|
|
||||||
(empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Channel.from_addr(parent_addr)` — the very first
|
|
||||||
thing `async_main` does — was never reached, OR was
|
|
||||||
reached but errored before `socket()` was called. The
|
|
||||||
parent (running `ipc_server.wait_for_peer`) waits
|
|
||||||
forever for the connection; it never comes.
|
|
||||||
|
|
||||||
## Refined hypothesis
|
|
||||||
|
|
||||||
`async_main` is stalled in some PRE-`Channel.from_addr`
|
|
||||||
checkpoint. Candidates:
|
|
||||||
|
|
||||||
1. **`get_console_log` / logger init** — called early in
|
|
||||||
`_trio_main` if `actor.loglevel is not None`. Logging
|
|
||||||
setup involves file/handler init that could block on
|
|
||||||
something fork-inherited (e.g. a stale lock).
|
|
||||||
2. **`debug.maybe_init_greenback`** — `start_guest_run`
|
|
||||||
includes a check (`if debug_mode(): assert 0` —
|
|
||||||
currently asserts unsupported). For non-debug mode
|
|
||||||
this is bypassed but related machinery may run.
|
|
||||||
3. **Stackscope SIGUSR1 handler install** — gated on
|
|
||||||
`_debug_mode` OR `TRACTOR_ENABLE_STACKSCOPE` env-var.
|
|
||||||
The `enable_stack_on_sig()` path captures a trio
|
|
||||||
token via `trio.lowlevel.current_trio_token()` —
|
|
||||||
could block under guest mode.
|
|
||||||
4. **Initial `await trio.sleep(0)` / first checkpoint**
|
|
||||||
in `async_main` before reaching the
|
|
||||||
`Channel.from_addr` line. Under guest mode, if the
|
|
||||||
FIRST `call_soon_threadsafe` callback never gets
|
|
||||||
processed by asyncio, trio's first slice never
|
|
||||||
completes — but the worker thread WOULD still be in
|
|
||||||
`epoll_wait` having been started by trio's I/O
|
|
||||||
manager init.
|
|
||||||
|
|
||||||
## Confirming `async_main`'s parked location
|
|
||||||
|
|
||||||
Add temporary logging at the top of `Actor.async_main`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tractor/runtime/_runtime.py around line 855
|
|
||||||
async def async_main(self, parent_addr=None):
|
|
||||||
log.devx('async_main: ENTERED') # marker A
|
|
||||||
try:
|
|
||||||
log.devx('async_main: pre-Channel.from_addr') # marker B
|
|
||||||
chan = await Channel.from_addr(
|
|
||||||
addr=wrap_address(parent_addr)
|
|
||||||
)
|
|
||||||
log.devx('async_main: post-Channel.from_addr') # marker C
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-run the test with `--ll=devx`. The last marker logged
|
|
||||||
tells us exactly where `async_main` parked. If only A
|
|
||||||
fires, the issue is between A and B (logger init,
|
|
||||||
stackscope, etc.). If A and B fire but not C, it's in
|
|
||||||
`Channel.from_addr` (DNS, socket creation, connect).
|
|
||||||
|
|
||||||
## Related sibling bug
|
|
||||||
|
|
||||||
`tests/test_multi_program.py::test_register_duplicate_name`
|
|
||||||
hangs under the same backend with a DIFFERENT
|
|
||||||
fingerprint:
|
|
||||||
|
|
||||||
- Subactor at 100% CPU (busy-loop), not parked
|
|
||||||
- `recvfrom(6, "", 65536, 0, NULL, NULL) = 0` repeating
|
|
||||||
with no `epoll_wait` in between
|
|
||||||
- fd=6 is one of trio's internal AF_UNIX
|
|
||||||
socketpair fds (the kicker mechanism)
|
|
||||||
|
|
||||||
Distinct root cause — possibly trio's kicker socketpair
|
|
||||||
inheriting a half-closed state across the fork — but
|
|
||||||
shares the broader theme: **trio internal-state
|
|
||||||
initialization isn't fully fork-safe under
|
|
||||||
`main_thread_forkserver`** for the more exotic
|
|
||||||
dispatch paths.
|
|
||||||
|
|
||||||
## Workarounds (until fix lands)
|
|
||||||
|
|
||||||
1. **Skip-mark on the fork backend** — temporarily mark
|
|
||||||
`tests/test_infected_asyncio.py` with
|
|
||||||
`pytest.mark.skipon_spawn_backend('main_thread_forkserver',
|
|
||||||
reason='infect_asyncio + fork interaction broken,
|
|
||||||
see ai/conc-anal/infected_asyncio_under_main_thread_forkserver_hang_issue.md')`.
|
|
||||||
Lets the rest of the test suite run green while
|
|
||||||
this is being fixed properly.
|
|
||||||
|
|
||||||
2. **Run infected-asyncio tests under the `trio`
|
|
||||||
backend only** — they don't exercise fork
|
|
||||||
semantics, so they won't hit this bug.
|
|
||||||
|
|
||||||
## Investigation next steps
|
|
||||||
|
|
||||||
In rough priority:
|
|
||||||
|
|
||||||
1. Catch the hang alive again, **detach strace**,
|
|
||||||
`py-spy --locals` the subactor — confirm trio
|
|
||||||
thread is NOT yet at `async_main`.
|
|
||||||
2. Diff `start_guest_run` setup pre-fork vs post-fork
|
|
||||||
by adding `log.devx()` markers in
|
|
||||||
`tractor.to_asyncio.run_as_asyncio_guest::aio_main`
|
|
||||||
at:
|
|
||||||
- asyncio loop bringup
|
|
||||||
- immediately before `start_guest_run`
|
|
||||||
- immediately after `start_guest_run`
|
|
||||||
- inside the `trio_done_callback` registration
|
|
||||||
3. Check whether the asyncio loop dispatches ANY
|
|
||||||
callbacks in the forked child — instrument
|
|
||||||
`loop.call_soon_threadsafe` (e.g. monkey-patch
|
|
||||||
`loop._call_soon` to log).
|
|
||||||
4. If steps 1–3 confirm that asyncio's queue is
|
|
||||||
stuck, look at whether the asyncio event-loop
|
|
||||||
policy or selector is being inherited from a
|
|
||||||
pre-fork (parent-process) state in a way that
|
|
||||||
breaks the new loop.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [#379](https://github.com/goodboy/tractor/issues/379) — subint umbrella
|
|
||||||
- [#451](https://github.com/goodboy/tractor/issues/451) — Mode-A cancel-cascade hang
|
|
||||||
- `ai/conc-anal/fork_thread_semantics_execution_vs_memory.md`
|
|
||||||
- `ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`
|
|
||||||
- python-trio/trio#1614 — trio + fork hazards
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
# `subint` backend: parent trio loop parks after subint teardown (Ctrl-C works; not a CPython-level issue)
|
|
||||||
|
|
||||||
Follow-up to the Phase B subint spawn-backend PR (see
|
|
||||||
`tractor.spawn._subint`, issue #379). Distinct from the
|
|
||||||
`subint_sigint_starvation_issue.md` (SIGINT-unresponsive
|
|
||||||
starvation hang): this one is **Ctrl-C-able**, which means
|
|
||||||
it's *not* the shared-GIL-hostage class and is ours to fix
|
|
||||||
from inside tractor rather than waiting on upstream CPython
|
|
||||||
/ msgspec progress.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
After a stuck-subint subactor is torn down via the
|
|
||||||
hard-kill path, a parent-side trio task parks on an
|
|
||||||
*orphaned resource* (most likely a `chan.recv()` /
|
|
||||||
`process_messages` loop on the now-dead subint's IPC
|
|
||||||
channel) and waits forever for bytes that can't arrive —
|
|
||||||
because the channel was torn down without emitting a clean
|
|
||||||
EOF/`BrokenResourceError` to the waiting receiver.
|
|
||||||
|
|
||||||
Unlike `subint_sigint_starvation_issue.md`, the main trio
|
|
||||||
loop **is** iterating normally — SIGINT delivers cleanly
|
|
||||||
and the test unhangs. But absent Ctrl-C, the test suite
|
|
||||||
wedges indefinitely.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Running `test_subint_non_checkpointing_child` under
|
|
||||||
`--spawn-backend=subint` (in
|
|
||||||
`tests/test_subint_cancellation.py`):
|
|
||||||
|
|
||||||
1. Test spawns a subactor whose main task runs
|
|
||||||
`threading.Event.wait(1.0)` in a loop — releases the
|
|
||||||
GIL but never inserts a trio checkpoint.
|
|
||||||
2. Parent does `an.cancel_scope.cancel()`. Our
|
|
||||||
`subint_proc` cancel path fires: soft-kill sends
|
|
||||||
`Portal.cancel_actor()` over the live IPC channel →
|
|
||||||
subint's trio loop *should* process the cancel msg on
|
|
||||||
its IPC dispatcher task (since the GIL releases are
|
|
||||||
happening).
|
|
||||||
3. Expected: subint's `trio.run()` unwinds, driver thread
|
|
||||||
exits naturally, parent returns.
|
|
||||||
4. Actual: parent `trio.run()` never completes. Test
|
|
||||||
hangs past its `trio.fail_after()` deadline.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
### `strace` on the hung pytest process during SIGINT
|
|
||||||
|
|
||||||
```
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(17, "\2", 1) = 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Contrast with the SIGINT-starvation hang (see
|
|
||||||
`subint_sigint_starvation_issue.md`) where that same
|
|
||||||
`write()` returned `EAGAIN`. Here the SIGINT byte is
|
|
||||||
written successfully → Python's signal handler pipe is
|
|
||||||
being drained → main trio loop **is** iterating → SIGINT
|
|
||||||
gets turned into `trio.Cancelled` → the test unhangs (if
|
|
||||||
the operator happens to be there to hit Ctrl-C).
|
|
||||||
|
|
||||||
### Stack dump (via `tractor.devx.dump_on_hang`)
|
|
||||||
|
|
||||||
Single main thread visible, parked in
|
|
||||||
`trio._core._io_epoll.get_events` inside `trio.run` at the
|
|
||||||
test's `trio.run(...)` call site. No subint driver thread
|
|
||||||
(subint was destroyed successfully — this is *after* the
|
|
||||||
hard-kill path, not during it).
|
|
||||||
|
|
||||||
## Root cause hypothesis
|
|
||||||
|
|
||||||
Most consistent with the evidence: a parent-side trio
|
|
||||||
task is awaiting a `chan.recv()` / `process_messages` loop
|
|
||||||
on the dead subint's IPC channel. The sequence:
|
|
||||||
|
|
||||||
1. Soft-kill in `subint_proc` sends `Portal.cancel_actor()`
|
|
||||||
over the channel. The subint's trio dispatcher *may* or
|
|
||||||
may not have processed the cancel msg before the subint
|
|
||||||
was destroyed — timing-dependent.
|
|
||||||
2. Hard-kill timeout fires (because the subint's main
|
|
||||||
task was in `threading.Event.wait()` with no trio
|
|
||||||
checkpoint — cancel-msg processing couldn't race the
|
|
||||||
timeout).
|
|
||||||
3. Driver thread abandoned, `_interpreters.destroy()`
|
|
||||||
runs. Subint is gone.
|
|
||||||
4. But the parent-side trio task holding a
|
|
||||||
`chan.recv()` / `process_messages` loop against that
|
|
||||||
channel was **not** explicitly cancelled. The channel's
|
|
||||||
underlying socket got torn down, but without a clean
|
|
||||||
EOF delivered to the waiting recv, the task parks
|
|
||||||
forever on `trio.lowlevel.wait_readable` (or similar).
|
|
||||||
|
|
||||||
This matches the "main loop fine, task parked on
|
|
||||||
orphaned I/O" signature.
|
|
||||||
|
|
||||||
## Why this is ours to fix (not CPython's)
|
|
||||||
|
|
||||||
- Main trio loop iterates normally → GIL isn't starved.
|
|
||||||
- SIGINT is deliverable → not a signal-pipe-full /
|
|
||||||
wakeup-fd contention scenario.
|
|
||||||
- The hang is in *our* supervision code, specifically in
|
|
||||||
how `subint_proc` tears down its side of the IPC when
|
|
||||||
the subint is abandoned/destroyed.
|
|
||||||
|
|
||||||
## Possible fix directions
|
|
||||||
|
|
||||||
1. **Explicit parent-side channel abort on subint
|
|
||||||
abandon.** In `subint_proc`'s teardown block, after the
|
|
||||||
hard-kill timeout fires, explicitly close the parent's
|
|
||||||
end of the IPC channel to the subint. Any waiting
|
|
||||||
`chan.recv()` / `process_messages` task sees
|
|
||||||
`BrokenResourceError` (or `ClosedResourceError`) and
|
|
||||||
unwinds.
|
|
||||||
2. **Cancel parent-side RPC tasks tied to the dead
|
|
||||||
subint's channel.** The `Actor._rpc_tasks` / nursery
|
|
||||||
machinery should have a handle on any
|
|
||||||
`process_messages` loops bound to a specific peer
|
|
||||||
channel. Iterate those and cancel explicitly.
|
|
||||||
3. **Bound the top-level `await actor_nursery
|
|
||||||
._join_procs.wait()` shield in `subint_proc`** (same
|
|
||||||
pattern as the other bounded shields the hard-kill
|
|
||||||
patch added). If the nursery never sets `_join_procs`
|
|
||||||
because a child task is parked, the bound would at
|
|
||||||
least let the teardown proceed.
|
|
||||||
|
|
||||||
Of these, (1) is the most surgical and directly addresses
|
|
||||||
the root cause. (2) is a defense-in-depth companion. (3)
|
|
||||||
is a band-aid but cheap to add.
|
|
||||||
|
|
||||||
## Current workaround
|
|
||||||
|
|
||||||
None in-tree. The test's `trio.fail_after()` bound
|
|
||||||
currently fires and raises `TooSlowError`, so the test
|
|
||||||
visibly **fails** rather than hangs — which is
|
|
||||||
intentional (an unbounded cancellation-audit test would
|
|
||||||
defeat itself). But in interactive test runs the operator
|
|
||||||
has to hit Ctrl-C to move past the parked state before
|
|
||||||
pytest reports the failure.
|
|
||||||
|
|
||||||
## Reproducer
|
|
||||||
|
|
||||||
```
|
|
||||||
./py314/bin/python -m pytest \
|
|
||||||
tests/test_subint_cancellation.py::test_subint_non_checkpointing_child \
|
|
||||||
--spawn-backend=subint --tb=short --no-header -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: hangs until `trio.fail_after(15)` fires, or
|
|
||||||
Ctrl-C unwedges it manually.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `tractor.spawn._subint.subint_proc` — current subint
|
|
||||||
teardown code; see the `_HARD_KILL_TIMEOUT` bounded
|
|
||||||
shields + `daemon=True` driver-thread abandonment
|
|
||||||
(commit `b025c982`).
|
|
||||||
- `ai/conc-anal/subint_sigint_starvation_issue.md` — the
|
|
||||||
sibling CPython-level hang (GIL-starvation,
|
|
||||||
SIGINT-unresponsive) which is **not** this issue.
|
|
||||||
- Phase B tracking: issue #379.
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
# `os.fork()` from a non-main sub-interpreter aborts the child (CPython refuses post-fork cleanup)
|
|
||||||
|
|
||||||
Third `subint`-class analysis in this project. Unlike its
|
|
||||||
two siblings (`subint_sigint_starvation_issue.md`,
|
|
||||||
`subint_cancel_delivery_hang_issue.md`), this one is not a
|
|
||||||
hang — it's a **hard CPython-level refusal** of an
|
|
||||||
experimental spawn strategy we wanted to try.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
An in-process sub-interpreter cannot be used as a
|
|
||||||
"launchpad" for `os.fork()` on current CPython. The fork
|
|
||||||
syscall succeeds in the parent, but the forked CHILD
|
|
||||||
process is aborted immediately by CPython's post-fork
|
|
||||||
cleanup with:
|
|
||||||
|
|
||||||
```
|
|
||||||
Fatal Python error: _PyInterpreterState_DeleteExceptMain: not main interpreter
|
|
||||||
```
|
|
||||||
|
|
||||||
This is enforced by a hard `PyStatus_ERR` gate in
|
|
||||||
`Python/pystate.c`. The CPython devs acknowledge the
|
|
||||||
fragility with an in-source comment (`// Ideally we could
|
|
||||||
guarantee tstate is running main.`) but provide no
|
|
||||||
mechanism to satisfy the precondition from user code.
|
|
||||||
|
|
||||||
**Implication for tractor**: the `subint_fork` backend
|
|
||||||
sketched in `tractor.spawn._subint_fork` is structurally
|
|
||||||
dead on current CPython. The submodule is kept as
|
|
||||||
documentation of the attempt; `--spawn-backend=subint_fork`
|
|
||||||
raises `NotImplementedError` pointing here.
|
|
||||||
|
|
||||||
## Context — why we tried this
|
|
||||||
|
|
||||||
The motivation is issue #379's "Our own thoughts, ideas
|
|
||||||
for `fork()`-workaround/hacks..." section. The existing
|
|
||||||
trio-backend (`tractor.spawn._trio.trio_proc`) spawns
|
|
||||||
subactors via `trio.lowlevel.open_process()` → ultimately
|
|
||||||
`posix_spawn()` or `fork+exec`, from the parent's main
|
|
||||||
interpreter that is currently running `trio.run()`. This
|
|
||||||
brushes against a known-fragile interaction between
|
|
||||||
`trio` and `fork()` tracked in
|
|
||||||
[python-trio/trio#1614](https://github.com/python-trio/trio/issues/1614)
|
|
||||||
and siblings — mostly mitigated in `tractor`'s case only
|
|
||||||
incidentally (we `exec()` immediately post-fork).
|
|
||||||
|
|
||||||
The idea was:
|
|
||||||
|
|
||||||
1. Create a subint that has *never* imported `trio`.
|
|
||||||
2. From a worker thread in that subint, call `os.fork()`.
|
|
||||||
3. In the child, `execv()` back into
|
|
||||||
`python -m tractor._child` — same as `trio_proc` does.
|
|
||||||
4. The fork is from a trio-free context → trio+fork
|
|
||||||
hazards avoided regardless of downstream behavior.
|
|
||||||
|
|
||||||
The parent-side orchestration (`ipc_server.wait_for_peer`,
|
|
||||||
`SpawnSpec`, `Portal` yield) would reuse
|
|
||||||
`trio_proc`'s flow verbatim, with only the subproc-spawn
|
|
||||||
mechanics swapped.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Running the prototype (`tractor.spawn._subint_fork.subint_fork_proc`,
|
|
||||||
see git history prior to the stub revert) on py3.14:
|
|
||||||
|
|
||||||
```
|
|
||||||
Fatal Python error: _PyInterpreterState_DeleteExceptMain: not main interpreter
|
|
||||||
Python runtime state: initialized
|
|
||||||
|
|
||||||
Current thread 0x00007f6b71a456c0 [subint-fork-lau] (most recent call first):
|
|
||||||
File "<script>", line 2 in <module>
|
|
||||||
<script>:2: DeprecationWarning: This process (pid=802985) is multi-threaded, use of fork() may lead to deadlocks in the child.
|
|
||||||
```
|
|
||||||
|
|
||||||
Key clues:
|
|
||||||
|
|
||||||
- The **`DeprecationWarning`** fires in the parent (before
|
|
||||||
fork completes) — fork *is* executing, we get that far.
|
|
||||||
- The **`Fatal Python error`** comes from the child — it
|
|
||||||
aborts during CPython's post-fork C initialization
|
|
||||||
before any user Python runs in the child.
|
|
||||||
- The thread name `subint-fork-lau[nchpad]` is ours —
|
|
||||||
confirms the fork is being called from the launchpad
|
|
||||||
subint's driver thread.
|
|
||||||
|
|
||||||
## CPython source walkthrough
|
|
||||||
|
|
||||||
### Call site — `Modules/posixmodule.c:728-793`
|
|
||||||
|
|
||||||
The post-fork-child hook CPython runs in the child process:
|
|
||||||
|
|
||||||
```c
|
|
||||||
void
|
|
||||||
PyOS_AfterFork_Child(void)
|
|
||||||
{
|
|
||||||
PyStatus status;
|
|
||||||
_PyRuntimeState *runtime = &_PyRuntime;
|
|
||||||
|
|
||||||
// re-creates runtime->interpreters.mutex (HEAD_UNLOCK)
|
|
||||||
status = _PyRuntimeState_ReInitThreads(runtime);
|
|
||||||
...
|
|
||||||
|
|
||||||
PyThreadState *tstate = _PyThreadState_GET();
|
|
||||||
_Py_EnsureTstateNotNULL(tstate);
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
// Ideally we could guarantee tstate is running main. ← !!!
|
|
||||||
_PyInterpreterState_ReinitRunningMain(tstate);
|
|
||||||
|
|
||||||
status = _PyEval_ReInitThreads(tstate);
|
|
||||||
...
|
|
||||||
|
|
||||||
status = _PyInterpreterState_DeleteExceptMain(runtime);
|
|
||||||
if (_PyStatus_EXCEPTION(status)) {
|
|
||||||
goto fatal_error;
|
|
||||||
}
|
|
||||||
...
|
|
||||||
|
|
||||||
fatal_error:
|
|
||||||
Py_ExitStatusException(status);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `// Ideally we could guarantee tstate is running
|
|
||||||
main.` comment is a flashing warning sign — the CPython
|
|
||||||
devs *know* this path is fragile when fork is called from
|
|
||||||
a non-main subint, but they've chosen to abort rather than
|
|
||||||
silently corrupt state. Arguably the right call.
|
|
||||||
|
|
||||||
### The refusal — `Python/pystate.c:1035-1075`
|
|
||||||
|
|
||||||
```c
|
|
||||||
/*
|
|
||||||
* Delete all interpreter states except the main interpreter. If there
|
|
||||||
* is a current interpreter state, it *must* be the main interpreter.
|
|
||||||
*/
|
|
||||||
PyStatus
|
|
||||||
_PyInterpreterState_DeleteExceptMain(_PyRuntimeState *runtime)
|
|
||||||
{
|
|
||||||
struct pyinterpreters *interpreters = &runtime->interpreters;
|
|
||||||
|
|
||||||
PyThreadState *tstate = _PyThreadState_Swap(runtime, NULL);
|
|
||||||
if (tstate != NULL && tstate->interp != interpreters->main) {
|
|
||||||
return _PyStatus_ERR("not main interpreter"); ← our error
|
|
||||||
}
|
|
||||||
|
|
||||||
HEAD_LOCK(runtime);
|
|
||||||
PyInterpreterState *interp = interpreters->head;
|
|
||||||
interpreters->head = NULL;
|
|
||||||
while (interp != NULL) {
|
|
||||||
if (interp == interpreters->main) {
|
|
||||||
interpreters->main->next = NULL;
|
|
||||||
interpreters->head = interp;
|
|
||||||
interp = interp->next;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// XXX Won't this fail since PyInterpreterState_Clear() requires
|
|
||||||
// the "current" tstate to be set?
|
|
||||||
PyInterpreterState_Clear(interp); // XXX must activate?
|
|
||||||
zapthreads(interp);
|
|
||||||
...
|
|
||||||
}
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The comment in the docstring (`If there is a current
|
|
||||||
interpreter state, it *must* be the main interpreter.`) is
|
|
||||||
the formal API contract. The `XXX` comments further in
|
|
||||||
suggest the CPython team is already aware this function
|
|
||||||
has latent issues even in the happy path.
|
|
||||||
|
|
||||||
## Chain summary
|
|
||||||
|
|
||||||
1. Our launchpad subint's driver OS-thread calls
|
|
||||||
`os.fork()`.
|
|
||||||
2. `fork()` succeeds. Child wakes up with:
|
|
||||||
- The parent's full memory image (including all
|
|
||||||
subints).
|
|
||||||
- Only the *calling* thread alive (the driver thread).
|
|
||||||
- `_PyThreadState_GET()` on that thread returns the
|
|
||||||
**launchpad subint's tstate**, *not* main's.
|
|
||||||
3. CPython runs `PyOS_AfterFork_Child()`.
|
|
||||||
4. It reaches `_PyInterpreterState_DeleteExceptMain()`.
|
|
||||||
5. Gate check fails: `tstate->interp != interpreters->main`.
|
|
||||||
6. `PyStatus_ERR("not main interpreter")` → `fatal_error`
|
|
||||||
goto → `Py_ExitStatusException()` → child aborts.
|
|
||||||
|
|
||||||
Parent-side consequence: `os.fork()` in the subint
|
|
||||||
bootstrap returned successfully with the child's PID, but
|
|
||||||
the child died before connecting back. Our parent's
|
|
||||||
`ipc_server.wait_for_peer(uid)` would hang forever — the
|
|
||||||
child never gets to `_actor_child_main`.
|
|
||||||
|
|
||||||
## Definitive answer to "Open Question 1"
|
|
||||||
|
|
||||||
From the (now-stub) `subint_fork_proc` docstring:
|
|
||||||
|
|
||||||
> Does CPython allow `os.fork()` from a non-main
|
|
||||||
> sub-interpreter under the legacy config?
|
|
||||||
|
|
||||||
**No.** Not in a usable-by-user-code sense. The fork
|
|
||||||
syscall is not blocked, but the child cannot survive
|
|
||||||
CPython's post-fork initialization. This is enforced, not
|
|
||||||
accidental, and the CPython devs have acknowledged the
|
|
||||||
fragility in-source.
|
|
||||||
|
|
||||||
## What we'd need from CPython to unblock
|
|
||||||
|
|
||||||
Any one of these, from least-to-most invasive:
|
|
||||||
|
|
||||||
1. **A pre-fork hook mechanism** that lets user code (or
|
|
||||||
tractor itself via `os.register_at_fork(before=...)`)
|
|
||||||
swap the current tstate to main before fork runs. The
|
|
||||||
swap would need to work across the subint→main
|
|
||||||
boundary, which is the actual hard part —
|
|
||||||
`_PyThreadState_Swap()` exists but is internal.
|
|
||||||
|
|
||||||
2. **A `_PyInterpreterState_DeleteExceptFor(tstate->interp)`
|
|
||||||
variant** that cleans up all *other* subints while
|
|
||||||
preserving the calling subint's state. Lets the child
|
|
||||||
continue executing in the subint after fork; a
|
|
||||||
subsequent `execv()` clears everything at the OS
|
|
||||||
level anyway.
|
|
||||||
|
|
||||||
3. **A cleaner error** than `Fatal Python error` aborting
|
|
||||||
the child. Even without fixing the underlying
|
|
||||||
capability, a raised Python-level exception in the
|
|
||||||
parent's `fork()` call (rather than a silent child
|
|
||||||
abort) would at least make the failure mode
|
|
||||||
debuggable.
|
|
||||||
|
|
||||||
## Upstream-report draft (for CPython issue tracker)
|
|
||||||
|
|
||||||
### Title
|
|
||||||
|
|
||||||
> `os.fork()` from a non-main sub-interpreter aborts the
|
|
||||||
> child with a fatal error in `PyOS_AfterFork_Child`; can
|
|
||||||
> we at least make it a clean `RuntimeError` in the
|
|
||||||
> parent?
|
|
||||||
|
|
||||||
### Body
|
|
||||||
|
|
||||||
> **Version**: Python 3.14.x
|
|
||||||
>
|
|
||||||
> **Summary**: Calling `os.fork()` from a thread currently
|
|
||||||
> executing inside a sub-interpreter causes the forked
|
|
||||||
> child process to abort during CPython's post-fork
|
|
||||||
> cleanup, with the following output in the child:
|
|
||||||
>
|
|
||||||
> ```
|
|
||||||
> Fatal Python error: _PyInterpreterState_DeleteExceptMain: not main interpreter
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> From the **parent's** point of view the fork succeeded
|
|
||||||
> (returned a valid child PID). The failure is completely
|
|
||||||
> opaque to parent-side Python code — unless the parent
|
|
||||||
> does `os.waitpid()` it won't even notice the child
|
|
||||||
> died.
|
|
||||||
>
|
|
||||||
> **Root cause** (as I understand it from reading sources):
|
|
||||||
> `Modules/posixmodule.c::PyOS_AfterFork_Child()` calls
|
|
||||||
> `_PyInterpreterState_DeleteExceptMain()` with a
|
|
||||||
> precondition that `_PyThreadState_GET()->interp` be the
|
|
||||||
> main interpreter. When `fork()` is called from a thread
|
|
||||||
> executing inside a subinterpreter, the child wakes up
|
|
||||||
> with its tstate still pointing at the subint, and the
|
|
||||||
> gate in `Python/pystate.c:1044-1047` fails.
|
|
||||||
>
|
|
||||||
> A comment in the source
|
|
||||||
> (`Modules/posixmodule.c:753` — `// Ideally we could
|
|
||||||
> guarantee tstate is running main.`) suggests this is a
|
|
||||||
> known-fragile path rather than an intentional
|
|
||||||
> invariant.
|
|
||||||
>
|
|
||||||
> **Use case**: I was experimenting with using a
|
|
||||||
> sub-interpreter as a "fork launchpad" — have a subint
|
|
||||||
> that has never imported `trio`, call `os.fork()` from
|
|
||||||
> that subint's thread, and in the child `execv()` back
|
|
||||||
> into a fresh Python interpreter process. The goal was
|
|
||||||
> to sidestep known issues with `trio` + `fork()`
|
|
||||||
> interaction (see
|
|
||||||
> [python-trio/trio#1614](https://github.com/python-trio/trio/issues/1614))
|
|
||||||
> by guaranteeing the forking context had never been
|
|
||||||
> "contaminated" by trio's imports or globals. This
|
|
||||||
> approach would allow `trio`-using applications to
|
|
||||||
> combine `fork`-based subprocess spawning with
|
|
||||||
> per-worker `trio.run()` runtimes — a fairly common
|
|
||||||
> pattern that currently requires workarounds.
|
|
||||||
>
|
|
||||||
> **Request**:
|
|
||||||
>
|
|
||||||
> Ideally: make fork-from-subint work (e.g., by swapping
|
|
||||||
> the caller's tstate to main in the pre-fork hook), or
|
|
||||||
> provide a `_PyInterpreterState_DeleteExceptFor(interp)`
|
|
||||||
> variant that permits the caller's subint to survive
|
|
||||||
> post-fork so user code can subsequently `execv()`.
|
|
||||||
>
|
|
||||||
> Minimally: convert the fatal child-side abort into a
|
|
||||||
> clean `RuntimeError` (or similar) raised in the
|
|
||||||
> parent's `fork()` call. Even if the capability isn't
|
|
||||||
> expanded, the failure mode should be debuggable by
|
|
||||||
> user-code in the parent — right now it's a silent
|
|
||||||
> child death with an error message buried in the
|
|
||||||
> child's stderr that parent code can't programmatically
|
|
||||||
> see.
|
|
||||||
>
|
|
||||||
> **Related**: PEP 684 (per-interpreter GIL), PEP 734
|
|
||||||
> (`concurrent.interpreters` public API). The private
|
|
||||||
> `_interpreters` module is what I used to create the
|
|
||||||
> launchpad — behavior is the same whether using
|
|
||||||
> `_interpreters.create('legacy')` or
|
|
||||||
> `concurrent.interpreters.create()` (the latter was not
|
|
||||||
> tested but the gate is identical).
|
|
||||||
>
|
|
||||||
> Happy to contribute a minimal reproducer + test case if
|
|
||||||
> this is something the team wants to pursue.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `Modules/posixmodule.c:728` —
|
|
||||||
[`PyOS_AfterFork_Child`](https://github.com/python/cpython/blob/main/Modules/posixmodule.c#L728)
|
|
||||||
- `Python/pystate.c:1040` —
|
|
||||||
[`_PyInterpreterState_DeleteExceptMain`](https://github.com/python/cpython/blob/main/Python/pystate.c#L1040)
|
|
||||||
- PEP 684 (per-interpreter GIL):
|
|
||||||
<https://peps.python.org/pep-0684/>
|
|
||||||
- PEP 734 (`concurrent.interpreters` public API):
|
|
||||||
<https://peps.python.org/pep-0734/>
|
|
||||||
- [python-trio/trio#1614](https://github.com/python-trio/trio/issues/1614)
|
|
||||||
— the original motivation for the launchpad idea.
|
|
||||||
- tractor issue #379 — "Our own thoughts, ideas for
|
|
||||||
`fork()`-workaround/hacks..." section where this was
|
|
||||||
first sketched.
|
|
||||||
- `tractor.spawn._subint_fork` — in-tree stub preserving
|
|
||||||
the attempted impl's shape in git history.
|
|
||||||
|
|
@ -1,375 +0,0 @@
|
||||||
#!/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 <name>`)
|
|
||||||
---------------------------------------------
|
|
||||||
|
|
||||||
- `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 sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
# The actual primitives this script exercises live in
|
|
||||||
# `tractor.spawn._subint_forkserver` — we re-import them here
|
|
||||||
# rather than inlining so the module and the validation stay
|
|
||||||
# in sync. (Early versions of this file had them inline for
|
|
||||||
# the "zero tractor imports" isolation guarantee; now that
|
|
||||||
# CPython-level feasibility is confirmed, the validated
|
|
||||||
# primitives have moved into tractor proper.)
|
|
||||||
from tractor.spawn._main_thread_forkserver import (
|
|
||||||
fork_from_worker_thread,
|
|
||||||
wait_child,
|
|
||||||
)
|
|
||||||
from tractor.spawn._subint_forkserver import (
|
|
||||||
run_subint_in_worker_thread,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# small observability helpers (test-harness only)
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _banner(title: str) -> None:
|
|
||||||
line = '=' * 60
|
|
||||||
print(f'\n{line}\n{title}\n{line}', flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _report(
|
|
||||||
label: str,
|
|
||||||
*,
|
|
||||||
ok: bool,
|
|
||||||
status_str: str,
|
|
||||||
expect_exit_ok: bool,
|
|
||||||
) -> None:
|
|
||||||
verdict: str = 'PASS' if ok else 'FAIL'
|
|
||||||
expected_str: str = (
|
|
||||||
'normal exit (rc=0)'
|
|
||||||
if expect_exit_ok
|
|
||||||
else 'abnormal death (signal or nonzero exit)'
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f'[{verdict}] {label}: '
|
|
||||||
f'expected {expected_str}; observed {status_str}',
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# 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 _run_worker_thread_fork_scenario(
|
|
||||||
label: str,
|
|
||||||
*,
|
|
||||||
child_target=None,
|
|
||||||
) -> int:
|
|
||||||
'''
|
|
||||||
Thin wrapper: delegate the actual fork to the
|
|
||||||
`tractor.spawn._subint_forkserver` primitive, then wait
|
|
||||||
on the child and render a pass/fail banner.
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
pid: int = fork_from_worker_thread(
|
|
||||||
child_target=child_target,
|
|
||||||
thread_name=f'worker-fork-thread[{label}]',
|
|
||||||
)
|
|
||||||
except RuntimeError as err:
|
|
||||||
print(f'[FAIL] {label}: {err}', flush=True)
|
|
||||||
return 1
|
|
||||||
print(f' forked child pid={pid}', flush=True)
|
|
||||||
ok, status_str = wait_child(pid, expect_exit_ok=True)
|
|
||||||
_report(
|
|
||||||
label,
|
|
||||||
ok=ok,
|
|
||||||
status_str=status_str,
|
|
||||||
expect_exit_ok=True,
|
|
||||||
)
|
|
||||||
return 0 if ok 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 _run_worker_thread_fork_scenario(
|
|
||||||
'worker_thread_fork',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# scenario: `full_architecture`
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_CHILD_TRIO_BOOTSTRAP: str = (
|
|
||||||
'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'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _child_trio_in_subint() -> int:
|
|
||||||
'''
|
|
||||||
CHILD-side `child_target`: drive a trivial `trio.run()`
|
|
||||||
inside a fresh legacy-config subint on a worker thread,
|
|
||||||
using the `tractor.spawn._subint_forkserver.run_subint_in_worker_thread`
|
|
||||||
primitive. Returns 0 on success.
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
run_subint_in_worker_thread(
|
|
||||||
_CHILD_TRIO_BOOTSTRAP,
|
|
||||||
thread_name='child-subint-trio-thread',
|
|
||||||
)
|
|
||||||
except RuntimeError as err:
|
|
||||||
print(
|
|
||||||
f' CHILD: run_subint_in_worker_thread timed out / thread '
|
|
||||||
f'never returned: {err}',
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
return 3
|
|
||||||
except BaseException as err:
|
|
||||||
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 _run_worker_thread_fork_scenario(
|
|
||||||
'full_architecture',
|
|
||||||
child_target=_child_trio_in_subint,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# 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())
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
# `subint_forkserver` × `multiprocessing.SharedMemory`: fork-inherited `resource_tracker` fd
|
|
||||||
|
|
||||||
Surfaced by `tests/test_shm.py` under
|
|
||||||
`--spawn-backend=subint_forkserver`. Two distinct
|
|
||||||
failure modes, one root cause:
|
|
||||||
**`multiprocessing.resource_tracker` is fork-without-exec
|
|
||||||
unsafe** (canonical CPython class — bpo-38119, bpo-45209).
|
|
||||||
|
|
||||||
**Status: resolved by `tractor/ipc/_mp_bs.py` +
|
|
||||||
`tractor/ipc/_shm.py` changes (see "Resolution" below).
|
|
||||||
This doc kept as the
|
|
||||||
post-mortem / decision record.**
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
`mp.shared_memory.SharedMemory` registers each shm
|
|
||||||
allocation with the per-process
|
|
||||||
`multiprocessing.resource_tracker` singleton. The
|
|
||||||
tracker is a daemon process started lazily; the
|
|
||||||
parent owns a unix-pipe-fd to it. When the parent
|
|
||||||
forks-without-execing into a `subint_forkserver`
|
|
||||||
child, the child inherits that fd — but it refers to
|
|
||||||
the *parent's* tracker, which the child has no
|
|
||||||
business writing to.
|
|
||||||
|
|
||||||
Two manifestations under the original (pre-fix) code:
|
|
||||||
|
|
||||||
1. **`test_child_attaches_alot`** — child loops 1000×
|
|
||||||
`attach_shm_list()`. First `mp.SharedMemory` call
|
|
||||||
in the child triggers
|
|
||||||
`resource_tracker._ensure_running_and_write` →
|
|
||||||
`_teardown_dead_process` → `os.close(self._fd)` on
|
|
||||||
an fd the child should never have touched. Surfaces
|
|
||||||
as `OSError: [Errno 9] Bad file descriptor`
|
|
||||||
wrapped in `tractor.RemoteActorError`.
|
|
||||||
|
|
||||||
2. **`test_parent_writer_child_reader[*]`** — first
|
|
||||||
parametrize variant "passes" (with
|
|
||||||
`resource_tracker: leaked shared_memory` warning)
|
|
||||||
because nobody ever cleans up `/shm_list`.
|
|
||||||
Subsequent variants then fail with
|
|
||||||
`FileExistsError: '/shm_list'` because the leak
|
|
||||||
persists across the parametrize loop and forkserver
|
|
||||||
children can't `shm_open(create=True)` an existing
|
|
||||||
key.
|
|
||||||
|
|
||||||
Trio backend (`mp_spawn`-style) doesn't surface this:
|
|
||||||
each subactor `exec`s a fresh interpreter →
|
|
||||||
independent resource tracker per subactor → no
|
|
||||||
inherited-fd issue, and the test's pre-existing leak
|
|
||||||
gets masked by the per-process tracker reset.
|
|
||||||
|
|
||||||
Under `subint_forkserver`, the child is `os.fork()`'d
|
|
||||||
from a worker thread (no `exec`) → inherits parent's
|
|
||||||
`mp.resource_tracker._resource_tracker._fd` → EBADF
|
|
||||||
/ cross-talk on first `mp.SharedMemory` op.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
We side-step the broken upstream machinery entirely
|
|
||||||
rather than try to make it fork-safe. Two-part fix
|
|
||||||
landed (commits to follow this doc):
|
|
||||||
|
|
||||||
### 1. `tractor/ipc/_mp_bs.py::disable_mantracker()`
|
|
||||||
— unconditional disable
|
|
||||||
|
|
||||||
The previous "3.13+ short-circuit" path used
|
|
||||||
`partial(SharedMemory, track=False)` to opt-out of
|
|
||||||
registration on 3.13+. The `track=False` switch is
|
|
||||||
necessary but not sufficient under fork: the
|
|
||||||
inherited tracker fd can still be touched indirectly
|
|
||||||
(e.g. through `_ensure_running_and_write`'s
|
|
||||||
self-check path).
|
|
||||||
|
|
||||||
The fix takes both belts AND suspenders:
|
|
||||||
|
|
||||||
- **Always** monkey-patch
|
|
||||||
`mp.resource_tracker._resource_tracker` to a
|
|
||||||
no-op `ManTracker` subclass whose
|
|
||||||
`register`/`unregister`/`ensure_running` are all
|
|
||||||
empty.
|
|
||||||
- **Always** wrap `SharedMemory` with
|
|
||||||
`track=False`.
|
|
||||||
|
|
||||||
Result: the inherited tracker fd in the fork child
|
|
||||||
is still inherited (fd is a kernel object; we can't
|
|
||||||
un-inherit it across fork) but **nothing in the
|
|
||||||
shm code path will ever try to use it** — both the
|
|
||||||
tracker singleton and the per-allocation registration
|
|
||||||
are short-circuited.
|
|
||||||
|
|
||||||
### 2. `tractor/ipc/_shm.py::open_shm_list()`
|
|
||||||
— own the cleanup
|
|
||||||
|
|
||||||
Without `mp.resource_tracker`, nobody else will
|
|
||||||
unlink leaked segments at process exit. tractor
|
|
||||||
already controls actor lifecycle, so we register
|
|
||||||
unlink on the actor's lifetime stack:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def try_unlink():
|
|
||||||
try:
|
|
||||||
shml.shm.unlink()
|
|
||||||
except FileNotFoundError as fne:
|
|
||||||
log.exception(...) # benign sibling-already-cleaned race
|
|
||||||
|
|
||||||
actor.lifetime_stack.callback(try_unlink)
|
|
||||||
```
|
|
||||||
|
|
||||||
The `FileNotFoundError` swallow handles the case
|
|
||||||
where a sibling actor already unlinked the same
|
|
||||||
segment (legitimate race in shared-key setups).
|
|
||||||
|
|
||||||
## Why this is the right call
|
|
||||||
|
|
||||||
- **mp's tracker is widely criticized.** The
|
|
||||||
in-tree comment "non-SC madness" predates this
|
|
||||||
fix and matches CPython upstream's own discomfort
|
|
||||||
(e.g. the per-context tracker design rework
|
|
||||||
discussions in bpo-43475).
|
|
||||||
- **tractor already owns process lifecycle.** We
|
|
||||||
have `actor.lifetime_stack`, `Portal.cancel_actor`,
|
|
||||||
and the IPC cancel cascade. Adding mp's tracker
|
|
||||||
on top buys nothing we can't do better ourselves.
|
|
||||||
- **Backend-uniform.** No special-casing per spawn
|
|
||||||
backend. trio (`mp_spawn`-style), `subint_forkserver`,
|
|
||||||
and the future `subint` all behave identically
|
|
||||||
— register-time no-op, exit-time unlink-via-
|
|
||||||
lifetime-stack.
|
|
||||||
|
|
||||||
## Trade-offs / known gaps
|
|
||||||
|
|
||||||
- **Crash-leaked segments.** If an actor segfaults
|
|
||||||
or is `SIGKILL`'d before its lifetime stack runs,
|
|
||||||
`/dev/shm/<key>` will leak. Mitigation:
|
|
||||||
`scripts/tractor-reap --shm` walks `/dev/shm`,
|
|
||||||
filters to segments owned by the current uid that
|
|
||||||
no live process is mapping or holding open (via
|
|
||||||
`/proc/*/maps` + `/proc/*/fd/*`), and unlinks
|
|
||||||
them. The "nobody-has-it-open" filter is
|
|
||||||
kernel-canonical so it never touches in-flight
|
|
||||||
segments held by sibling apps (verified locally
|
|
||||||
against 81 piker/lttng/aja-held segments — all
|
|
||||||
preserved).
|
|
||||||
- Higher-level apps using shm should still pin a
|
|
||||||
UUID into the key (the `'shml_<uuid>'` pattern
|
|
||||||
in `test_child_attaches_alot`) so concurrent
|
|
||||||
sessions don't collide on the same key.
|
|
||||||
- **Cross-actor unlink races.** Two actors holding
|
|
||||||
the same shm key racing on `unlink()` — handled
|
|
||||||
by the `FileNotFoundError` swallow.
|
|
||||||
- **Crashes won't show up in mp's leak warning.**
|
|
||||||
We've turned off `resource_tracker`, so the usual
|
|
||||||
`resource_tracker: There appear to be N leaked
|
|
||||||
shared_memory objects to clean up at shutdown`
|
|
||||||
warning is gone too. If we ever want it back as
|
|
||||||
a crash-detection signal, we'd need our own
|
|
||||||
equivalent (walk the actor's `_shm_list_keys` set
|
|
||||||
at root teardown, log any unfreed).
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# fixed under both backends:
|
|
||||||
./py314/bin/python -m pytest tests/test_shm.py \
|
|
||||||
--spawn-backend=subint_forkserver
|
|
||||||
# 7 passed
|
|
||||||
|
|
||||||
./py314/bin/python -m pytest tests/test_shm.py \
|
|
||||||
--spawn-backend=trio
|
|
||||||
# 7 passed (regression check)
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- CPython upstream issues:
|
|
||||||
- https://bugs.python.org/issue38119 (fork
|
|
||||||
+ resource_tracker fd inheritance)
|
|
||||||
- https://bugs.python.org/issue45209
|
|
||||||
(SharedMemory + resource_tracker)
|
|
||||||
- https://bugs.python.org/issue43475
|
|
||||||
(per-context tracker rework discussion)
|
|
||||||
- Long-term alternative: migrate off
|
|
||||||
`multiprocessing.shared_memory` entirely to
|
|
||||||
`posix_ipc` (no tracker) or finish the
|
|
||||||
`hotbaud`-based ringbuf transport. Not blocked on
|
|
||||||
this fix — both are independently tracked.
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
# `subint_forkserver` backend: orphaned-subactor SIGINT wedged in `epoll_wait`
|
|
||||||
|
|
||||||
Follow-up to the Phase C `subint_forkserver` spawn-backend
|
|
||||||
PR (see `tractor.spawn._subint_forkserver`, issue #379).
|
|
||||||
Surfaced by the xfail'd
|
|
||||||
`tests/spawn/test_subint_forkserver.py::test_orphaned_subactor_sigint_cleanup_DRAFT`.
|
|
||||||
|
|
||||||
Related-but-distinct from
|
|
||||||
`subint_cancel_delivery_hang_issue.md` (orphaned-channel
|
|
||||||
park AFTER subint teardown) and
|
|
||||||
`subint_sigint_starvation_issue.md` (GIL-starvation,
|
|
||||||
SIGINT never delivered): here the SIGINT IS delivered,
|
|
||||||
trio's handler IS installed, but trio's event loop never
|
|
||||||
wakes — so the KBI-at-checkpoint → `_trio_main` catch path
|
|
||||||
(which is the runtime's *intentional* OS-cancel design)
|
|
||||||
never fires.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
When a `subint_forkserver`-spawned subactor is orphaned
|
|
||||||
(parent `SIGKILL`'d, no IPC cancel path available) and then
|
|
||||||
externally `SIGINT`'d, the subactor hangs in
|
|
||||||
`trio/_core/_io_epoll.py::get_events` (epoll_wait)
|
|
||||||
indefinitely — even though:
|
|
||||||
|
|
||||||
1. `threading.current_thread() is threading.main_thread()`
|
|
||||||
post-fork (CPython 3.14 re-designates correctly).
|
|
||||||
2. Trio's SIGINT handler IS installed in the subactor
|
|
||||||
(`signal.getsignal(SIGINT)` returns
|
|
||||||
`<function KIManager.install.<locals>.handler at 0x...>`).
|
|
||||||
3. The kernel does deliver SIGINT — the signal arrives at
|
|
||||||
the only thread in the process (the fork-inherited
|
|
||||||
worker which IS now "main" per Python).
|
|
||||||
|
|
||||||
Yet `epoll_wait` does not return. Trio's wakeup-fd mechanism
|
|
||||||
— the machinery that turns SIGINT into an epoll-wake — is
|
|
||||||
somehow not firing the wakeup. Until that's fixed, the
|
|
||||||
intentional "KBI-as-OS-cancel" path in
|
|
||||||
`tractor/spawn/_entry.py::_trio_main:164` is unreachable
|
|
||||||
for forkserver-spawned subactors whose parent dies.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Test: `tests/spawn/test_subint_forkserver.py::test_orphaned_subactor_sigint_cleanup_DRAFT`
|
|
||||||
(currently marked `@pytest.mark.xfail(strict=True)`).
|
|
||||||
|
|
||||||
1. Harness subprocess brings up a tractor root actor +
|
|
||||||
one `run_in_actor(_sleep_forever)` subactor via
|
|
||||||
`try_set_start_method('subint_forkserver')`.
|
|
||||||
2. Harness prints `CHILD_PID` (subactor) and
|
|
||||||
`PARENT_READY` (root actor) markers to stdout.
|
|
||||||
3. Test `os.kill(parent_pid, SIGKILL)` + `proc.wait()`
|
|
||||||
to fully reap the root-actor harness.
|
|
||||||
4. Child (now reparented to pid 1) is still alive.
|
|
||||||
5. Test `os.kill(child_pid, SIGINT)` and polls
|
|
||||||
`os.kill(child_pid, 0)` for up to 10s.
|
|
||||||
6. **Observed**: the child is still alive at deadline —
|
|
||||||
SIGINT did not unwedge the trio loop.
|
|
||||||
|
|
||||||
## What the "intentional" cancel path IS
|
|
||||||
|
|
||||||
`tractor/spawn/_entry.py::_trio_main:157-186` —
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
if infect_asyncio:
|
|
||||||
actor._infected_aio = True
|
|
||||||
run_as_asyncio_guest(trio_main)
|
|
||||||
else:
|
|
||||||
trio.run(trio_main)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logmeth = log.cancel
|
|
||||||
exit_status: str = (
|
|
||||||
'Actor received KBI (aka an OS-cancel)\n'
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The "KBI == OS-cancel" mapping IS the runtime's
|
|
||||||
deliberate, documented design. An OS-level SIGINT should
|
|
||||||
flow as: kernel → trio handler → KBI at trio checkpoint
|
|
||||||
→ unwinds `async_main` → surfaces at `_trio_main`'s
|
|
||||||
`except KeyboardInterrupt:` → `log.cancel` + clean `rc=0`.
|
|
||||||
|
|
||||||
**So fixing this hang is not "add a new SIGINT behavior" —
|
|
||||||
it's "make the existing designed behavior actually fire in
|
|
||||||
this backend config".** That's why option (B) ("fix root
|
|
||||||
cause") is aligned with existing design intent, not a
|
|
||||||
scope expansion.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
### Positive control: standalone fork-from-worker + `trio.run(sleep_forever)` + SIGINT WORKS
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os, signal, time, trio
|
|
||||||
from tractor.spawn._subint_forkserver import (
|
|
||||||
fork_from_worker_thread, wait_child,
|
|
||||||
)
|
|
||||||
|
|
||||||
def child_target() -> int:
|
|
||||||
async def _main():
|
|
||||||
try:
|
|
||||||
await trio.sleep_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print('CHILD: caught KBI — trio SIGINT works!')
|
|
||||||
return
|
|
||||||
trio.run(_main)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
pid = fork_from_worker_thread(child_target, thread_name='trio-sigint-test')
|
|
||||||
time.sleep(1.0)
|
|
||||||
os.kill(pid, signal.SIGINT)
|
|
||||||
wait_child(pid)
|
|
||||||
```
|
|
||||||
|
|
||||||
Result: `CHILD: caught KBI — trio SIGINT works!` + clean
|
|
||||||
exit. So the fork-child + trio signal plumbing IS healthy
|
|
||||||
in isolation. The hang appears only with the full tractor
|
|
||||||
subactor runtime on top.
|
|
||||||
|
|
||||||
### Negative test: full tractor subactor + orphan-SIGINT
|
|
||||||
|
|
||||||
Equivalent to the xfail test. Traceback dump via
|
|
||||||
`faulthandler.register(SIGUSR1, all_threads=True)` at the
|
|
||||||
stuck moment:
|
|
||||||
|
|
||||||
```
|
|
||||||
Current thread 0x00007... [subint-forkserv] (most recent call first):
|
|
||||||
File ".../trio/_core/_io_epoll.py", line 245 in get_events
|
|
||||||
File ".../trio/_core/_run.py", line 2415 in run
|
|
||||||
File "tractor/spawn/_entry.py", line 162 in _trio_main
|
|
||||||
File "tractor/_child.py", line 72 in _actor_child_main
|
|
||||||
File "tractor/spawn/_subint_forkserver.py", line 650 in _child_target
|
|
||||||
File "tractor/spawn/_subint_forkserver.py", line 308 in _worker
|
|
||||||
File ".../threading.py", line 1024 in run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Thread + signal-mask inventory of the stuck subactor
|
|
||||||
|
|
||||||
Single thread (`tid == pid`, comm `'subint-forkserv'`,
|
|
||||||
which IS `threading.main_thread()` post-fork):
|
|
||||||
|
|
||||||
```
|
|
||||||
SigBlk: 0000000000000000 # nothing blocked
|
|
||||||
SigIgn: 0000000001001000 # SIGPIPE etc (Python defaults)
|
|
||||||
SigCgt: 0000000108000202 # bit 1 = SIGINT caught
|
|
||||||
```
|
|
||||||
|
|
||||||
Bit 1 set in `SigCgt` → SIGINT handler IS installed. So
|
|
||||||
trio's handler IS in place at the kernel level — not a
|
|
||||||
"handler missing" situation.
|
|
||||||
|
|
||||||
### Handler identity
|
|
||||||
|
|
||||||
Inside the subactor's RPC body, `signal.getsignal(SIGINT)`
|
|
||||||
returns `<function KIManager.install.<locals>.handler at
|
|
||||||
0x...>` — trio's own `KIManager` handler. tractor's only
|
|
||||||
SIGINT touches are `signal.getsignal()` *reads* (to stash
|
|
||||||
into `debug.DebugStatus._trio_handler`); nothing writes
|
|
||||||
over trio's handler outside the debug-REPL shielding path
|
|
||||||
(`devx/debug/_tty_lock.py::shield_sigint`) which isn't
|
|
||||||
engaged here (no debug_mode).
|
|
||||||
|
|
||||||
## Ruled out
|
|
||||||
|
|
||||||
- **GIL starvation / signal-pipe-full** (class A,
|
|
||||||
`subint_sigint_starvation_issue.md`): subactor runs on
|
|
||||||
its own GIL (separate OS process), not sharing with the
|
|
||||||
parent → no cross-process GIL contention. And `strace`-
|
|
||||||
equivalent in the signal mask shows SIGINT IS caught,
|
|
||||||
not queued.
|
|
||||||
- **Orphaned channel park** (`subint_cancel_delivery_hang_issue.md`):
|
|
||||||
different failure mode — that one has trio iterating
|
|
||||||
normally and getting wedged on an orphaned
|
|
||||||
`chan.recv()` AFTER teardown. Here trio's event loop
|
|
||||||
itself never wakes.
|
|
||||||
- **Tractor explicitly catching + swallowing KBI**:
|
|
||||||
greppable — the one `except KeyboardInterrupt:` in the
|
|
||||||
runtime is the INTENTIONAL cancel-path catch at
|
|
||||||
`_trio_main:164`. `async_main` uses `except Exception`
|
|
||||||
(not BaseException), so KBI should propagate through
|
|
||||||
cleanly if it ever fires.
|
|
||||||
- **Missing `signal.set_wakeup_fd` (main-thread
|
|
||||||
restriction)**: post-fork, the fork-worker thread IS
|
|
||||||
`threading.main_thread()`, so trio's main-thread check
|
|
||||||
passes and its wakeup-fd install should succeed.
|
|
||||||
|
|
||||||
## Root cause hypothesis (unverified)
|
|
||||||
|
|
||||||
The SIGINT handler fires but trio's wakeup-fd write does
|
|
||||||
not wake `epoll_wait`. Candidate causes, ranked by
|
|
||||||
plausibility:
|
|
||||||
|
|
||||||
1. **Wakeup-fd lifecycle race around tractor IPC setup.**
|
|
||||||
`async_main` spins up an IPC server + `process_messages`
|
|
||||||
loops early. Somewhere in that path the wakeup-fd that
|
|
||||||
trio registered with its epoll instance may be
|
|
||||||
closed/replaced/clobbered, so subsequent SIGINT writes
|
|
||||||
land on an fd that's no longer in the epoll set.
|
|
||||||
Evidence needed: compare
|
|
||||||
`signal.set_wakeup_fd(-1)` return value inside a
|
|
||||||
post-tractor-bringup RPC body vs. a pre-bringup
|
|
||||||
equivalent. If they differ, that's it.
|
|
||||||
2. **Shielded cancel scope around `process_messages`.**
|
|
||||||
The RPC message loop is likely wrapped in a trio cancel
|
|
||||||
scope; if that scope is `shield=True` at any outer
|
|
||||||
layer, KBI scheduled at a checkpoint could be absorbed
|
|
||||||
by the shield and never bubble out to `_trio_main`.
|
|
||||||
3. **Pre-fork wakeup-fd inheritance.** trio in the PARENT
|
|
||||||
process registered a wakeup-fd with its own epoll. The
|
|
||||||
child inherits the fd number but not the parent's
|
|
||||||
epoll instance — if tractor/trio re-uses the parent's
|
|
||||||
stale fd number anywhere, writes would go to a no-op
|
|
||||||
fd. (This is the least likely — `trio.run()` on the
|
|
||||||
child calls `KIManager.install` which should install a
|
|
||||||
fresh wakeup-fd from scratch.)
|
|
||||||
|
|
||||||
## Cross-backend scope question
|
|
||||||
|
|
||||||
**Untested**: does the same orphan-SIGINT hang reproduce
|
|
||||||
against the `trio_proc` backend (stock subprocess + exec)?
|
|
||||||
If yes → pre-existing tractor bug, independent of
|
|
||||||
`subint_forkserver`. If no → something specific to the
|
|
||||||
fork-from-worker path (e.g. inherited fds, mid-epoll-setup
|
|
||||||
interference).
|
|
||||||
|
|
||||||
**Quick repro for trio_proc**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# save as /tmp/trio_proc_orphan_sigint_repro.py
|
|
||||||
import os, sys, signal, time, glob
|
|
||||||
import subprocess as sp
|
|
||||||
|
|
||||||
SCRIPT = '''
|
|
||||||
import os, sys, trio, tractor
|
|
||||||
async def _sleep_forever():
|
|
||||||
print(f"CHILD_PID={os.getpid()}", flush=True)
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
async def _main():
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(registry_addrs=[("127.0.0.1", 12350)]),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
await an.run_in_actor(_sleep_forever, name="sf-child")
|
|
||||||
print(f"PARENT_READY={os.getpid()}", flush=True)
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
trio.run(_main)
|
|
||||||
'''
|
|
||||||
|
|
||||||
proc = sp.Popen(
|
|
||||||
[sys.executable, '-c', SCRIPT],
|
|
||||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
|
||||||
)
|
|
||||||
# parse CHILD_PID + PARENT_READY off proc.stdout ...
|
|
||||||
# SIGKILL parent, SIGINT child, poll.
|
|
||||||
```
|
|
||||||
|
|
||||||
If that hangs too, open a broader issue; if not, this is
|
|
||||||
`subint_forkserver`-specific (likely fd-inheritance-related).
|
|
||||||
|
|
||||||
## Why this is ours to fix (not CPython's)
|
|
||||||
|
|
||||||
- Signal IS delivered (`SigCgt` bitmask confirms).
|
|
||||||
- Handler IS installed (trio's `KIManager`).
|
|
||||||
- Thread identity is correct post-fork.
|
|
||||||
- `_trio_main` already has the intentional KBI→clean-exit
|
|
||||||
path waiting to fire.
|
|
||||||
|
|
||||||
Every CPython-level precondition is met. Something in
|
|
||||||
tractor's runtime or trio's integration with it is
|
|
||||||
breaking the SIGINT→wakeup→event-loop-wake pipeline.
|
|
||||||
|
|
||||||
## Possible fix directions
|
|
||||||
|
|
||||||
1. **Audit the wakeup-fd across tractor's IPC bringup.**
|
|
||||||
Add a trio startup hook that captures
|
|
||||||
`signal.set_wakeup_fd(-1)` at `_trio_main` entry,
|
|
||||||
after `async_main` enters, and periodically — assert
|
|
||||||
it's unchanged. If it moves, track down the writer.
|
|
||||||
2. **Explicit `signal.set_wakeup_fd` reset after IPC
|
|
||||||
setup.** Brute force: re-install a fresh wakeup-fd
|
|
||||||
mid-bringup. Band-aid, but fast to try.
|
|
||||||
3. **Ensure no `shield=True` cancel scope envelopes the
|
|
||||||
RPC-message-loop / IPC-server task.** If one does,
|
|
||||||
KBI-at-checkpoint never escapes.
|
|
||||||
4. **Once fixed, the `child_sigint='trio'` mode on
|
|
||||||
`subint_forkserver_proc`** becomes effectively a no-op
|
|
||||||
or a doc-only mode — trio's natural handler already
|
|
||||||
does the right thing. Might end up removing the flag
|
|
||||||
entirely if there's no behavioral difference between
|
|
||||||
modes.
|
|
||||||
|
|
||||||
## Current workaround
|
|
||||||
|
|
||||||
None; `child_sigint` defaults to `'ipc'` (IPC cancel is
|
|
||||||
the only reliable cancel path today), and the xfail test
|
|
||||||
documents the gap. Operators hitting orphan-SIGINT get a
|
|
||||||
hung process that needs `SIGKILL`.
|
|
||||||
|
|
||||||
## Reproducer
|
|
||||||
|
|
||||||
Inline, standalone (no pytest):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# save as /tmp/orphan_sigint_repro.py (py3.14+)
|
|
||||||
import os, sys, signal, time, glob, trio
|
|
||||||
import tractor
|
|
||||||
from tractor.spawn._subint_forkserver import (
|
|
||||||
fork_from_worker_thread,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _sleep_forever():
|
|
||||||
print(f'SUBACTOR[{os.getpid()}]', flush=True)
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
async def _main():
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(
|
|
||||||
registry_addrs=[('127.0.0.1', 12349)],
|
|
||||||
),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
await an.run_in_actor(_sleep_forever, name='sf-child')
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
def child_target() -> int:
|
|
||||||
from tractor.spawn._spawn import try_set_start_method
|
|
||||||
try_set_start_method('subint_forkserver')
|
|
||||||
trio.run(_main)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
pid = fork_from_worker_thread(child_target, thread_name='repro')
|
|
||||||
time.sleep(3.0)
|
|
||||||
|
|
||||||
# find the subactor pid via /proc
|
|
||||||
children = []
|
|
||||||
for path in glob.glob(f'/proc/{pid}/task/*/children'):
|
|
||||||
with open(path) as f:
|
|
||||||
children.extend(int(x) for x in f.read().split() if x)
|
|
||||||
subactor_pid = children[0]
|
|
||||||
|
|
||||||
# SIGKILL root → orphan the subactor
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
os.waitpid(pid, 0)
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
# SIGINT the orphan — should cause clean trio exit
|
|
||||||
os.kill(subactor_pid, signal.SIGINT)
|
|
||||||
|
|
||||||
# poll for exit
|
|
||||||
for _ in range(100):
|
|
||||||
try:
|
|
||||||
os.kill(subactor_pid, 0)
|
|
||||||
time.sleep(0.1)
|
|
||||||
except ProcessLookupError:
|
|
||||||
print('HARNESS: subactor exited cleanly ✔')
|
|
||||||
sys.exit(0)
|
|
||||||
os.kill(subactor_pid, signal.SIGKILL)
|
|
||||||
print('HARNESS: subactor hung — reproduced')
|
|
||||||
sys.exit(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected (current): `HARNESS: subactor hung — reproduced`.
|
|
||||||
|
|
||||||
After fix: `HARNESS: subactor exited cleanly ✔`.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `tractor/spawn/_entry.py::_trio_main:157-186` — the
|
|
||||||
intentional KBI→clean-exit path this bug makes
|
|
||||||
unreachable.
|
|
||||||
- `tractor/spawn/_subint_forkserver` — the backend whose
|
|
||||||
orphan cancel-robustness this blocks.
|
|
||||||
- `tests/spawn/test_subint_forkserver.py::test_orphaned_subactor_sigint_cleanup_DRAFT`
|
|
||||||
— the xfail'd reproducer in the test suite.
|
|
||||||
- `ai/conc-anal/subint_cancel_delivery_hang_issue.md` —
|
|
||||||
sibling "orphaned channel park" hang (different class).
|
|
||||||
- `ai/conc-anal/subint_sigint_starvation_issue.md` —
|
|
||||||
sibling "GIL starvation SIGINT drop" hang (different
|
|
||||||
class).
|
|
||||||
- tractor issue #379 — subint backend tracking.
|
|
||||||
|
|
@ -1,851 +0,0 @@
|
||||||
# `subint_forkserver` backend: `test_cancellation.py` multi-level cancel cascade hang
|
|
||||||
|
|
||||||
> **Tracked at:** [#449](https://github.com/goodboy/tractor/issues/449)
|
|
||||||
|
|
||||||
Follow-up tracker: surfaced while wiring the new
|
|
||||||
`subint_forkserver` spawn backend into the full tractor
|
|
||||||
test matrix (step 2 of the post-backend-lands plan).
|
|
||||||
See also
|
|
||||||
`ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md`
|
|
||||||
— sibling tracker for a different forkserver-teardown
|
|
||||||
class which probably shares the same fundamental root
|
|
||||||
cause (fork-FD-inheritance across nested spawns).
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
`tests/test_cancellation.py::test_nested_multierrors[subint_forkserver]`
|
|
||||||
hangs indefinitely under our new backend. The hang is
|
|
||||||
**inside the graceful IPC cancel cascade** — every actor
|
|
||||||
in the multi-level tree parks in `epoll_wait` waiting
|
|
||||||
for IPC messages that never arrive. Not a hard-kill /
|
|
||||||
tree-reap issue (we don't reach the hard-kill fallback
|
|
||||||
path at all).
|
|
||||||
|
|
||||||
Working hypothesis (unverified): **`os.fork()` from a
|
|
||||||
subactor inherits the root parent's IPC listener socket
|
|
||||||
FDs**. When a first-level subactor forkserver-spawns a
|
|
||||||
grandchild, that grandchild inherits both its direct
|
|
||||||
spawner's FDs AND the root's FDs — IPC message routing
|
|
||||||
becomes ambiguous (or silently sends to the wrong
|
|
||||||
channel), so the cancel cascade can't reach its target.
|
|
||||||
|
|
||||||
## Corrected diagnosis vs. earlier draft
|
|
||||||
|
|
||||||
An earlier version of this doc claimed the root cause
|
|
||||||
was **"forkserver teardown doesn't tree-kill
|
|
||||||
descendants"** (SIGKILL only reaches the direct child,
|
|
||||||
grandchildren survive and hold TCP `:1616`). That
|
|
||||||
diagnosis was **wrong**, caused by conflating two
|
|
||||||
observations:
|
|
||||||
|
|
||||||
1. *5-zombie leak holding :1616* — happened in my own
|
|
||||||
workflow when I aborted a bg pytest task with
|
|
||||||
`pkill` (SIGTERM/SIGKILL, not SIGINT). The abrupt
|
|
||||||
kill skipped the graceful `ActorNursery.__aexit__`
|
|
||||||
cancel cascade entirely, orphaning descendants to
|
|
||||||
init. **This was my cleanup bug, not a forkserver
|
|
||||||
teardown bug.** Codified the fix (SIGINT-first +
|
|
||||||
bounded wait before SIGKILL) in
|
|
||||||
`feedback_sc_graceful_cancel_first.md` +
|
|
||||||
`.claude/skills/run-tests/SKILL.md`.
|
|
||||||
2. *`test_nested_multierrors` hangs indefinitely* —
|
|
||||||
the real, separate, forkserver-specific bug
|
|
||||||
captured by this doc.
|
|
||||||
|
|
||||||
The two symptoms are unrelated. The tree-kill / setpgrp
|
|
||||||
fix direction proposed earlier would not help (1) (SC-
|
|
||||||
graceful-cleanup is the right answer there) and would
|
|
||||||
not help (2) (the hang is in the cancel cascade, not
|
|
||||||
in the hard-kill fallback).
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Reproducer (py3.14, clean env):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# preflight: ensure clean env
|
|
||||||
ss -tlnp 2>/dev/null | grep ':1616' && echo 'FOUL — cleanup first!' || echo 'clean'
|
|
||||||
|
|
||||||
./py314/bin/python -m pytest --spawn-backend=subint_forkserver \
|
|
||||||
'tests/test_cancellation.py::test_nested_multierrors[subint_forkserver]' \
|
|
||||||
--timeout=30 --timeout-method=thread --tb=short -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `pytest-timeout` fires at 30s with a thread-
|
|
||||||
dump banner, but the process itself **remains alive
|
|
||||||
after timeout** and doesn't unwedge on subsequent
|
|
||||||
SIGINT. Requires SIGKILL to reap.
|
|
||||||
|
|
||||||
## Evidence (tree structure at hang point)
|
|
||||||
|
|
||||||
All 5 processes are kernel-level `S` (sleeping) in
|
|
||||||
`do_epoll_wait` (trio's event loop waiting on I/O):
|
|
||||||
|
|
||||||
```
|
|
||||||
PID PPID THREADS NAME ROLE
|
|
||||||
333986 1 2 subint-forkserv pytest main (the test body)
|
|
||||||
333993 333986 3 subint-forkserv "child 1" spawner subactor
|
|
||||||
334003 333993 1 subint-forkserv grandchild errorer under child-1
|
|
||||||
334014 333993 1 subint-forkserv grandchild errorer under child-1
|
|
||||||
333999 333986 1 subint-forkserv "child 2" spawner subactor (NO grandchildren!)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Asymmetric tree depth
|
|
||||||
|
|
||||||
The test's `spawn_and_error(breadth=2, depth=3)` should
|
|
||||||
have BOTH direct children spawning 2 grandchildren
|
|
||||||
each, going 3 levels deep. Reality:
|
|
||||||
|
|
||||||
- Child 1 (333993, 3 threads) DID spawn its two
|
|
||||||
grandchildren as expected — fully booted trio
|
|
||||||
runtime.
|
|
||||||
- Child 2 (333999, 1 thread) did NOT spawn any
|
|
||||||
grandchildren — clearly never completed its
|
|
||||||
nursery's first `run_in_actor`. Its 1-thread state
|
|
||||||
suggests the runtime never fully booted (no trio
|
|
||||||
worker threads for `waitpid`/IPC).
|
|
||||||
|
|
||||||
This asymmetry is the key clue: the two direct
|
|
||||||
children started identically but diverged. Probably a
|
|
||||||
race around fork-inherited state (listener FDs,
|
|
||||||
subactor-nursery channel state) that happens to land
|
|
||||||
differently depending on spawn ordering.
|
|
||||||
|
|
||||||
### Parent-side state
|
|
||||||
|
|
||||||
Thread-dump of pytest main (333986) at the hang:
|
|
||||||
|
|
||||||
- Main trio thread — parked in
|
|
||||||
`trio._core._io_epoll.get_events` (epoll_wait on
|
|
||||||
its event loop). Waiting for IPC from children.
|
|
||||||
- Two trio-cache worker threads — each parked in
|
|
||||||
`outcome.capture(sync_fn)` calling
|
|
||||||
`os.waitpid(child_pid, 0)`. These are our
|
|
||||||
`_ForkedProc.wait()` off-loads. They're waiting for
|
|
||||||
the direct children to exit — but children are
|
|
||||||
stuck in their own epoll_wait waiting for IPC from
|
|
||||||
the parent.
|
|
||||||
|
|
||||||
**It's a deadlock, not a leak:** the parent is
|
|
||||||
correctly running `soft_kill(proc, _ForkedProc.wait,
|
|
||||||
portal)` (graceful IPC cancel via
|
|
||||||
`Portal.cancel_actor()`), but the children never
|
|
||||||
acknowledge the cancel message (or the message never
|
|
||||||
reaches them through the tangled post-fork IPC).
|
|
||||||
|
|
||||||
## What's NOT the cause (ruled out)
|
|
||||||
|
|
||||||
- **`_ForkedProc.kill()` only SIGKILLs direct pid /
|
|
||||||
missing tree-kill**: doesn't apply — we never reach
|
|
||||||
the hard-kill path. The deadlock is in the graceful
|
|
||||||
cancel cascade.
|
|
||||||
- **Port `:1616` contention**: ruled out after the
|
|
||||||
`reg_addr` fixture-wiring fix; each test session
|
|
||||||
gets a unique port now.
|
|
||||||
- **GIL starvation / SIGINT pipe filling** (class-A,
|
|
||||||
`subint_sigint_starvation_issue.md`): doesn't apply
|
|
||||||
— each subactor is its own OS process with its own
|
|
||||||
GIL (not legacy-config subint).
|
|
||||||
- **Child-side `_trio_main` absorbing KBI**: grep
|
|
||||||
confirmed; `_trio_main` only catches KBI at the
|
|
||||||
`trio.run()` callsite, which is reached only if the
|
|
||||||
trio loop exits normally. The children here never
|
|
||||||
exit trio.run() — they're wedged inside.
|
|
||||||
|
|
||||||
## Hypothesis: FD inheritance across nested forks
|
|
||||||
|
|
||||||
`subint_forkserver_proc` calls
|
|
||||||
`fork_from_worker_thread()` which ultimately does
|
|
||||||
`os.fork()` from a dedicated worker thread. Standard
|
|
||||||
Linux/POSIX fork semantics: **the child inherits ALL
|
|
||||||
open FDs from the parent**, including listener
|
|
||||||
sockets, epoll fds, trio wakeup pipes, and the
|
|
||||||
parent's IPC channel sockets.
|
|
||||||
|
|
||||||
At root-actor fork-spawn time, the root's IPC server
|
|
||||||
listener FDs are open in the parent. Those get
|
|
||||||
inherited by child 1. Child 1 then forkserver-spawns
|
|
||||||
its OWN subactor (grandchild). The grandchild
|
|
||||||
inherits FDs from child 1 — but child 1's address
|
|
||||||
space still contains **the root's IPC listener FDs
|
|
||||||
too** (inherited at first fork). So the grandchild
|
|
||||||
has THREE sets of FDs:
|
|
||||||
|
|
||||||
1. Its own (created after becoming a subactor).
|
|
||||||
2. Its direct parent child-1's.
|
|
||||||
3. The ROOT's (grandparent's) — inherited transitively.
|
|
||||||
|
|
||||||
IPC message routing may be ambiguous in this tangled
|
|
||||||
state. Or a listener socket that the root thinks it
|
|
||||||
owns is actually open in multiple processes, and
|
|
||||||
messages sent to it go to an arbitrary one. That
|
|
||||||
would exactly match the observed "graceful cancel
|
|
||||||
never propagates".
|
|
||||||
|
|
||||||
This hypothesis predicts the bug **scales with fork
|
|
||||||
depth**: single-level forkserver spawn
|
|
||||||
(`test_subint_forkserver_spawn_basic`) works
|
|
||||||
perfectly, but any test that spawns a second level
|
|
||||||
deadlocks. Matches observations so far.
|
|
||||||
|
|
||||||
## Fix directions (to validate)
|
|
||||||
|
|
||||||
### 1. `close_fds=True` equivalent in `fork_from_worker_thread()`
|
|
||||||
|
|
||||||
`subprocess.Popen` / `trio.lowlevel.open_process` have
|
|
||||||
`close_fds=True` by default on POSIX — they
|
|
||||||
enumerate open FDs in the child post-fork and close
|
|
||||||
everything except stdio + any explicitly-passed FDs.
|
|
||||||
Our raw `os.fork()` doesn't. Adding the equivalent to
|
|
||||||
our `_worker` prelude would isolate each fork
|
|
||||||
generation's FD set.
|
|
||||||
|
|
||||||
Implementation sketch in
|
|
||||||
`tractor.spawn._subint_forkserver.fork_from_worker_thread._worker`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _worker() -> None:
|
|
||||||
pid: int = os.fork()
|
|
||||||
if pid == 0:
|
|
||||||
# CHILD: close inherited FDs except stdio + the
|
|
||||||
# pid-pipe we just opened.
|
|
||||||
keep: set[int] = {0, 1, 2, rfd, wfd}
|
|
||||||
import resource
|
|
||||||
soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
os.closerange(3, soft) # blunt; or enumerate /proc/self/fd
|
|
||||||
# ... then child_target() as before
|
|
||||||
```
|
|
||||||
|
|
||||||
Problem: overly aggressive — closes FDs the
|
|
||||||
grandchild might legitimately need (e.g. its parent's
|
|
||||||
IPC channel for the spawn-spec handshake, if we rely
|
|
||||||
on that). Needs thought about which FDs are
|
|
||||||
"inheritable and safe" vs. "inherited by accident".
|
|
||||||
|
|
||||||
### 2. Cloexec on tractor's own FDs
|
|
||||||
|
|
||||||
Set `FD_CLOEXEC` on tractor-created sockets (listener
|
|
||||||
sockets, IPC channel sockets, pipes). This flag
|
|
||||||
causes automatic close on `execve`, but since we
|
|
||||||
`fork()` without `exec()`, this alone doesn't help.
|
|
||||||
BUT — combined with a child-side explicit close-
|
|
||||||
non-cloexec loop, it gives us a way to mark "my
|
|
||||||
private FDs" vs. "safe to inherit". Most robust, but
|
|
||||||
requires tractor-wide audit.
|
|
||||||
|
|
||||||
### 3. Explicit FD cleanup in `_ForkedProc`/`_child_target`
|
|
||||||
|
|
||||||
Have `subint_forkserver_proc`'s `_child_target`
|
|
||||||
closure explicitly close the parent-side IPC listener
|
|
||||||
FDs before calling `_actor_child_main`. Requires
|
|
||||||
being able to enumerate "the parent's listener FDs
|
|
||||||
that the child shouldn't keep" — plausible via
|
|
||||||
`Actor.ipc_server`'s socket objects.
|
|
||||||
|
|
||||||
### 4. Use `os.posix_spawn` with explicit `file_actions`
|
|
||||||
|
|
||||||
Instead of raw `os.fork()`, use `os.posix_spawn()`
|
|
||||||
which supports explicit file-action specifications
|
|
||||||
(close this FD, dup2 that FD). Cleaner semantics, but
|
|
||||||
probably incompatible with our "no exec" requirement
|
|
||||||
(subint_forkserver is a fork-without-exec design).
|
|
||||||
|
|
||||||
**Likely correct answer: (3) — targeted FD cleanup
|
|
||||||
via `actor.ipc_server` handle.** (1) is too blunt,
|
|
||||||
(2) is too wide-ranging, (4) changes the spawn
|
|
||||||
mechanism.
|
|
||||||
|
|
||||||
## Reproducer (standalone, no pytest)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# save as /tmp/forkserver_nested_hang_repro.py (py3.14+)
|
|
||||||
import trio, tractor
|
|
||||||
|
|
||||||
async def assert_err():
|
|
||||||
assert 0
|
|
||||||
|
|
||||||
async def spawn_and_error(breadth: int = 2, depth: int = 1):
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
for i in range(breadth):
|
|
||||||
if depth > 0:
|
|
||||||
await n.run_in_actor(
|
|
||||||
spawn_and_error,
|
|
||||||
breadth=breadth,
|
|
||||||
depth=depth - 1,
|
|
||||||
name=f'spawner_{i}_{depth}',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await n.run_in_actor(
|
|
||||||
assert_err,
|
|
||||||
name=f'errorer_{i}',
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _main():
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
for i in range(2):
|
|
||||||
await n.run_in_actor(
|
|
||||||
spawn_and_error,
|
|
||||||
name=f'top_{i}',
|
|
||||||
breadth=2,
|
|
||||||
depth=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from tractor.spawn._spawn import try_set_start_method
|
|
||||||
try_set_start_method('subint_forkserver')
|
|
||||||
with trio.fail_after(20):
|
|
||||||
trio.run(_main)
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected (current): hangs on `trio.fail_after(20)`
|
|
||||||
— children never ack the error-propagation cancel
|
|
||||||
cascade. Pattern: top 2 direct children, 4
|
|
||||||
grandchildren, 1 errorer deadlocks while trying to
|
|
||||||
unwind through its parent chain.
|
|
||||||
|
|
||||||
After fix: `trio.TooSlowError`-free completion; the
|
|
||||||
root's `open_nursery` receives the
|
|
||||||
`BaseExceptionGroup` containing the `AssertionError`
|
|
||||||
from the errorer and unwinds cleanly.
|
|
||||||
|
|
||||||
## Update — 2026-04-23: partial fix landed, deeper layer surfaced
|
|
||||||
|
|
||||||
Three improvements landed as separate commits in the
|
|
||||||
`subint_forkserver_backend` branch (see `git log`):
|
|
||||||
|
|
||||||
1. **`_close_inherited_fds()` in fork-child prelude**
|
|
||||||
(`tractor/spawn/_subint_forkserver.py`). POSIX
|
|
||||||
close-fds-equivalent enumeration via
|
|
||||||
`/proc/self/fd` (or `RLIMIT_NOFILE` fallback), keep
|
|
||||||
only stdio. This is fix-direction (1) from the list
|
|
||||||
above — went with the blunt form rather than the
|
|
||||||
targeted enum-via-`actor.ipc_server` form, turns
|
|
||||||
out the aggressive close is safe because every
|
|
||||||
inheritable resource the fresh child needs
|
|
||||||
(IPC-channel socket, etc.) is opened AFTER the
|
|
||||||
fork anyway.
|
|
||||||
2. **`_ForkedProc.wait()` via `os.pidfd_open()` +
|
|
||||||
`trio.lowlevel.wait_readable()`** — matches the
|
|
||||||
`trio.Process.wait` / `mp.Process.sentinel` pattern
|
|
||||||
used by `trio_proc` and `proc_waiter`. Gives us
|
|
||||||
fully trio-cancellable child-wait (prior impl
|
|
||||||
blocked a cache thread on a sync `os.waitpid` that
|
|
||||||
was NOT trio-cancellable due to
|
|
||||||
`abandon_on_cancel=False`).
|
|
||||||
3. **`_parent_chan_cs` wiring** in
|
|
||||||
`tractor/runtime/_runtime.py`: capture the shielded
|
|
||||||
`loop_cs` for the parent-channel `process_messages`
|
|
||||||
task in `async_main`; explicitly cancel it in
|
|
||||||
`Actor.cancel()` teardown. This breaks the shield
|
|
||||||
during teardown so the parent-chan loop exits when
|
|
||||||
cancel is issued, instead of parking on a parent-
|
|
||||||
socket EOF that might never arrive under fork
|
|
||||||
semantics.
|
|
||||||
|
|
||||||
**Concrete wins from (1):** the sibling
|
|
||||||
`subint_forkserver_orphan_sigint_hang_issue.md` class
|
|
||||||
is **now fixed** — `test_orphaned_subactor_sigint_cleanup_DRAFT`
|
|
||||||
went from strict-xfail to pass. The xfail mark was
|
|
||||||
removed; the test remains as a regression guard.
|
|
||||||
|
|
||||||
**test_nested_multierrors STILL hangs** though.
|
|
||||||
|
|
||||||
### Updated diagnosis (narrowed)
|
|
||||||
|
|
||||||
DIAGDEBUG instrumentation of `process_messages` ENTER/
|
|
||||||
EXIT pairs + `_parent_chan_cs.cancel()` call sites
|
|
||||||
showed (captured during a 20s-timeout repro):
|
|
||||||
|
|
||||||
- 80 `process_messages` ENTERs, 75 EXITs → 5 stuck.
|
|
||||||
- **All 40 `shield=True` ENTERs matched EXIT** — every
|
|
||||||
shielded parent-chan loop exits cleanly. The
|
|
||||||
`_parent_chan_cs` wiring works as intended.
|
|
||||||
- **The 5 stuck loops are all `shield=False`** — peer-
|
|
||||||
channel handlers (inbound connections handled by
|
|
||||||
`handle_stream_from_peer` in stream_handler_tn).
|
|
||||||
- After our `_parent_chan_cs.cancel()` fires, NEW
|
|
||||||
shielded process_messages loops start (on the
|
|
||||||
session reg_addr port — probably discovery-layer
|
|
||||||
reconnection attempts). These don't block teardown
|
|
||||||
(they all exit) but indicate the cancel cascade has
|
|
||||||
more moving parts than expected.
|
|
||||||
|
|
||||||
### Remaining unknown
|
|
||||||
|
|
||||||
Why don't the 5 peer-channel loops exit when
|
|
||||||
`service_tn.cancel_scope.cancel()` fires? They're in
|
|
||||||
`stream_handler_tn` which IS `service_tn` in the
|
|
||||||
current configuration (`open_ipc_server(parent_tn=
|
|
||||||
service_tn, stream_handler_tn=service_tn)`). A
|
|
||||||
standard nursery-scope-cancel should propagate through
|
|
||||||
them — no shield, no special handler. Something
|
|
||||||
specific to the fork-spawned configuration keeps them
|
|
||||||
alive.
|
|
||||||
|
|
||||||
Candidate follow-up experiments:
|
|
||||||
|
|
||||||
- Dump the trio task tree at the hang point (via
|
|
||||||
`stackscope` or direct trio introspection) to see
|
|
||||||
what each stuck loop is awaiting. `chan.__anext__`
|
|
||||||
on a socket recv? An inner lock? A shielded sub-task?
|
|
||||||
- Compare peer-channel handler lifecycle under
|
|
||||||
`trio_proc` vs `subint_forkserver` with equivalent
|
|
||||||
logging to spot the divergence.
|
|
||||||
- Investigate whether the peer handler is caught in
|
|
||||||
the `except trio.Cancelled:` path at
|
|
||||||
`tractor/ipc/_server.py:448` that re-raises — but
|
|
||||||
re-raise means it should still exit. Unless
|
|
||||||
something higher up swallows it.
|
|
||||||
|
|
||||||
### Attempted fix (DID NOT work) — hypothesis (3)
|
|
||||||
|
|
||||||
Tried: in `_serve_ipc_eps` finally, after closing
|
|
||||||
listeners, also iterate `server._peers` and
|
|
||||||
sync-close each peer channel's underlying stream
|
|
||||||
socket fd:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for _uid, _chans in list(server._peers.items()):
|
|
||||||
for _chan in _chans:
|
|
||||||
try:
|
|
||||||
_stream = _chan._transport.stream if _chan._transport else None
|
|
||||||
if _stream is not None:
|
|
||||||
_stream.socket.close() # sync fd close
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
Theory: closing the socket fd from outside the stuck
|
|
||||||
recv task would make the recv see EBADF /
|
|
||||||
ClosedResourceError and unblock.
|
|
||||||
|
|
||||||
Result: `test_nested_multierrors[subint_forkserver]`
|
|
||||||
still hangs identically. Either:
|
|
||||||
- The sync `socket.close()` doesn't propagate into
|
|
||||||
trio's in-flight `recv_some()` the way I expected
|
|
||||||
(trio may hold an internal reference that keeps the
|
|
||||||
fd open even after an external close), or
|
|
||||||
- The stuck recv isn't even the root blocker and the
|
|
||||||
peer handlers never reach the finally for some
|
|
||||||
reason I haven't understood yet.
|
|
||||||
|
|
||||||
Either way, the sync-close hypothesis is **ruled
|
|
||||||
out**. Reverted the experiment, restored the skip-
|
|
||||||
mark on the test.
|
|
||||||
|
|
||||||
### Aside: `-s` flag does NOT change `test_nested_multierrors` behavior
|
|
||||||
|
|
||||||
Tested explicitly: both with and without `-s`, the
|
|
||||||
test hangs identically. So the capture-pipe-fill
|
|
||||||
hypothesis is **ruled out** for this test.
|
|
||||||
|
|
||||||
The earlier `test_context_stream_semantics.py` `-s`
|
|
||||||
observation was most likely caused by a competing
|
|
||||||
pytest run in my session (confirmed via process list
|
|
||||||
— my leftover pytest was alive at that time and
|
|
||||||
could have been holding state on the default
|
|
||||||
registry port).
|
|
||||||
|
|
||||||
## Update — 2026-04-23 (late): cancel delivery ruled in, nursery-wait ruled BLOCKER
|
|
||||||
|
|
||||||
**New diagnostic run** instrumented
|
|
||||||
`handle_stream_from_peer` at ENTER / `except
|
|
||||||
trio.Cancelled:` / finally, plus `Actor.cancel()`
|
|
||||||
just before `self._parent_chan_cs.cancel()`. Result:
|
|
||||||
|
|
||||||
- **40 `handle_stream_from_peer` ENTERs**.
|
|
||||||
- **0 `except trio.Cancelled:` hits** — cancel
|
|
||||||
never fires on any peer-handler.
|
|
||||||
- **35 finally hits** — those handlers exit via
|
|
||||||
peer-initiated EOF (normal return), NOT cancel.
|
|
||||||
- **5 handlers never reach finally** — stuck forever.
|
|
||||||
- **`Actor.cancel()` fired in 12 PIDs** — but the
|
|
||||||
PIDs with peer handlers that DIDN'T fire
|
|
||||||
Actor.cancel are exactly **root + 2 direct
|
|
||||||
spawners**. These 3 actors have peer handlers
|
|
||||||
(for their own subactors) that stay stuck because
|
|
||||||
**`Actor.cancel()` at these levels never runs**.
|
|
||||||
|
|
||||||
### The actual deadlock shape
|
|
||||||
|
|
||||||
`Actor.cancel()` lives in
|
|
||||||
`open_root_actor.__aexit__` / `async_main` teardown.
|
|
||||||
That only runs when the enclosing `async with
|
|
||||||
tractor.open_nursery()` exits. The nursery's
|
|
||||||
`__aexit__` calls the backend `*_proc` spawn target's
|
|
||||||
teardown, which does `soft_kill() →
|
|
||||||
_ForkedProc.wait()` on its child PID. That wait is
|
|
||||||
trio-cancellable via pidfd now (good) — but nothing
|
|
||||||
CANCELS it because the outer scope only cancels when
|
|
||||||
`Actor.cancel()` runs, which only runs when the
|
|
||||||
nursery completes, which waits on the child.
|
|
||||||
|
|
||||||
It's a **multi-level mutual wait**:
|
|
||||||
|
|
||||||
```
|
|
||||||
root blocks on spawner.wait()
|
|
||||||
spawner blocks on grandchild.wait()
|
|
||||||
grandchild blocks on errorer.wait()
|
|
||||||
errorer Actor.cancel() ran, but process
|
|
||||||
may not have fully exited yet
|
|
||||||
(something in root_tn holding on?)
|
|
||||||
```
|
|
||||||
|
|
||||||
Each level waits for the level below. The bottom
|
|
||||||
level (errorer) reaches Actor.cancel(), but its
|
|
||||||
process may not fully exit — meaning its pidfd
|
|
||||||
doesn't go readable, meaning the grandchild's
|
|
||||||
waitpid doesn't return, meaning the grandchild's
|
|
||||||
nursery doesn't unwind, etc. all the way up.
|
|
||||||
|
|
||||||
### Refined question
|
|
||||||
|
|
||||||
**Why does an errorer process not exit after its
|
|
||||||
`Actor.cancel()` completes?**
|
|
||||||
|
|
||||||
Possibilities:
|
|
||||||
1. `_parent_chan_cs.cancel()` fires (shielded
|
|
||||||
parent-chan loop unshielded), but the task is
|
|
||||||
stuck INSIDE the shielded loop's recv in a way
|
|
||||||
that cancel still can't break.
|
|
||||||
2. After `Actor.cancel()` returns, `async_main`
|
|
||||||
still has other tasks in `root_tn` waiting for
|
|
||||||
something that never arrives (e.g. outbound
|
|
||||||
IPC reply delivery).
|
|
||||||
3. The `os._exit(rc)` in `_worker` (at
|
|
||||||
`_subint_forkserver.py`) doesn't run because
|
|
||||||
`_child_target` never returns.
|
|
||||||
|
|
||||||
Next-session candidate probes (in priority order):
|
|
||||||
|
|
||||||
1. **Instrument `_worker`'s fork-child branch** to
|
|
||||||
confirm whether `child_target()` returns (and
|
|
||||||
thus `os._exit(rc)` is reached) for errorer
|
|
||||||
PIDs. If yes → process should die; if no →
|
|
||||||
trace back into `_actor_child_main` /
|
|
||||||
`_trio_main` / `async_main` to find the stuck
|
|
||||||
spot.
|
|
||||||
2. **Instrument `async_main`'s final unwind** to
|
|
||||||
see which await in the teardown doesn't
|
|
||||||
complete.
|
|
||||||
3. **Compare under `trio_proc` backend** at the
|
|
||||||
same `_worker`-equivalent level to see where
|
|
||||||
the flows diverge.
|
|
||||||
|
|
||||||
### Rule-out: NOT a stuck peer-chan recv
|
|
||||||
|
|
||||||
Earlier hypothesis was that the 5 stuck peer-chan
|
|
||||||
loops were blocked on a socket recv that cancel
|
|
||||||
couldn't interrupt. This pass revealed the real
|
|
||||||
cause: cancel **never reaches those tasks** because
|
|
||||||
their owning actor's `Actor.cancel()` never runs.
|
|
||||||
The recvs are fine — they're just parked because
|
|
||||||
nothing is telling them to stop.
|
|
||||||
|
|
||||||
## Update — 2026-04-23 (very late): leaves exit, middle actors stuck in `trio.run`
|
|
||||||
|
|
||||||
Yet another instrumentation pass — this time
|
|
||||||
printing at:
|
|
||||||
|
|
||||||
- `_worker` child branch: `pre child_target()` /
|
|
||||||
`child_target RETURNED rc=N` / `about to
|
|
||||||
os._exit(rc)`
|
|
||||||
- `_trio_main`: `about to trio.run` /
|
|
||||||
`trio.run RETURNED NORMALLY` / `FINALLY`
|
|
||||||
|
|
||||||
**Fresh-run results** (`test_nested_multierrors[
|
|
||||||
subint_forkserver]`, depth=1/breadth=2, 1 root + 14
|
|
||||||
forked = 15 actors total):
|
|
||||||
|
|
||||||
- **9 processes completed the full flow** —
|
|
||||||
`trio.run RETURNED NORMALLY` → `child_target
|
|
||||||
RETURNED rc=0` → `about to os._exit(0)`. These
|
|
||||||
are the LEAVES of the tree (errorer actors) plus
|
|
||||||
their direct parents (depth-0 spawners). They
|
|
||||||
actually exit their processes.
|
|
||||||
- **5 processes are stuck INSIDE `trio.run(trio_main)`**
|
|
||||||
— they hit "about to trio.run" but NEVER see
|
|
||||||
"trio.run RETURNED NORMALLY". These are root +
|
|
||||||
top-level spawners + one intermediate.
|
|
||||||
|
|
||||||
**What this means:** `async_main` itself is the
|
|
||||||
deadlock holder, not the peer-channel loops.
|
|
||||||
Specifically, the outer `async with root_tn:` in
|
|
||||||
`async_main` never exits for the 5 stuck actors.
|
|
||||||
Their `trio.run` never returns → `_trio_main`
|
|
||||||
catch/finally never runs → `_worker` never reaches
|
|
||||||
`os._exit(rc)` → the PROCESS never dies → its
|
|
||||||
parent's `_ForkedProc.wait()` blocks → parent's
|
|
||||||
nursery hangs → parent's `async_main` hangs → ...
|
|
||||||
|
|
||||||
### The new precise question
|
|
||||||
|
|
||||||
**What task in the 5 stuck actors' `async_main`
|
|
||||||
never completes?** Candidates:
|
|
||||||
|
|
||||||
1. The shielded parent-chan `process_messages`
|
|
||||||
task in `root_tn` — but we explicitly cancel it
|
|
||||||
via `_parent_chan_cs.cancel()` in `Actor.cancel()`.
|
|
||||||
However, `Actor.cancel()` only runs during
|
|
||||||
`open_root_actor.__aexit__`, which itself runs
|
|
||||||
only after `async_main`'s outer unwind — which
|
|
||||||
doesn't happen. So the shield isn't broken.
|
|
||||||
|
|
||||||
2. `await actor_nursery._join_procs.wait()` or
|
|
||||||
similar in the inline backend `*_proc` flow.
|
|
||||||
|
|
||||||
3. `_ForkedProc.wait()` on a grandchild that
|
|
||||||
actually DID exit — but the pidfd_open watch
|
|
||||||
didn't fire for some reason (race between
|
|
||||||
pidfd_open and the child exiting?).
|
|
||||||
|
|
||||||
The most specific next probe: **add DIAG around
|
|
||||||
`_ForkedProc.wait()` enter/exit** to see whether
|
|
||||||
the pidfd-based wait returns for every grandchild
|
|
||||||
exit. If a stuck parent's `_ForkedProc.wait()`
|
|
||||||
NEVER returns despite its child exiting, the
|
|
||||||
pidfd mechanism has a race bug under nested
|
|
||||||
forkserver.
|
|
||||||
|
|
||||||
Alternative probe: instrument `async_main`'s outer
|
|
||||||
nursery exits to find which nursery's `__aexit__`
|
|
||||||
is stuck, drilling down from `trio.run` to the
|
|
||||||
specific `async with` that never completes.
|
|
||||||
|
|
||||||
### Cascade summary (updated tree view)
|
|
||||||
|
|
||||||
```
|
|
||||||
ROOT (pytest) STUCK in trio.run
|
|
||||||
├── top_0 (spawner, d=1) STUCK in trio.run
|
|
||||||
│ ├── spawner_0_d1_0 (d=0) exited (os._exit 0)
|
|
||||||
│ │ ├── errorer_0_0 exited (os._exit 0)
|
|
||||||
│ │ └── errorer_0_1 exited (os._exit 0)
|
|
||||||
│ └── spawner_0_d1_1 (d=0) exited (os._exit 0)
|
|
||||||
│ ├── errorer_0_2 exited (os._exit 0)
|
|
||||||
│ └── errorer_0_3 exited (os._exit 0)
|
|
||||||
└── top_1 (spawner, d=1) STUCK in trio.run
|
|
||||||
├── spawner_1_d1_0 (d=0) STUCK in trio.run (sibling race?)
|
|
||||||
│ ├── errorer_1_0 exited
|
|
||||||
│ └── errorer_1_1 exited
|
|
||||||
└── spawner_1_d1_1 (d=0) STUCK in trio.run
|
|
||||||
├── errorer_1_2 exited
|
|
||||||
└── errorer_1_3 exited
|
|
||||||
```
|
|
||||||
|
|
||||||
Grandchildren (d=0 spawners) exit OR stick —
|
|
||||||
asymmetric. Not purely depth-determined. Some race
|
|
||||||
condition in nursery teardown when multiple
|
|
||||||
siblings error simultaneously.
|
|
||||||
|
|
||||||
## Update — 2026-04-23 (late, probe iteration 3): hang pinpointed to `wait_for_no_more_peers()`
|
|
||||||
|
|
||||||
Further DIAGDEBUG at every milestone in `async_main`
|
|
||||||
(runtime UP / EXITED service_tn / EXITED root_tn /
|
|
||||||
FINALLY ENTER / RETURNING) plus `_ForkedProc.wait`
|
|
||||||
ENTER/RETURNED per-pidfd. Result:
|
|
||||||
|
|
||||||
**Every stuck actor reaches `async_main: FINALLY
|
|
||||||
ENTER` but NOT `async_main: RETURNING`.**
|
|
||||||
|
|
||||||
That isolates the hang to a specific await in
|
|
||||||
`async_main`'s finally block at
|
|
||||||
`tractor/runtime/_runtime.py:1837+`. The suspect:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Ensure all peers (actors connected to us as clients) are finished
|
|
||||||
if ipc_server := actor.ipc_server and ipc_server.has_peers(check_chans=True):
|
|
||||||
...
|
|
||||||
await ipc_server.wait_for_no_more_peers() # ← UNBOUNDED, blocks forever
|
|
||||||
```
|
|
||||||
|
|
||||||
`_no_more_peers` is an `Event` set only when
|
|
||||||
`server._peers` empties (see
|
|
||||||
`ipc/_server.py:526-530`). If ANY peer-handler is
|
|
||||||
stuck (the 5 unclosed loops from the earlier pass),
|
|
||||||
it keeps its channel in `server._peers`, so the
|
|
||||||
event never fires, so the wait hangs.
|
|
||||||
|
|
||||||
### Applied fix (partial, landed as defensive-in-depth)
|
|
||||||
|
|
||||||
`tractor/runtime/_runtime.py:1981` —
|
|
||||||
`wait_for_no_more_peers()` call now wrapped in
|
|
||||||
`trio.move_on_after(3.0)` + a warning log when the
|
|
||||||
timeout fires. Commented with the full rationale.
|
|
||||||
|
|
||||||
**Verified:** with this fix, ALL 15 actors reach
|
|
||||||
`async_main: RETURNING` cleanly (up from 10/15
|
|
||||||
reaching end before).
|
|
||||||
|
|
||||||
**Unfortunately:** the test still hangs past 45s
|
|
||||||
total — meaning there's YET ANOTHER unbounded wait
|
|
||||||
downstream of `async_main`. The bounded
|
|
||||||
`wait_for_no_more_peers` unblocks one level, but
|
|
||||||
the cascade has another level above it.
|
|
||||||
|
|
||||||
### Candidates for the remaining hang
|
|
||||||
|
|
||||||
1. `open_root_actor`'s own finally / post-
|
|
||||||
`async_main` flow in `_root.py` — specifically
|
|
||||||
`await actor.cancel(None)` which has its own
|
|
||||||
internal waits.
|
|
||||||
2. The `trio.run()` itself doesn't return even
|
|
||||||
after the root task completes because trio's
|
|
||||||
nursery still has background tasks running.
|
|
||||||
3. Maybe `_serve_ipc_eps`'s finally has an await
|
|
||||||
that blocks when peers aren't clearing.
|
|
||||||
|
|
||||||
### Current stance
|
|
||||||
|
|
||||||
- Defensive `wait_for_no_more_peers` bound landed
|
|
||||||
(good hygiene regardless). Revealing a real
|
|
||||||
deadlock-avoidance gap in tractor's cleanup.
|
|
||||||
- Test still hangs → skip-mark restored on
|
|
||||||
`test_nested_multierrors[subint_forkserver]`.
|
|
||||||
- The full chain of unbounded waits needs another
|
|
||||||
session of drilling, probably at
|
|
||||||
`open_root_actor` / `actor.cancel` level.
|
|
||||||
|
|
||||||
### Summary of this investigation's wins
|
|
||||||
|
|
||||||
1. **FD hygiene fix** (`_close_inherited_fds`) —
|
|
||||||
correct, closed orphan-SIGINT sibling issue.
|
|
||||||
2. **pidfd-based `_ForkedProc.wait`** — cancellable,
|
|
||||||
matches trio_proc pattern.
|
|
||||||
3. **`_parent_chan_cs` wiring** —
|
|
||||||
`Actor.cancel()` now breaks the shielded parent-
|
|
||||||
chan `process_messages` loop.
|
|
||||||
4. **`wait_for_no_more_peers` bounded** —
|
|
||||||
prevents the actor-level finally hang.
|
|
||||||
5. **Ruled-out hypotheses:** tree-kill missing
|
|
||||||
(wrong), stuck socket recv (wrong).
|
|
||||||
6. **Pinpointed remaining unknown:** at least one
|
|
||||||
more unbounded wait in the teardown cascade
|
|
||||||
above `async_main`. Concrete candidates
|
|
||||||
enumerated above.
|
|
||||||
|
|
||||||
## Update — 2026-04-23 (VERY late): pytest capture pipe IS the final gate
|
|
||||||
|
|
||||||
After landing fixes 1-4 and instrumenting every
|
|
||||||
layer down to `tractor_test`'s `trio.run(_main)`:
|
|
||||||
|
|
||||||
**Empirical result: with `pytest -s` the test PASSES
|
|
||||||
in 6.20s.** Without `-s` (default `--capture=fd`) it
|
|
||||||
hangs forever.
|
|
||||||
|
|
||||||
DIAG timeline for the root pytest PID (with `-s`
|
|
||||||
implied from later verification):
|
|
||||||
|
|
||||||
```
|
|
||||||
tractor_test: about to trio.run(_main)
|
|
||||||
open_root_actor: async_main task started, yielding to test body
|
|
||||||
_main: about to await wrapped test fn
|
|
||||||
_main: wrapped RETURNED cleanly ← test body completed!
|
|
||||||
open_root_actor: about to actor.cancel(None)
|
|
||||||
Actor.cancel ENTER req_chan=False
|
|
||||||
Actor.cancel RETURN
|
|
||||||
open_root_actor: actor.cancel RETURNED
|
|
||||||
open_root_actor: outer FINALLY
|
|
||||||
open_root_actor: finally END (returning from ctxmgr)
|
|
||||||
tractor_test: trio.run FINALLY (returned or raised) ← trio.run fully returned!
|
|
||||||
```
|
|
||||||
|
|
||||||
`trio.run()` fully returns. The test body itself
|
|
||||||
completes successfully (pytest.raises absorbed the
|
|
||||||
expected `BaseExceptionGroup`). What blocks is
|
|
||||||
**pytest's own stdout/stderr capture** — under
|
|
||||||
`--capture=fd` default, pytest replaces the parent
|
|
||||||
process's fd 1,2 with pipe write-ends it's reading
|
|
||||||
from. Fork children inherit those pipe fds
|
|
||||||
(because `_close_inherited_fds` correctly preserves
|
|
||||||
stdio). High-volume subactor error-log tracebacks
|
|
||||||
(7+ actors each logging multiple
|
|
||||||
`RemoteActorError`/`ExceptionGroup` tracebacks on
|
|
||||||
the error-propagation cascade) fill the 64KB Linux
|
|
||||||
pipe buffer. Subactor writes block. Subactor can't
|
|
||||||
progress. Process doesn't exit. Parent's
|
|
||||||
`_ForkedProc.wait` (now pidfd-based and
|
|
||||||
cancellable, but nothing's cancelling here since
|
|
||||||
the test body already completed) keeps the pipe
|
|
||||||
reader alive... but pytest isn't draining its end
|
|
||||||
fast enough because test-teardown/fixture-cleanup
|
|
||||||
is in progress.
|
|
||||||
|
|
||||||
**Actually** the exact mechanism is slightly
|
|
||||||
different: pytest's capture fixture MIGHT be
|
|
||||||
actively reading, but faster-than-writer subactors
|
|
||||||
overflow its internal buffer. Or pytest might be
|
|
||||||
blocked itself on the finalization step.
|
|
||||||
|
|
||||||
Either way, `-s` conclusively fixes it.
|
|
||||||
|
|
||||||
### Why I ruled this out earlier (and shouldn't have)
|
|
||||||
|
|
||||||
Earlier in this investigation I tested
|
|
||||||
`test_nested_multierrors` with/without `-s` and
|
|
||||||
both hung. That's because AT THAT TIME, fixes 1-4
|
|
||||||
weren't all in place yet. The test was hanging at
|
|
||||||
multiple deeper levels long before reaching the
|
|
||||||
"generate lots of error-log output" phase. Once
|
|
||||||
the cascade actually tore down cleanly, enough
|
|
||||||
output was produced to hit the capture-pipe limit.
|
|
||||||
|
|
||||||
**Classic order-of-operations mistake in
|
|
||||||
debugging:** ruling something out too early based
|
|
||||||
on a test that was actually failing for a
|
|
||||||
different reason.
|
|
||||||
|
|
||||||
### Fix direction (next session)
|
|
||||||
|
|
||||||
Redirect subactor stdout/stderr to `/dev/null` (or
|
|
||||||
a session-scoped log file) in the fork-child
|
|
||||||
prelude, right after `_close_inherited_fds()`. This
|
|
||||||
severs the inherited pytest-capture pipes and lets
|
|
||||||
subactor output flow elsewhere. Under normal
|
|
||||||
production use (non-pytest), stdout/stderr would
|
|
||||||
be the TTY — we'd want to keep that. So the
|
|
||||||
redirect should be conditional or opt-in via the
|
|
||||||
`child_sigint`/proc_kwargs flag family.
|
|
||||||
|
|
||||||
Alternative: document as a gotcha and recommend
|
|
||||||
`pytest -s` for any tests using the
|
|
||||||
`subint_forkserver` backend with multi-level actor
|
|
||||||
trees. Simpler, user-visible, no code change.
|
|
||||||
|
|
||||||
### Current state
|
|
||||||
|
|
||||||
- Skip-mark on `test_nested_multierrors[subint_forkserver]`
|
|
||||||
restored with reason pointing here.
|
|
||||||
- Test confirmed passing with `-s` after all 4
|
|
||||||
cascade fixes applied.
|
|
||||||
- The 4 cascade fixes are NOT wasted — they're
|
|
||||||
correct hardening regardless of the capture-pipe
|
|
||||||
issue, AND without them we'd never reach the
|
|
||||||
"actually produces enough output to fill the
|
|
||||||
pipe" state.
|
|
||||||
|
|
||||||
## Stopgap (landed)
|
|
||||||
|
|
||||||
`test_nested_multierrors` skip-marked under
|
|
||||||
`subint_forkserver` via
|
|
||||||
`@pytest.mark.skipon_spawn_backend('subint_forkserver',
|
|
||||||
reason='...')`, cross-referenced to this doc. Mark
|
|
||||||
should be dropped once the peer-channel-loop exit
|
|
||||||
issue is fixed.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `tractor/spawn/_subint_forkserver.py::fork_from_worker_thread`
|
|
||||||
— the primitive whose post-fork FD hygiene is
|
|
||||||
probably the culprit.
|
|
||||||
- `tractor/spawn/_subint_forkserver.py::subint_forkserver_proc`
|
|
||||||
— the backend function that orchestrates the
|
|
||||||
graceful cancel path hitting this bug.
|
|
||||||
- `tractor/spawn/_subint_forkserver.py::_ForkedProc`
|
|
||||||
— the `trio.Process`-compatible shim; NOT the
|
|
||||||
failing component (confirmed via thread-dump).
|
|
||||||
- `tests/test_cancellation.py::test_nested_multierrors`
|
|
||||||
— the test that surfaced the hang.
|
|
||||||
- `ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md`
|
|
||||||
— sibling hang class; probably same underlying
|
|
||||||
fork-FD-inheritance root cause.
|
|
||||||
- tractor issue #379 — subint backend tracking.
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
# Revisit `subint_forkserver` thread-cache constraints once msgspec PEP 684 support lands
|
|
||||||
|
|
||||||
> **Tracked at:** [#450](https://github.com/goodboy/tractor/issues/450)
|
|
||||||
|
|
||||||
Follow-up tracker for cleanup work gated on the msgspec
|
|
||||||
PEP 684 adoption upstream ([jcrist/msgspec#563](https://github.com/jcrist/msgspec/issues/563)).
|
|
||||||
|
|
||||||
Context — why this exists
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
The `tractor.spawn._subint_forkserver` submodule currently
|
|
||||||
carries two "non-trio" thread-hygiene constraints whose
|
|
||||||
necessity is tangled with issues that *should* dissolve
|
|
||||||
under PEP 684 isolated-mode subinterpreters:
|
|
||||||
|
|
||||||
1. `fork_from_worker_thread()` / `run_subint_in_worker_thread()`
|
|
||||||
internally allocate a **dedicated `threading.Thread`**
|
|
||||||
rather than using `trio.to_thread.run_sync()`.
|
|
||||||
2. The test helper is named
|
|
||||||
`run_fork_in_non_trio_thread()` — the
|
|
||||||
`non_trio` qualifier is load-bearing today.
|
|
||||||
|
|
||||||
This doc catalogs *why* those constraints exist, which of
|
|
||||||
them isolated-mode would fix, and what the
|
|
||||||
audit-and-cleanup path looks like once msgspec #563 is
|
|
||||||
resolved.
|
|
||||||
|
|
||||||
The three reasons the constraints exist
|
|
||||||
---------------------------------------
|
|
||||||
|
|
||||||
### 1. GIL-starvation class → fixed by PEP 684 isolated mode
|
|
||||||
|
|
||||||
The class-A hang documented in
|
|
||||||
`subint_sigint_starvation_issue.md` is entirely about
|
|
||||||
legacy-config subints **sharing the main GIL**. Once
|
|
||||||
msgspec #563 lands and tractor flips
|
|
||||||
`tractor.spawn._subint` to
|
|
||||||
`concurrent.interpreters.create()` (isolated config), each
|
|
||||||
subint gets its own GIL. Abandoned subint threads can't
|
|
||||||
contend for main's GIL → can't starve the main trio loop
|
|
||||||
→ signal-wakeup-pipe drains normally → no SIGINT-drop.
|
|
||||||
|
|
||||||
This class of hazard **dissolves entirely**. The
|
|
||||||
non-trio-thread requirement for *this reason* disappears.
|
|
||||||
|
|
||||||
### 2. Destroy race / tstate-recycling → orthogonal; unclear
|
|
||||||
|
|
||||||
The `subint_proc` dedicated-thread fix (commit `26fb8206`)
|
|
||||||
addressed a different issue: `_interpreters.destroy(interp_id)`
|
|
||||||
was blocking on a trio-cache worker that had run an
|
|
||||||
earlier `interp.exec()` for that subint. Working
|
|
||||||
hypothesis at the time was "the cached thread retains the
|
|
||||||
subint's tstate".
|
|
||||||
|
|
||||||
But tstate-handling is **not specific to GIL mode** —
|
|
||||||
`_PyXI_Enter` / `_PyXI_Exit` (the C-level machinery both
|
|
||||||
configs use to enter/leave a subint from a thread) should
|
|
||||||
restore the caller's tstate regardless of GIL config. So
|
|
||||||
isolated mode **doesn't obviously fix this**. It might be:
|
|
||||||
|
|
||||||
- A py3.13 bug fixed in later versions — we saw the race
|
|
||||||
first on 3.13 and never re-tested on 3.14 after moving
|
|
||||||
to dedicated threads.
|
|
||||||
- A genuine CPython quirk around cached threads that
|
|
||||||
exec'd into a subint, persisting across GIL modes.
|
|
||||||
- Something else we misdiagnosed — the empirical fix
|
|
||||||
(dedicated thread) worked but the analysis may have
|
|
||||||
been incomplete.
|
|
||||||
|
|
||||||
Only way to know: once we're on isolated mode, empirically
|
|
||||||
retry `trio.to_thread.run_sync(interp.exec, ...)` and see
|
|
||||||
if `destroy()` still blocks. If it does, keep the
|
|
||||||
dedicated thread; if not, one constraint relaxed.
|
|
||||||
|
|
||||||
### 3. Fork-from-main-interp-tstate (the constraint in this module's helper names)
|
|
||||||
|
|
||||||
The fork-from-main-interp-tstate invariant — CPython's
|
|
||||||
`PyOS_AfterFork_Child` →
|
|
||||||
`_PyInterpreterState_DeleteExceptMain` gate documented in
|
|
||||||
`subint_fork_blocked_by_cpython_post_fork_issue.md` — is
|
|
||||||
about the calling thread's **current** tstate at the
|
|
||||||
moment `os.fork()` runs. If trio's cache threads never
|
|
||||||
enter subints at all, their tstate is plain main-interp,
|
|
||||||
and fork from them would be fine.
|
|
||||||
|
|
||||||
The reason the smoke test +
|
|
||||||
`run_fork_in_non_trio_thread` test helper
|
|
||||||
currently use a dedicated `threading.Thread` is narrow:
|
|
||||||
**we don't want to risk a trio cache thread that has
|
|
||||||
previously been used as a subint driver being the one that
|
|
||||||
picks up the fork job**. If cached tstate doesn't get
|
|
||||||
cleared (back to reason #2), the fork's child-side
|
|
||||||
post-init would see the wrong interp and abort.
|
|
||||||
|
|
||||||
In an isolated-mode world where msgspec works:
|
|
||||||
|
|
||||||
- `subint_proc` would use the public
|
|
||||||
`concurrent.interpreters.create()` + `Interpreter.exec()`
|
|
||||||
/ `Interpreter.close()` — which *should* handle tstate
|
|
||||||
cleanly (they're the "blessed" API).
|
|
||||||
- If so, trio's cache threads are safe to fork from
|
|
||||||
regardless of whether they've previously driven subints.
|
|
||||||
- → the `non_trio` qualifier in
|
|
||||||
`run_fork_in_non_trio_thread` becomes
|
|
||||||
*overcautious* rather than load-bearing, and the
|
|
||||||
dedicated-thread primitives in `_subint_forkserver.py`
|
|
||||||
can likely be replaced with straight
|
|
||||||
`trio.to_thread.run_sync()` wrappers.
|
|
||||||
|
|
||||||
TL;DR
|
|
||||||
-----
|
|
||||||
|
|
||||||
| constraint | fixed by isolated mode? |
|
|
||||||
|---|---|
|
|
||||||
| GIL-starvation (class A) | **yes** |
|
|
||||||
| destroy race on cached worker | unclear — empirical test on py3.14 + isolated API required |
|
|
||||||
| fork-from-main-tstate requirement on worker | **probably yes, conditional on the destroy-race question above** |
|
|
||||||
|
|
||||||
If #2 also resolves on py3.14+ with isolated mode,
|
|
||||||
tractor could drop the `non_trio` qualifier from the fork
|
|
||||||
helper's name and just use `trio.to_thread.run_sync(...)`
|
|
||||||
for everything. But **we shouldn't do that preemptively**
|
|
||||||
— the current cautious design is cheap (one dedicated
|
|
||||||
thread per fork / per subint-exec) and correct.
|
|
||||||
|
|
||||||
Audit plan when msgspec #563 lands
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
Assuming msgspec grows `Py_mod_multiple_interpreters`
|
|
||||||
support:
|
|
||||||
|
|
||||||
1. **Flip `tractor.spawn._subint` to isolated mode.** Drop
|
|
||||||
the `_interpreters.create('legacy')` call in favor of
|
|
||||||
the public API (`concurrent.interpreters.create()` +
|
|
||||||
`Interpreter.exec()` / `Interpreter.close()`). Run the
|
|
||||||
three `ai/conc-anal/subint_*_issue.md` reproducers —
|
|
||||||
class-A (`test_stale_entry_is_deleted` etc.) should
|
|
||||||
pass without the `skipon_spawn_backend('subint')` marks
|
|
||||||
(revisit the marker inventory).
|
|
||||||
|
|
||||||
2. **Empirical destroy-race retest.** In `subint_proc`,
|
|
||||||
swap the dedicated `threading.Thread` back to
|
|
||||||
`trio.to_thread.run_sync(Interpreter.exec, ...,
|
|
||||||
abandon_on_cancel=False)` and run the full subint test
|
|
||||||
suite. If `Interpreter.close()` (or the backing
|
|
||||||
destroy) blocks the same way as the legacy version
|
|
||||||
did, revert and keep the dedicated thread.
|
|
||||||
|
|
||||||
3. **If #2 clean**, audit `_subint_forkserver.py`:
|
|
||||||
- Rename `run_fork_in_non_trio_thread` → drop the
|
|
||||||
`_non_trio_` qualifier (e.g. `run_fork_in_thread`) or
|
|
||||||
inline the two-line `trio.to_thread.run_sync` call at
|
|
||||||
the call sites and drop the helper entirely.
|
|
||||||
- Consider whether `fork_from_worker_thread` +
|
|
||||||
`run_subint_in_worker_thread` still warrant being
|
|
||||||
separate module-level primitives or whether they
|
|
||||||
collapse into a compound
|
|
||||||
`trio.to_thread.run_sync`-driven pattern inside the
|
|
||||||
(future) `subint_forkserver_proc` backend.
|
|
||||||
|
|
||||||
4. **Doc fallout.** `subint_sigint_starvation_issue.md`
|
|
||||||
and `subint_cancel_delivery_hang_issue.md` both cite
|
|
||||||
the legacy-GIL-sharing architecture as the root cause.
|
|
||||||
Close them with commit-refs to the isolated-mode
|
|
||||||
migration. This doc itself should get a closing
|
|
||||||
post-mortem section noting which of #1/#2/#3 actually
|
|
||||||
resolved vs persisted.
|
|
||||||
|
|
||||||
References
|
|
||||||
----------
|
|
||||||
|
|
||||||
- `tractor.spawn._subint_forkserver` — the in-tree module
|
|
||||||
whose constraints this doc catalogs.
|
|
||||||
- `ai/conc-anal/subint_sigint_starvation_issue.md` — the
|
|
||||||
GIL-starvation class.
|
|
||||||
- `ai/conc-anal/subint_cancel_delivery_hang_issue.md` —
|
|
||||||
sibling Ctrl-C-able hang class.
|
|
||||||
- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
|
||||||
— why fork-from-subint is blocked (this drives the
|
|
||||||
forkserver-via-non-subint-thread workaround).
|
|
||||||
- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`
|
|
||||||
— empirical validation for the workaround.
|
|
||||||
- [PEP 684 — per-interpreter GIL](https://peps.python.org/pep-0684/)
|
|
||||||
- [PEP 734 — `concurrent.interpreters` public API](https://peps.python.org/pep-0734/)
|
|
||||||
- [jcrist/msgspec#563 — PEP 684 support tracker](https://github.com/jcrist/msgspec/issues/563)
|
|
||||||
- tractor issue #379 — subint backend tracking.
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
# `subint` backend: abandoned-subint thread can wedge main trio event loop (Ctrl-C unresponsive)
|
|
||||||
|
|
||||||
Follow-up to the Phase B subint spawn-backend PR (see
|
|
||||||
`tractor.spawn._subint`, issue #379). The hard-kill escape
|
|
||||||
hatch we landed (`_HARD_KILL_TIMEOUT`, bounded shields,
|
|
||||||
`daemon=True` driver-thread abandonment) handles *most*
|
|
||||||
stuck-subint scenarios cleanly, but there's one class of
|
|
||||||
hang that can't be fully escaped from within tractor: a
|
|
||||||
still-running abandoned sub-interpreter can starve the
|
|
||||||
**parent's** trio event loop to the point where **SIGINT is
|
|
||||||
effectively dropped by the kernel ↔ Python boundary** —
|
|
||||||
making the pytest process un-Ctrl-C-able.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Running `test_stale_entry_is_deleted[subint]` under
|
|
||||||
`--spawn-backend=subint`:
|
|
||||||
|
|
||||||
1. Test spawns a subactor (`transport_fails_actor`) which
|
|
||||||
kills its own IPC server and then
|
|
||||||
`trio.sleep_forever()`.
|
|
||||||
2. Parent tries `Portal.cancel_actor()` → channel
|
|
||||||
disconnected → fast return.
|
|
||||||
3. Nursery teardown triggers our `subint_proc` cancel path.
|
|
||||||
Portal-cancel fails (dead channel),
|
|
||||||
`_HARD_KILL_TIMEOUT` fires, driver thread is abandoned
|
|
||||||
(`daemon=True`), `_interpreters.destroy(interp_id)`
|
|
||||||
raises `InterpreterError` (because the subint is still
|
|
||||||
running).
|
|
||||||
4. Test appears to hang indefinitely at the *outer*
|
|
||||||
`async with tractor.open_nursery() as an:` exit.
|
|
||||||
5. `Ctrl-C` at the terminal does nothing. The pytest
|
|
||||||
process is un-interruptable.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
### `strace` on the hung pytest process
|
|
||||||
|
|
||||||
```
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(37, "\2", 1) = -1 EAGAIN (Resource temporarily unavailable)
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140585542325792
|
|
||||||
```
|
|
||||||
|
|
||||||
Translated:
|
|
||||||
|
|
||||||
- Kernel delivers `SIGINT` to pytest.
|
|
||||||
- CPython's C-level signal handler fires and tries to
|
|
||||||
write the signal number byte (`0x02` = SIGINT) to fd 37
|
|
||||||
— the **Python signal-wakeup fd** (set via
|
|
||||||
`signal.set_wakeup_fd()`, which trio uses to wake its
|
|
||||||
event loop on signals).
|
|
||||||
- Write returns `EAGAIN` — **the pipe is full**. Nothing
|
|
||||||
is draining it.
|
|
||||||
- `rt_sigreturn` with the signal masked off — signal is
|
|
||||||
"handled" from the kernel's perspective but the actual
|
|
||||||
Python-level handler (and therefore trio's
|
|
||||||
`KeyboardInterrupt` delivery) never runs.
|
|
||||||
|
|
||||||
### Stack dump (via `tractor.devx.dump_on_hang`)
|
|
||||||
|
|
||||||
At 20s into the hang, only the **main thread** is visible:
|
|
||||||
|
|
||||||
```
|
|
||||||
Thread 0x...7fdca0191780 [python] (most recent call first):
|
|
||||||
File ".../trio/_core/_io_epoll.py", line 245 in get_events
|
|
||||||
File ".../trio/_core/_run.py", line 2415 in run
|
|
||||||
File ".../tests/discovery/test_registrar.py", line 575 in test_stale_entry_is_deleted
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
No driver thread shows up. The abandoned-legacy-subint
|
|
||||||
thread still exists from the OS's POV (it's still running
|
|
||||||
inside `_interpreters.exec()` driving the subint's
|
|
||||||
`trio.run()` on `trio.sleep_forever()`) but the **main
|
|
||||||
interp's faulthandler can't see threads currently executing
|
|
||||||
inside a sub-interpreter's tstate**. Concretely: the thread
|
|
||||||
is alive, holding state we can't introspect from here.
|
|
||||||
|
|
||||||
## Root cause analysis
|
|
||||||
|
|
||||||
The most consistent explanation for both observations:
|
|
||||||
|
|
||||||
1. **Legacy-config subinterpreters share the main GIL.**
|
|
||||||
PEP 734's public `concurrent.interpreters.create()`
|
|
||||||
defaults to `'isolated'` (per-interp GIL), but tractor
|
|
||||||
uses `_interpreters.create('legacy')` as a workaround
|
|
||||||
for C extensions that don't yet support PEP 684
|
|
||||||
(notably `msgspec`, see
|
|
||||||
[jcrist/msgspec#563](https://github.com/jcrist/msgspec/issues/563)).
|
|
||||||
Legacy-mode subints share process-global state
|
|
||||||
including the GIL.
|
|
||||||
|
|
||||||
2. **Our abandoned subint thread never exits.** After our
|
|
||||||
hard-kill timeout, `driver_thread.join()` is abandoned
|
|
||||||
via `abandon_on_cancel=True` and the thread is
|
|
||||||
`daemon=True` so proc-exit won't block on it — but the
|
|
||||||
thread *itself* is still alive inside
|
|
||||||
`_interpreters.exec()`, driving a `trio.run()` that
|
|
||||||
will never return (the subint actor is in
|
|
||||||
`trio.sleep_forever()`).
|
|
||||||
|
|
||||||
3. **`_interpreters.destroy()` cannot force-stop a running
|
|
||||||
subint.** It raises `InterpreterError` on any
|
|
||||||
still-running subinterpreter; there is no public
|
|
||||||
CPython API to force-destroy one.
|
|
||||||
|
|
||||||
4. **Shared-GIL + non-terminating subint thread → main
|
|
||||||
trio loop starvation.** Under enough load (the subint's
|
|
||||||
trio event loop iterating in the background, IPC-layer
|
|
||||||
tasks still in the subint, etc.) the main trio event
|
|
||||||
loop can fail to iterate frequently enough to drain its
|
|
||||||
wakeup pipe. Once that pipe fills, `SIGINT` writes from
|
|
||||||
the C signal handler return `EAGAIN` and signals are
|
|
||||||
silently dropped — exactly what `strace` shows.
|
|
||||||
|
|
||||||
The shielded
|
|
||||||
`await actor_nursery._join_procs.wait()` at the top of
|
|
||||||
`subint_proc` (inherited unchanged from the `trio_proc`
|
|
||||||
pattern) is structurally involved too: if main trio *does*
|
|
||||||
get a schedule slice, it'd find the `subint_proc` task
|
|
||||||
parked on `_join_procs` under shield — which traps whatever
|
|
||||||
`Cancelled` arrives. But that's a second-order effect; the
|
|
||||||
signal-pipe-full condition is the primary "Ctrl-C doesn't
|
|
||||||
work" cause.
|
|
||||||
|
|
||||||
## Why we can't fix this from inside tractor
|
|
||||||
|
|
||||||
- **No force-destroy API.** CPython provides neither a
|
|
||||||
`_interpreters.force_destroy()` nor a thread-
|
|
||||||
cancellation primitive (`pthread_cancel` is actively
|
|
||||||
discouraged and unavailable on Windows). A subint stuck
|
|
||||||
in pure-Python loops (or worse, C code that doesn't poll
|
|
||||||
for signals) is structurally unreachable from outside.
|
|
||||||
- **Shared GIL is the root scheduling issue.** As long as
|
|
||||||
we're forced into legacy-mode subints for `msgspec`
|
|
||||||
compatibility, the abandoned-thread scenario is
|
|
||||||
fundamentally a process-global GIL-starvation window.
|
|
||||||
- **`signal.set_wakeup_fd()` is process-global.** Even if
|
|
||||||
we wanted to put our own drainer on the wakeup pipe,
|
|
||||||
only one party owns it at a time.
|
|
||||||
|
|
||||||
## Current workaround
|
|
||||||
|
|
||||||
- **Fixture-side SIGINT loop on the `daemon` subproc** (in
|
|
||||||
this test's `daemon: subprocess.Popen` fixture in
|
|
||||||
`tests/conftest.py`). The daemon dying closes its end of
|
|
||||||
the registry IPC, which unblocks a pending recv in main
|
|
||||||
trio's IPC-server task, which lets the event loop
|
|
||||||
iterate, which drains the wakeup pipe, which finally
|
|
||||||
delivers the test-harness SIGINT.
|
|
||||||
- **Module-level skip on py3.13**
|
|
||||||
(`pytest.importorskip('concurrent.interpreters')`) — the
|
|
||||||
private `_interpreters` C module exists on 3.13 but the
|
|
||||||
multi-trio-task interaction hangs silently there
|
|
||||||
independently of this issue.
|
|
||||||
|
|
||||||
## Path forward
|
|
||||||
|
|
||||||
1. **Primary**: upstream `msgspec` PEP 684 adoption
|
|
||||||
([jcrist/msgspec#563](https://github.com/jcrist/msgspec/issues/563)).
|
|
||||||
Unlocks `concurrent.interpreters.create()` isolated
|
|
||||||
mode → per-interp GIL → abandoned subint threads no
|
|
||||||
longer starve the parent's main trio loop. At that
|
|
||||||
point we can flip `_subint.py` back to the public API
|
|
||||||
(`create()` / `Interpreter.exec()` / `Interpreter.close()`)
|
|
||||||
and drop the private `_interpreters` path.
|
|
||||||
|
|
||||||
2. **Secondary**: watch CPython for a public
|
|
||||||
force-destroy primitive. If something like
|
|
||||||
`Interpreter.close(force=True)` lands, we can use it as
|
|
||||||
a hard-kill final stage and actually tear down
|
|
||||||
abandoned subints.
|
|
||||||
|
|
||||||
3. **Harness-level**: document the fixture-side SIGINT
|
|
||||||
loop pattern as the "known workaround" for subint-
|
|
||||||
backend tests that can leave background state holding
|
|
||||||
the main event loop hostage.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- PEP 734 (`concurrent.interpreters`):
|
|
||||||
<https://peps.python.org/pep-0734/>
|
|
||||||
- PEP 684 (per-interpreter GIL):
|
|
||||||
<https://peps.python.org/pep-0684/>
|
|
||||||
- `msgspec` PEP 684 tracker:
|
|
||||||
<https://github.com/jcrist/msgspec/issues/563>
|
|
||||||
- CPython `_interpretersmodule.c` source:
|
|
||||||
<https://github.com/python/cpython/blob/main/Modules/_interpretersmodule.c>
|
|
||||||
- `tractor.spawn._subint` module docstring (in-tree
|
|
||||||
explanation of the legacy-mode choice and its
|
|
||||||
tradeoffs).
|
|
||||||
|
|
||||||
## Reproducer
|
|
||||||
|
|
||||||
```
|
|
||||||
./py314/bin/python -m pytest \
|
|
||||||
tests/discovery/test_registrar.py::test_stale_entry_is_deleted \
|
|
||||||
--spawn-backend=subint \
|
|
||||||
--tb=short --no-header -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Hangs indefinitely without the fixture-side SIGINT loop;
|
|
||||||
with the loop, the test completes (albeit with the
|
|
||||||
abandoned-thread warning in logs).
|
|
||||||
|
|
||||||
## Additional known-hanging tests (same class)
|
|
||||||
|
|
||||||
All three tests below exhibit the same
|
|
||||||
signal-wakeup-fd-starvation fingerprint (`write() → EAGAIN`
|
|
||||||
on the wakeup pipe after enough SIGINT attempts) and
|
|
||||||
share the same structural cause — abandoned legacy-subint
|
|
||||||
driver threads contending with the main interpreter for
|
|
||||||
the shared GIL until the main trio loop can no longer
|
|
||||||
drain its wakeup pipe fast enough to deliver signals.
|
|
||||||
|
|
||||||
They're listed separately because each exposes the class
|
|
||||||
under a different load pattern worth documenting.
|
|
||||||
|
|
||||||
### `tests/discovery/test_registrar.py::test_stale_entry_is_deleted[subint]`
|
|
||||||
|
|
||||||
Original exemplar — see the **Symptom** and **Evidence**
|
|
||||||
sections above. One abandoned subint
|
|
||||||
(`transport_fails_actor`, stuck in `trio.sleep_forever()`
|
|
||||||
after self-cancelling its IPC server) is sufficient to
|
|
||||||
tip main into starvation once the harness's `daemon`
|
|
||||||
fixture subproc keeps its half of the registry IPC alive.
|
|
||||||
|
|
||||||
### `tests/test_cancellation.py::test_cancel_while_childs_child_in_sync_sleep[subint-False]`
|
|
||||||
|
|
||||||
Cancel a grandchild that's in sync Python sleep from 2
|
|
||||||
nurseries up. The test's own docstring declares the
|
|
||||||
dependency: "its parent should issue a 'zombie reaper' to
|
|
||||||
hard kill it after sufficient timeout" — which for
|
|
||||||
`trio`/`mp_*` is an OS-level `SIGKILL` of the grandchild
|
|
||||||
subproc. **Under `subint` there's no equivalent** (no
|
|
||||||
public CPython API to force-destroy a running
|
|
||||||
sub-interpreter), so the grandchild's sync-sleeping
|
|
||||||
`trio.run()` persists inside its abandoned driver thread
|
|
||||||
indefinitely. The nested actor-tree (parent → child →
|
|
||||||
grandchild, all subints) means a single cancel triggers
|
|
||||||
multiple concurrent hard-kill abandonments, each leaving
|
|
||||||
a live driver thread.
|
|
||||||
|
|
||||||
This test often only manifests the starvation under
|
|
||||||
**full-suite runs** rather than solo execution —
|
|
||||||
earlier-in-session subint tests also leave abandoned
|
|
||||||
driver threads behind, and the combined population is
|
|
||||||
what actually tips main trio into starvation. Solo runs
|
|
||||||
may stay Ctrl-C-able with fewer abandoned threads in the
|
|
||||||
mix.
|
|
||||||
|
|
||||||
### `tests/test_cancellation.py::test_multierror_fast_nursery[subint-25-0.5]`
|
|
||||||
|
|
||||||
Nursery-error-path throughput stress-test parametrized
|
|
||||||
for **25 concurrent subactors**. When the multierror
|
|
||||||
fires and the nursery cancels, every subactor goes
|
|
||||||
through our `subint_proc` teardown. The bounded
|
|
||||||
hard-kills run in parallel (all `subint_proc` tasks are
|
|
||||||
sibling trio tasks), so the timeout budget is ~3s total
|
|
||||||
rather than 3s × 25. After that, **25 abandoned
|
|
||||||
`daemon=True` driver threads are simultaneously alive** —
|
|
||||||
an extreme pressure multiplier on the same mechanism.
|
|
||||||
|
|
||||||
The `strace` fingerprint is striking under this load: six
|
|
||||||
or more **successful** `write(16, "\2", 1) = 1` calls
|
|
||||||
(main trio getting brief GIL slices, each long enough to
|
|
||||||
drain exactly one wakeup-pipe byte) before finally
|
|
||||||
saturating with `EAGAIN`:
|
|
||||||
|
|
||||||
```
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = 1
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = -1 EAGAIN (Resource temporarily unavailable)
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 140141623162400
|
|
||||||
```
|
|
||||||
|
|
||||||
Those successful writes indicate CPython's
|
|
||||||
`sys.getswitchinterval()`-based GIL round-robin *is*
|
|
||||||
giving main brief slices — just never long enough to run
|
|
||||||
the Python-level signal handler through to the point
|
|
||||||
where trio converts the delivered SIGINT into a
|
|
||||||
`Cancelled` on the appropriate scope. Once the
|
|
||||||
accumulated write rate outpaces main's drain rate, the
|
|
||||||
pipe saturates and subsequent signals are silently
|
|
||||||
dropped.
|
|
||||||
|
|
||||||
The `pstree` below (pid `530060` = hung `pytest`) shows
|
|
||||||
the subint-driver thread population at the moment of
|
|
||||||
capture. Even with fewer than the full 25 shown (pstree
|
|
||||||
truncates thread names to `subint-driver[<interp_id>` —
|
|
||||||
interpreters `3` and `4` visible across 16 thread
|
|
||||||
entries), the GIL-contender count is more than enough to
|
|
||||||
explain the starvation:
|
|
||||||
|
|
||||||
```
|
|
||||||
>>> pstree -snapt 530060
|
|
||||||
systemd,1 --switched-root --system --deserialize=40
|
|
||||||
└─login,1545 --
|
|
||||||
└─bash,1872
|
|
||||||
└─sway,2012
|
|
||||||
└─alacritty,70471 -e xonsh
|
|
||||||
└─xonsh,70487 .../bin/xonsh
|
|
||||||
└─uv,70955 run xonsh
|
|
||||||
└─xonsh,70959 .../py314/bin/xonsh
|
|
||||||
└─python,530060 .../py314/bin/pytest -v tests/test_cancellation.py --spawn-backend=subint
|
|
||||||
├─{subint-driver[3},531857
|
|
||||||
├─{subint-driver[3},531860
|
|
||||||
├─{subint-driver[3},531862
|
|
||||||
├─{subint-driver[3},531866
|
|
||||||
├─{subint-driver[3},531877
|
|
||||||
├─{subint-driver[3},531882
|
|
||||||
├─{subint-driver[3},531884
|
|
||||||
├─{subint-driver[3},531945
|
|
||||||
├─{subint-driver[3},531950
|
|
||||||
├─{subint-driver[3},531952
|
|
||||||
├─{subint-driver[4},531956
|
|
||||||
├─{subint-driver[4},531959
|
|
||||||
├─{subint-driver[4},531961
|
|
||||||
├─{subint-driver[4},531965
|
|
||||||
├─{subint-driver[4},531968
|
|
||||||
└─{subint-driver[4},531979
|
|
||||||
```
|
|
||||||
|
|
||||||
(`pstree` uses `{...}` to denote threads rather than
|
|
||||||
processes — these are all the **driver OS-threads** our
|
|
||||||
`subint_proc` creates with name
|
|
||||||
`f'subint-driver[{interp_id}]'`. Every one of them is
|
|
||||||
still alive, executing `_interpreters.exec()` inside a
|
|
||||||
sub-interpreter our hard-kill has abandoned. At 16+
|
|
||||||
abandoned driver threads competing for the main GIL, the
|
|
||||||
main-interpreter trio loop gets starved and signal
|
|
||||||
delivery stalls.)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
session: subints-spawner-phase-b1-impl
|
|
||||||
timestamp: 2026-04-17T12:44:37Z
|
|
||||||
git_ref: 5cd6df5
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260417T124437Z_5cd6df5_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Continuing the Phase B work from the design sessions
|
|
||||||
logged in `20260417T034918Z_9703210_prompt_io.md` and
|
|
||||||
the Phase A impl in `20260417T035800Z_61a73ba_...`.
|
|
||||||
|
|
||||||
User direction at this step:
|
|
||||||
- "ok now let's continue with the subints part of the
|
|
||||||
plan ya?" → kick off Phase B.
|
|
||||||
- After I proposed a 4-sub-phase breakdown (B.1
|
|
||||||
scaffolding, B.2 min-viable spawn, B.3 lifecycle,
|
|
||||||
B.4 test enablement), user: "ok can we continue
|
|
||||||
with B" → approving the phasing and design
|
|
||||||
defaults (thread-per-subint, UDS default,
|
|
||||||
code-string bootstrap via `interp.exec()`).
|
|
||||||
- User switched the worktree to
|
|
||||||
`subint_spawner_backend` (instead of opening a
|
|
||||||
fresh worktree as I'd proposed): "i already
|
|
||||||
switched branches in the wkt, you should work off
|
|
||||||
`subint_spawner_backend` now".
|
|
||||||
|
|
||||||
Scope of this turn: **B.1 scaffolding only** —
|
|
||||||
wire the `'subint'` name through the spawn-method
|
|
||||||
registry + harness, add a feature-detected stub
|
|
||||||
module, bump pyproject. No actual spawn flow.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Produced the Phase B.1 scaffolding patch, landed as
|
|
||||||
commit `5cd6df58` (subject: `Add `'subint'` spawn
|
|
||||||
backend scaffold (#379)`). Four files changed,
|
|
||||||
+124 / -2 lines.
|
|
||||||
|
|
||||||
Key pieces (all generated by claude, reviewed by
|
|
||||||
human before commit):
|
|
||||||
- `tractor/spawn/_subint.py` — **new**; feature-
|
|
||||||
detects `concurrent.interpreters`; `subint_proc()`
|
|
||||||
stub raises `RuntimeError` on py<3.14 or
|
|
||||||
`NotImplementedError` with issue-#379 URL on
|
|
||||||
py≥3.14. Signature mirrors `trio_proc`/`mp_proc`
|
|
||||||
so B.2 can drop the impl in without touching
|
|
||||||
`_methods`.
|
|
||||||
- `tractor/spawn/_spawn.py` — adds `'subint'` to
|
|
||||||
`SpawnMethodKey`, grows a `case 'subint'` arm in
|
|
||||||
`try_set_start_method()` with feature-gate, re-
|
|
||||||
imports `sys` for the gate-error msg, adds late
|
|
||||||
`from ._subint import subint_proc` import and
|
|
||||||
`_methods` entry.
|
|
||||||
- `tractor/_testing/pytest.py` — converts the
|
|
||||||
gate-error into `pytest.UsageError` via a
|
|
||||||
`try/except` around `try_set_start_method()` so
|
|
||||||
`--spawn-backend=subint` on py<3.14 prints a
|
|
||||||
clean banner instead of a traceback.
|
|
||||||
- `pyproject.toml` — pin `requires-python` `<3.14`
|
|
||||||
→ `<3.15`, add `3.14` trove classifier.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
See `git diff 5cd6df5~1..5cd6df5 --stat`:
|
|
||||||
|
|
||||||
```
|
|
||||||
pyproject.toml | 3 +-
|
|
||||||
tractor/_testing/pytest.py | 8 +++-
|
|
||||||
tractor/spawn/_spawn.py | 15 ++++++
|
|
||||||
tractor/spawn/_subint.py | 100 +++++++++++++++++++++++++
|
|
||||||
4 files changed, 124 insertions(+), 2 deletions(-)
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation on py3.13:
|
|
||||||
- registry/gate probe — OK; `_has_subints = False`,
|
|
||||||
`subint_proc()` raises `RuntimeError` as expected.
|
|
||||||
- `try_set_start_method('subint')` — raises cleanly.
|
|
||||||
- `pytest --spawn-backend=subint` — exits with
|
|
||||||
`pytest.UsageError` banner.
|
|
||||||
- Spawn-relevant test subset — 69 passed, 1 skipped.
|
|
||||||
- Full suite on py3.13 deferred (scaffolding is
|
|
||||||
no-op on <3.14).
|
|
||||||
- Full-suite on py3.14 pending user setting up the
|
|
||||||
venv.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None — committed as generated by claude. `uv.lock`
|
|
||||||
was intentionally left unstaged by the user (pin-
|
|
||||||
bump regenerated cp314 wheel entries but they were
|
|
||||||
deferred to a follow-up).
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
timestamp: 2026-04-17T12:44:37Z
|
|
||||||
git_ref: 5cd6df5
|
|
||||||
diff_cmd: git diff 5cd6df5~1..5cd6df5
|
|
||||||
---
|
|
||||||
|
|
||||||
Code generated in this turn was committed verbatim as
|
|
||||||
`5cd6df58` ("Add `'subint'` spawn backend scaffold
|
|
||||||
(#379)"). Per diff-ref mode, per-file code is captured
|
|
||||||
via the pointers below, each followed by a prose
|
|
||||||
summary. Non-code output (sanity-check results,
|
|
||||||
design rationale) is included verbatim.
|
|
||||||
|
|
||||||
## Per-file generated content
|
|
||||||
|
|
||||||
### `tractor/spawn/_subint.py` (new, 100 lines)
|
|
||||||
|
|
||||||
> `git diff 5cd6df5~1..5cd6df5 -- tractor/spawn/_subint.py`
|
|
||||||
|
|
||||||
New scaffolding module for the PEP 734 subinterpreter
|
|
||||||
backend. Contents:
|
|
||||||
- AGPL header + module docstring (describes backend
|
|
||||||
intent, 3.14+ availability gate, and explicit
|
|
||||||
"SCAFFOLDING STUB" status pointing at issue #379).
|
|
||||||
- Top-level `try/except ImportError` wrapping
|
|
||||||
`from concurrent import interpreters as
|
|
||||||
_interpreters` → sets module-global
|
|
||||||
`_has_subints: bool`. This lets the registry stay
|
|
||||||
introspectable on py<3.14 while spawn-time still
|
|
||||||
fails cleanly.
|
|
||||||
- `subint_proc()` coroutine with signature matching
|
|
||||||
`trio_proc`/`mp_proc` exactly (same param names,
|
|
||||||
defaults, and `TaskStatus[Portal]` typing) —
|
|
||||||
intentional so Phase B.2 can drop the impl in
|
|
||||||
without touching `_methods` or changing call-site
|
|
||||||
binding.
|
|
||||||
- Body raises `RuntimeError` on py<3.14 (with
|
|
||||||
`sys.version` printed) or `NotImplementedError`
|
|
||||||
with issue-#379 URL on py≥3.14.
|
|
||||||
|
|
||||||
### `tractor/spawn/_spawn.py` (modified, +15 LOC)
|
|
||||||
|
|
||||||
> `git diff 5cd6df5~1..5cd6df5 -- tractor/spawn/_spawn.py`
|
|
||||||
|
|
||||||
- `import sys` re-added (pruned during Phase A, now
|
|
||||||
needed again for the py-version string in the
|
|
||||||
`'subint'` gate-error).
|
|
||||||
- `SpawnMethodKey = Literal[...]` grows `'subint'` as
|
|
||||||
the 4th member, with inline comment `# py3.14+ via
|
|
||||||
`concurrent.interpreters` (PEP 734)`.
|
|
||||||
- `try_set_start_method()` match-block grows a new
|
|
||||||
`case 'subint':` arm that imports
|
|
||||||
`from ._subint import _has_subints` lazily and
|
|
||||||
raises `RuntimeError` with a multi-line gate msg
|
|
||||||
if unavailable.
|
|
||||||
- Bottom-of-module late-import section grows
|
|
||||||
`from ._subint import subint_proc` alongside the
|
|
||||||
existing `_trio` / `_mp` imports.
|
|
||||||
- `_methods` dict grows `'subint': subint_proc`.
|
|
||||||
|
|
||||||
### `tractor/_testing/pytest.py` (modified, +8 LOC)
|
|
||||||
|
|
||||||
> `git diff 5cd6df5~1..5cd6df5 -- tractor/_testing/pytest.py`
|
|
||||||
|
|
||||||
`pytest_configure()` wraps the
|
|
||||||
`try_set_start_method(backend)` call in a
|
|
||||||
`try/except RuntimeError` that re-raises as
|
|
||||||
`pytest.UsageError(str(err))`. Rationale: the gate
|
|
||||||
error on py<3.14 is legitimately a configuration
|
|
||||||
problem, not a test failure, so pytest's UsageError
|
|
||||||
path (exit code 4) gives a clean single-line banner
|
|
||||||
instead of a traceback.
|
|
||||||
|
|
||||||
### `pyproject.toml` (modified, +2 / -1)
|
|
||||||
|
|
||||||
> `git diff 5cd6df5~1..5cd6df5 -- pyproject.toml`
|
|
||||||
|
|
||||||
- `requires-python` pin relaxed `>=3.12, <3.14` →
|
|
||||||
`>=3.12, <3.15` to admit 3.14 as a valid target.
|
|
||||||
- Added `"Programming Language :: Python :: 3.14"`
|
|
||||||
to the trove classifiers.
|
|
||||||
|
|
||||||
## Non-code output (verbatim)
|
|
||||||
|
|
||||||
### Design choices captured in the prior turn
|
|
||||||
|
|
||||||
(Previously confirmed by the user on April 17 in this
|
|
||||||
session — relevant excerpts captured here for
|
|
||||||
provenance since they drove the shape of the impl):
|
|
||||||
|
|
||||||
1. **Python pin**: `<3.15` + feature-gate the subint
|
|
||||||
backend at spawn time — user answered option (a)
|
|
||||||
from the design triage.
|
|
||||||
2. **Deferred `fork()`-via-subint hack** from issue
|
|
||||||
#379's "Our own thoughts" section.
|
|
||||||
3. **Phase B phasing**: user approved the B.1 / B.2 /
|
|
||||||
B.3 / B.4 breakdown — this commit is strictly B.1
|
|
||||||
(scaffolding only, no spawn-flow impl).
|
|
||||||
4. **Option (B) worktree strategy**: new worktree
|
|
||||||
branched from `wkt/spawn_modularize`. *(Amended by
|
|
||||||
user at runtime: user switched the existing
|
|
||||||
`spawn_modularize` worktree to the
|
|
||||||
`subint_spawner_backend` branch instead.)*
|
|
||||||
|
|
||||||
### Sanity-check output (verbatim terminal excerpts)
|
|
||||||
|
|
||||||
Registry / feature-gate verification on py3.13:
|
|
||||||
```
|
|
||||||
SpawnMethodKey values: ('trio', 'mp_spawn',
|
|
||||||
'mp_forkserver', 'subint')
|
|
||||||
_methods keys: ['trio', 'mp_spawn',
|
|
||||||
'mp_forkserver', 'subint']
|
|
||||||
_has_subints: False (py version: (3, 13) )
|
|
||||||
[expected] RuntimeError: The 'subint' spawn backend
|
|
||||||
requires Python 3.14+ (stdlib
|
|
||||||
`concurrent.interpreters`, PEP 734).
|
|
||||||
```
|
|
||||||
|
|
||||||
`try_set_start_method('subint')` gate on py3.13:
|
|
||||||
```
|
|
||||||
[expected] RuntimeError: Spawn method 'subint'
|
|
||||||
requires Python 3.14+ (stdlib
|
|
||||||
`concurrent.interpreters`, PEP 734).
|
|
||||||
```
|
|
||||||
|
|
||||||
Pytest `--spawn-backend=subint` on py3.13 (the new
|
|
||||||
UsageError wrapper kicking in):
|
|
||||||
```
|
|
||||||
ERROR: Spawn method 'subint' requires Python 3.14+
|
|
||||||
(stdlib `concurrent.interpreters`, PEP 734).
|
|
||||||
Current runtime: 3.13.11 (main, Dec 5 2025,
|
|
||||||
16:06:33) [GCC 15.2.0]
|
|
||||||
```
|
|
||||||
|
|
||||||
Collection probe: `404 tests collected in 0.18s`
|
|
||||||
(no import errors from the new module).
|
|
||||||
|
|
||||||
Spawn-relevant test subset (`tests/test_local.py
|
|
||||||
test_rpc.py test_spawning.py test_multi_program.py
|
|
||||||
tests/discovery/`):
|
|
||||||
```
|
|
||||||
69 passed, 1 skipped, 10 warnings in 61.38s
|
|
||||||
```
|
|
||||||
|
|
||||||
Full suite was **not** run on py3.13 for this commit
|
|
||||||
— the scaffolding is no-op on <3.14 and full-suite
|
|
||||||
validation under py3.14 is pending that venv being
|
|
||||||
set up by the user.
|
|
||||||
|
|
||||||
### Commit message
|
|
||||||
|
|
||||||
Also AI-drafted (via `/commit-msg`, with the prose
|
|
||||||
rewrapped through `/home/goodboy/.claude/skills/pr-msg/
|
|
||||||
scripts/rewrap.py --width 67`) — the 33-line message
|
|
||||||
on commit `5cd6df58` itself. Not reproduced here; see
|
|
||||||
`git log -1 5cd6df58`.
|
|
||||||
|
|
||||||
### Known follow-ups flagged to user
|
|
||||||
|
|
||||||
- **`uv.lock` deferred**: pin-bump regenerated cp314
|
|
||||||
wheel entries in `uv.lock`, but the user chose to
|
|
||||||
not stage `uv.lock` for this commit. Warned
|
|
||||||
explicitly.
|
|
||||||
- **Phase B.2 needs py3.14 venv** — running the
|
|
||||||
actual subint impl requires it; user said they'd
|
|
||||||
set it up separately.
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
session: subints-phase-b2-destroy-race-fix
|
|
||||||
timestamp: 2026-04-18T04:25:26Z
|
|
||||||
git_ref: 26fb820
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260418T042526Z_26fb820_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Follow-up to Phase B.2 (`5cd6df58`) after the user
|
|
||||||
observed intermittent mid-suite hangs when running
|
|
||||||
the tractor test suite under `--spawn-backend=subint`
|
|
||||||
on py3.14. The specific sequence of prompts over
|
|
||||||
several turns:
|
|
||||||
|
|
||||||
1. User pointed at the `test_context_stream_semantics.py`
|
|
||||||
suite as the first thing to make run clean under
|
|
||||||
`--spawn-backend=subint`.
|
|
||||||
2. After a series of `timeout`-terminated runs that
|
|
||||||
gave no diagnostic info, user nudged me to stop
|
|
||||||
relying on `timeout` and get actual runtime
|
|
||||||
diagnostics ("the suite hangs indefinitely, so i
|
|
||||||
don't think this `timeout 30` is helping you at
|
|
||||||
all.."). Switched to
|
|
||||||
`faulthandler.dump_traceback_later(...)` and a
|
|
||||||
resource-tracker fixture to rule out leaks.
|
|
||||||
3. Captured a stack pinning the hang on
|
|
||||||
`_interpreters.destroy(interp_id)` in the subint
|
|
||||||
teardown finally block.
|
|
||||||
4. Proposed dedicated-OS-thread fix. User greenlit.
|
|
||||||
5. Implemented + verified on-worktree; user needed
|
|
||||||
to be pointed at the *worktree*'s `./py313` venv
|
|
||||||
because bare `pytest` was picking up the main
|
|
||||||
repo's venv (running un-patched `_subint.py`) and
|
|
||||||
still hanging.
|
|
||||||
|
|
||||||
Running theme over the whole exchange: this patch
|
|
||||||
only closes the *destroy race*. The user and I also
|
|
||||||
traced through the deeper cancellation story — SIGINT
|
|
||||||
can't reach subints, legacy-mode shares the GIL,
|
|
||||||
portal-cancel dies when the IPC channel is already
|
|
||||||
broken — and agreed the next step is a bounded
|
|
||||||
hard-kill in `subint_proc`'s teardown plus a
|
|
||||||
dedicated cancellation test suite. Those land as
|
|
||||||
separate commits.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Produced the `tractor/spawn/_subint.py` patch landed
|
|
||||||
as commit `26fb8206` ("Fix subint destroy race via
|
|
||||||
dedicated OS thread"). One file, +110/-84 LOC.
|
|
||||||
|
|
||||||
Mechanism: swap `trio.to_thread.run_sync(_interpreters
|
|
||||||
.exec, ...)` for a plain `threading.Thread(target=...
|
|
||||||
, daemon=False)`. The trio thread cache recycles
|
|
||||||
workers — so the OS thread that ran `_interpreters
|
|
||||||
.exec()` remained alive in the cache holding a
|
|
||||||
stale subint tstate, blocking
|
|
||||||
`_interpreters.destroy()` in the finally indefinitely.
|
|
||||||
A dedicated one-shot thread exits naturally after
|
|
||||||
the sync target returns, releasing tstate and
|
|
||||||
unblocking destroy.
|
|
||||||
|
|
||||||
Coordination across the trio↔thread boundary:
|
|
||||||
- `trio.lowlevel.current_trio_token()` captured at
|
|
||||||
`subint_proc` entry
|
|
||||||
- driver thread signals `subint_exited.set()` back
|
|
||||||
to parent trio via `trio.from_thread.run_sync(...,
|
|
||||||
trio_token=token)` (synchronous from the thread's
|
|
||||||
POV; the call returns after trio has run `.set()`)
|
|
||||||
- `trio.RunFinishedError` swallowed in that path for
|
|
||||||
the process-teardown case where parent trio already
|
|
||||||
exited
|
|
||||||
- teardown `finally` off-loads the sync
|
|
||||||
`driver_thread.join()` via `to_thread.run_sync` (a
|
|
||||||
cache thread carries no subint tstate — safe)
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
See `git diff 26fb820~1..26fb820 --stat`:
|
|
||||||
|
|
||||||
```
|
|
||||||
tractor/spawn/_subint.py | 194 +++++++++++++++++++------------
|
|
||||||
1 file changed, 110 insertions(+), 84 deletions(-)
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- `test_parent_cancels[chk_ctx_result_before_exit=True-
|
|
||||||
cancel_method=ctx-child_returns_early=False]`
|
|
||||||
(the specific test that was hanging for the user)
|
|
||||||
— passed in 1.06s.
|
|
||||||
- Full `tests/test_context_stream_semantics.py` under
|
|
||||||
subint — 61 passed in 100.35s (clean-cache re-run:
|
|
||||||
100.82s).
|
|
||||||
- Trio backend regression subset — 69 passed / 1
|
|
||||||
skipped / 89.19s — no regressions from this change.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
Beyond the `_subint.py` patch, the raw log also
|
|
||||||
records the cancellation-semantics research that
|
|
||||||
spanned this conversation but did not ship as code
|
|
||||||
in *this* commit. Preserving it inline under "Non-
|
|
||||||
code output" because it directly informs the
|
|
||||||
Phase B.3 hard-kill impl that will follow (and any
|
|
||||||
upstream CPython bug reports we end up filing).
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None — committed as generated. The commit message
|
|
||||||
itself was also AI-drafted via `/commit-msg` and
|
|
||||||
rewrapped via the project's `rewrap.py --width 67`
|
|
||||||
tooling; user landed it without edits.
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
timestamp: 2026-04-18T04:25:26Z
|
|
||||||
git_ref: 26fb820
|
|
||||||
diff_cmd: git diff 26fb820~1..26fb820
|
|
||||||
---
|
|
||||||
|
|
||||||
Code generated in this turn was committed as `26fb8206`
|
|
||||||
("Fix subint destroy race via dedicated OS thread").
|
|
||||||
Per diff-ref mode, per-file code is captured via the
|
|
||||||
pointer below; the rest is verbatim non-code output
|
|
||||||
— test-session diagnostics, CPython-internals
|
|
||||||
reasoning, and the design synthesis that's worth
|
|
||||||
keeping alongside the code since it informs the
|
|
||||||
still-pending Phase B.3 hard-kill work.
|
|
||||||
|
|
||||||
## Per-file generated content
|
|
||||||
|
|
||||||
### `tractor/spawn/_subint.py` (modified, +110/-84 LOC)
|
|
||||||
|
|
||||||
> `git diff 26fb820~1..26fb820 -- tractor/spawn/_subint.py`
|
|
||||||
|
|
||||||
Rewrites the subint driver-thread strategy:
|
|
||||||
- replaces `trio.to_thread.run_sync(_interpreters.exec, ...)`
|
|
||||||
with a plain `threading.Thread(target=_subint_target,
|
|
||||||
daemon=False)` so the OS thread truly exits after
|
|
||||||
`_interpreters.exec()` returns
|
|
||||||
- captures a `trio.lowlevel.current_trio_token()` at
|
|
||||||
`subint_proc` entry; the driver thread signals
|
|
||||||
completion back via `trio.from_thread.run_sync(
|
|
||||||
subint_exited.set, trio_token=...)`
|
|
||||||
- swallows `trio.RunFinishedError` in the signal path
|
|
||||||
for the case where the parent trio loop has already
|
|
||||||
exited (process teardown)
|
|
||||||
- in the teardown `finally` off-loads the sync
|
|
||||||
`driver_thread.join()` call to `trio.to_thread.run_sync`
|
|
||||||
(a cache thread w/ no subint tstate — so no cache
|
|
||||||
conflict) to wait for the driver thread to fully
|
|
||||||
exit before calling `_interpreters.destroy()`
|
|
||||||
|
|
||||||
## Non-code output (verbatim) — the CPython-internals research
|
|
||||||
|
|
||||||
### What went wrong before this commit
|
|
||||||
|
|
||||||
Under `--spawn-backend=subint` on py3.14, most single
|
|
||||||
tests passed but longer runs hung intermittently. The
|
|
||||||
position of the hang moved between runs (test #22 on
|
|
||||||
one run, test #53 on another) suggesting a timing-
|
|
||||||
dependent race rather than a deterministic bug.
|
|
||||||
|
|
||||||
`faulthandler.dump_traceback_later()` eventually
|
|
||||||
caught a stack with the main thread blocked in
|
|
||||||
`_interpreters.destroy(interp_id)` at `_subint.py:293`.
|
|
||||||
Only 2 threads were alive:
|
|
||||||
- main thread waiting in `_interpreters.destroy()`
|
|
||||||
- one idle trio thread-cache worker in
|
|
||||||
`trio._core._thread_cache._work`
|
|
||||||
|
|
||||||
No subint was still running (`_interpreters.list_all()`
|
|
||||||
showed only the main interp). A resource-tracker
|
|
||||||
pytest fixture confirmed threads/subints did NOT
|
|
||||||
accumulate across tests — this was not a leak but a
|
|
||||||
specific "destroy blocks on cached thread w/ stale
|
|
||||||
tstate" race.
|
|
||||||
|
|
||||||
### Why the race exists
|
|
||||||
|
|
||||||
`trio.to_thread.run_sync` uses a thread *cache* to
|
|
||||||
avoid OS-thread creation overhead. When the sync
|
|
||||||
callable returns, the OS thread is NOT terminated —
|
|
||||||
it's parked in `_thread_cache._work` waiting for the
|
|
||||||
next job. CPython's subinterpreter implementation
|
|
||||||
attaches a **tstate** (thread-state object) to each
|
|
||||||
OS thread that ever entered a subint via
|
|
||||||
`_interpreters.exec()`. That tstate is released
|
|
||||||
lazily — either when the thread picks up a new job
|
|
||||||
(which re-attaches a new tstate, evicting the old
|
|
||||||
one) or when the thread truly exits.
|
|
||||||
|
|
||||||
`_interpreters.destroy(interp_id)` waits for *all*
|
|
||||||
tstates associated w/ that subint to be released
|
|
||||||
before it can proceed. If the cached worker is idle
|
|
||||||
holding the stale tstate, destroy blocks indefinitely.
|
|
||||||
Whether the race manifests depends on timing — if
|
|
||||||
the cached thread happens to pick up another job
|
|
||||||
quickly, destroy unblocks; if it sits idle, we hang.
|
|
||||||
|
|
||||||
### Why a dedicated `threading.Thread` fixes it
|
|
||||||
|
|
||||||
A plain `threading.Thread(target=_subint_target,
|
|
||||||
daemon=False)` runs its target once and exits. When
|
|
||||||
the target returns, OS-thread teardown (`_bootstrap_inner`
|
|
||||||
→ `_bootstrap`) fires and CPython releases the
|
|
||||||
tstate for that thread. `_interpreters.destroy()`
|
|
||||||
then has no blocker.
|
|
||||||
|
|
||||||
### Diagnostic tactics that actually helped
|
|
||||||
|
|
||||||
1. `faulthandler.dump_traceback_later(n, repeat=False,
|
|
||||||
file=open(path, 'w'))` for captured stack dumps on
|
|
||||||
hang. Critically, pipe to a `file=` not stderr —
|
|
||||||
pytest captures stderr weirdly and the dump is
|
|
||||||
easy to miss.
|
|
||||||
2. A resource-tracker autouse fixture printing
|
|
||||||
per-test `threading.active_count()` +
|
|
||||||
`len(_interpreters.list_all())` deltas → ruled out
|
|
||||||
leak-accumulation theories quickly.
|
|
||||||
3. Running the hanging test *solo* vs in-suite —
|
|
||||||
when solo passes but in-suite hangs, you know
|
|
||||||
it's a cross-test state-transfer bug rather than
|
|
||||||
a test-internal bug.
|
|
||||||
|
|
||||||
### Design synthesis — SIGINT + subints + SC
|
|
||||||
|
|
||||||
The user and I walked through the cancellation
|
|
||||||
semantics of PEP 684/734 subinterpreters in detail.
|
|
||||||
Key findings we want to preserve:
|
|
||||||
|
|
||||||
**Signal delivery in subints (stdlib limitation).**
|
|
||||||
CPython's signal machinery only delivers signals
|
|
||||||
(SIGINT included) to the *main thread of the main
|
|
||||||
interpreter*. Subints cannot install signal handlers
|
|
||||||
that will ever fire. This is an intentional design
|
|
||||||
choice in PEP 684 and not expected to change. For
|
|
||||||
tractor's subint actors, this means:
|
|
||||||
|
|
||||||
- Ctrl-C never reaches a subint directly.
|
|
||||||
- `trio.run()` running on a worker thread (as we do
|
|
||||||
for subints) already skips SIGINT handler install
|
|
||||||
because `signal.signal()` raises on non-main
|
|
||||||
threads.
|
|
||||||
- The only cancellation surface into a subint is
|
|
||||||
our IPC `Portal.cancel_actor()`.
|
|
||||||
|
|
||||||
**Legacy-mode subints share the main GIL** (which
|
|
||||||
our impl uses since `msgspec` lacks PEP 684 support
|
|
||||||
per `jcrist/msgspec#563`). This means a stuck subint
|
|
||||||
thread can starve the parent's trio loop during
|
|
||||||
cancellation — the parent can't even *start* its
|
|
||||||
teardown handling until the subint yields the GIL.
|
|
||||||
|
|
||||||
**Failure modes identified for Phase B.3 audit:**
|
|
||||||
|
|
||||||
1. Portal cancel lands cleanly → subint unwinds →
|
|
||||||
thread exits → destroy succeeds. (Happy path.)
|
|
||||||
2. IPC channel is already broken when we try to
|
|
||||||
send cancel (e.g., `test_ipc_channel_break_*`)
|
|
||||||
→ cancel raises `BrokenResourceError` → subint
|
|
||||||
keeps running unaware → parent hangs waiting for
|
|
||||||
`subint_exited`. This is what breaks
|
|
||||||
`test_advanced_faults.py` under subint.
|
|
||||||
3. Subint is stuck in non-checkpointing Python code
|
|
||||||
→ portal-cancel msg queued but never processed.
|
|
||||||
4. Subint is in a shielded cancel scope when cancel
|
|
||||||
arrives → delay until shield exits.
|
|
||||||
|
|
||||||
**Current teardown has a shield-bug too:**
|
|
||||||
`trio.CancelScope(shield=True)` wrapping the `finally`
|
|
||||||
block absorbs Ctrl-C, so even when the user tries
|
|
||||||
to break out they can't. This is the reason
|
|
||||||
`test_ipc_channel_break_during_stream[break_parent-...
|
|
||||||
no_msgstream_aclose]` locks up unkillable.
|
|
||||||
|
|
||||||
**B.3 hard-kill fix plan (next commit):**
|
|
||||||
|
|
||||||
1. Bound `driver_thread.join()` with
|
|
||||||
`trio.move_on_after(HARD_KILL_TIMEOUT)`.
|
|
||||||
2. If it times out, log a warning naming the
|
|
||||||
`interp_id` and switch the driver thread to
|
|
||||||
`daemon=True` mode (not actually possible after
|
|
||||||
start — so instead create as daemon=True upfront
|
|
||||||
and accept the tradeoff of proc-exit not waiting
|
|
||||||
for a stuck subint).
|
|
||||||
3. Best-effort `_interpreters.destroy()`; catch the
|
|
||||||
`InterpreterError` if the subint is still running.
|
|
||||||
4. Document that the leak is real and the only
|
|
||||||
escape hatch we have without upstream cooperation.
|
|
||||||
|
|
||||||
**Test plan for Phase B.3:**
|
|
||||||
|
|
||||||
New `tests/test_subint_cancellation.py` covering:
|
|
||||||
- SIGINT at spawn
|
|
||||||
- SIGINT mid-portal-RPC
|
|
||||||
- SIGINT during shielded section in subint
|
|
||||||
- Dead-channel cancel (mirror of `test_ipc_channel_
|
|
||||||
break_during_stream` minimized)
|
|
||||||
- Non-checkpointing subint (tight `while True` in
|
|
||||||
user code)
|
|
||||||
- Per-test `pytest-timeout`-style bounds so the
|
|
||||||
tests visibly fail instead of wedging the runner
|
|
||||||
|
|
||||||
### Sanity-check output (verbatim terminal excerpts)
|
|
||||||
|
|
||||||
Post-fix single-test validation:
|
|
||||||
```
|
|
||||||
1 passed, 1 warning in 1.06s
|
|
||||||
```
|
|
||||||
(same test that was hanging pre-fix:
|
|
||||||
`test_parent_cancels[...cancel_method=ctx-...False]`)
|
|
||||||
|
|
||||||
Full `tests/test_context_stream_semantics.py`
|
|
||||||
under subint:
|
|
||||||
```
|
|
||||||
61 passed, 1 warning in 100.35s (0:01:40)
|
|
||||||
```
|
|
||||||
and a clean-cache re-run:
|
|
||||||
```
|
|
||||||
61 passed, 1 warning in 100.82s (0:01:40)
|
|
||||||
```
|
|
||||||
|
|
||||||
No regressions on trio backend (same subset):
|
|
||||||
```
|
|
||||||
69 passed, 1 skipped, 3 warnings in 89.19s
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit msg
|
|
||||||
|
|
||||||
Also AI-drafted via `/commit-msg` + `rewrap.py
|
|
||||||
--width 67`. See `git log -1 26fb820`.
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
session: subint-phase-b-hang-classification
|
|
||||||
timestamp: 2026-04-20T19:27:39Z
|
|
||||||
git_ref: HEAD (pre-commit; on branch `subint_spawner_backend`)
|
|
||||||
scope: docs
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260420T192739Z_5e8cd8b2_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Collab between user (`goodboy`) and `claude` to
|
|
||||||
disambiguate two distinct hang modes hit during
|
|
||||||
Phase B subint-spawn-backend bringup (issue #379).
|
|
||||||
The user ran the failing suites, captured `strace`
|
|
||||||
evidence on hung pytest pids, and set the framing:
|
|
||||||
|
|
||||||
> "These cannot be the same bug. Different fix
|
|
||||||
> paths. Write them up separately or we'll keep
|
|
||||||
> conflating them."
|
|
||||||
|
|
||||||
Follow-on asks:
|
|
||||||
- Cross-link each doc from its reproducer test so
|
|
||||||
a future triager lands on the analysis without
|
|
||||||
needing to grep `ai/conc-anal/`.
|
|
||||||
- On `test_stale_entry_is_deleted`: arm
|
|
||||||
`dump_on_hang(seconds=20, path=...)` so a
|
|
||||||
regression captures a stack dump (pytest stderr
|
|
||||||
capture otherwise eats `faulthandler` output).
|
|
||||||
- Keep `test_stale_entry_is_deleted` un-`skip`ped
|
|
||||||
so the dump file remains inspectable.
|
|
||||||
|
|
||||||
Defer the actual fix for the class-2 hang
|
|
||||||
(Ctrl-C-able, tractor-side bug) to a later commit
|
|
||||||
per the incremental-commits preference.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Wrote two new `ai/conc-anal/` classification docs
|
|
||||||
and cross-linked them from the reproducer tests:
|
|
||||||
|
|
||||||
1. **`subint_sigint_starvation_issue.md`** —
|
|
||||||
class 1: abandoned-legacy-subint thread +
|
|
||||||
shared GIL starves main trio loop →
|
|
||||||
signal-wakeup-fd pipe fills → SIGINT silently
|
|
||||||
dropped (`write() = EAGAIN`). Pytest process
|
|
||||||
un-Ctrl-C-able. Structurally a CPython limit;
|
|
||||||
blocked on `msgspec` PEP 684 support
|
|
||||||
(jcrist/msgspec#563). Reproducer:
|
|
||||||
`test_stale_entry_is_deleted[subint]`.
|
|
||||||
|
|
||||||
2. **`subint_cancel_delivery_hang_issue.md`** —
|
|
||||||
class 2: parent-side trio task parks on an
|
|
||||||
orphaned IPC channel after subint teardown;
|
|
||||||
no clean EOF delivered to waiting receiver.
|
|
||||||
Ctrl-C-able (main trio loop iterating fine).
|
|
||||||
OUR bug to fix. Candidate fix: explicit
|
|
||||||
parent-side channel abort in `subint_proc`'s
|
|
||||||
hard-kill teardown. Reproducer:
|
|
||||||
`test_subint_non_checkpointing_child`.
|
|
||||||
|
|
||||||
Test-side cross-links:
|
|
||||||
- `tests/discovery/test_registrar.py`:
|
|
||||||
`test_stale_entry_is_deleted` → `trio.run(main)`
|
|
||||||
wrapped in `dump_on_hang(seconds=20,
|
|
||||||
path=<per-method-tmp>)`; long inline comment
|
|
||||||
summarizes `strace` evidence + root-cause chain
|
|
||||||
and points at both docs.
|
|
||||||
- `tests/test_subint_cancellation.py`:
|
|
||||||
`test_subint_non_checkpointing_child` docstring
|
|
||||||
extended with "KNOWN ISSUE (Ctrl-C-able hang)"
|
|
||||||
section pointing at the class-2 doc + noting
|
|
||||||
the class-1 doc is NOT what this test hits.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `ai/conc-anal/subint_sigint_starvation_issue.md`
|
|
||||||
— new, 205 LOC
|
|
||||||
- `ai/conc-anal/subint_cancel_delivery_hang_issue.md`
|
|
||||||
— new, 161 LOC
|
|
||||||
- `tests/discovery/test_registrar.py` — +52/-1
|
|
||||||
(arm `dump_on_hang`, inline-comment cross-link)
|
|
||||||
- `tests/test_subint_cancellation.py` — +26
|
|
||||||
(docstring "KNOWN ISSUE" block)
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
Substantive collab — prose was jointly iterated:
|
|
||||||
|
|
||||||
- User framed the two-doc split, set the
|
|
||||||
classification criteria (Ctrl-C-able vs not),
|
|
||||||
and provided the `strace` evidence.
|
|
||||||
- User decided to keep `test_stale_entry_is_deleted`
|
|
||||||
un-`skip`ped (my initial suggestion was
|
|
||||||
`pytestmark.skipif(spawn_backend=='subint')`).
|
|
||||||
- User chose the candidate fix ordering for
|
|
||||||
class 2 and marked "explicit parent-side channel
|
|
||||||
abort" as the surgical preferred fix.
|
|
||||||
- User picked the file naming convention
|
|
||||||
(`subint_<hang-shape>_issue.md`) over my initial
|
|
||||||
`hang_class_{1,2}.md`.
|
|
||||||
- Assistant drafted the prose, aggregated prior-
|
|
||||||
session root-cause findings from Phase B.2/B.3
|
|
||||||
bringup, and wrote the test-side cross-linking
|
|
||||||
comments.
|
|
||||||
|
|
||||||
No further mechanical edits expected before
|
|
||||||
commit; user may still rewrap via
|
|
||||||
`scripts/rewrap.py` if preferred.
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
---
|
|
||||||
model: claude-opus-4-7[1m]
|
|
||||||
service: claude
|
|
||||||
timestamp: 2026-04-20T19:27:39Z
|
|
||||||
git_ref: HEAD (pre-commit; will land on branch `subint_spawner_backend`)
|
|
||||||
diff_cmd: git diff HEAD~1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
Collab between `goodboy` (user) and `claude` (this
|
|
||||||
assistant) spanning multiple test-run iterations on
|
|
||||||
branch `subint_spawner_backend`. The user ran the
|
|
||||||
failing suites, captured `strace` evidence on the
|
|
||||||
hung pytest pids, and set the direction ("these are
|
|
||||||
two different hangs — write them up separately so
|
|
||||||
we don't re-confuse ourselves later"). The assistant
|
|
||||||
aggregated prior-session findings (Phase B.2/B.3
|
|
||||||
bringup) into two classification docs + test-side
|
|
||||||
cross-links. All prose was jointly iterated; the
|
|
||||||
user had final say on framing and decided which
|
|
||||||
candidate fix directions to list.
|
|
||||||
|
|
||||||
## Per-file generated content
|
|
||||||
|
|
||||||
### `ai/conc-anal/subint_sigint_starvation_issue.md` (new, 205 LOC)
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- ai/conc-anal/subint_sigint_starvation_issue.md`
|
|
||||||
|
|
||||||
Writes up the "abandoned-legacy-subint thread wedges
|
|
||||||
the parent trio loop" class. Key sections:
|
|
||||||
|
|
||||||
- **Symptom** — `test_stale_entry_is_deleted[subint]`
|
|
||||||
hangs indefinitely AND is un-Ctrl-C-able.
|
|
||||||
- **Evidence** — annotated `strace` excerpt showing
|
|
||||||
SIGINT delivered to pytest, C-level signal handler
|
|
||||||
tries to write to the signal-wakeup-fd pipe, gets
|
|
||||||
`write() = -1 EAGAIN (Resource temporarily
|
|
||||||
unavailable)`. Pipe is full because main trio loop
|
|
||||||
isn't iterating often enough to drain it.
|
|
||||||
- **Root-cause chain** — our hard-kill abandons the
|
|
||||||
`daemon=True` driver OS thread after
|
|
||||||
`_HARD_KILL_TIMEOUT`; the subint *inside* that
|
|
||||||
thread is still running `trio.run()`;
|
|
||||||
`_interpreters.destroy()` cannot force-stop a
|
|
||||||
running subint (raises `InterpreterError`); legacy
|
|
||||||
subints share the main GIL → abandoned subint
|
|
||||||
starves main trio loop → wakeup-fd fills → SIGINT
|
|
||||||
silently dropped.
|
|
||||||
- **Why it's structurally a CPython limit** — no
|
|
||||||
public force-destroy primitive for a running
|
|
||||||
subint; the only escape is per-interpreter GIL
|
|
||||||
isolation, gated on msgspec PEP 684 adoption
|
|
||||||
(jcrist/msgspec#563).
|
|
||||||
- **Current escape hatch** — harness-side SIGINT
|
|
||||||
loop in the `daemon` fixture teardown that kills
|
|
||||||
the bg registrar subproc, eventually unblocking
|
|
||||||
a parent-side recv enough for the main loop to
|
|
||||||
drain the wakeup pipe.
|
|
||||||
|
|
||||||
### `ai/conc-anal/subint_cancel_delivery_hang_issue.md` (new, 161 LOC)
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- ai/conc-anal/subint_cancel_delivery_hang_issue.md`
|
|
||||||
|
|
||||||
Writes up the *sibling* hang class — same subint
|
|
||||||
backend, distinct root cause:
|
|
||||||
|
|
||||||
- **TL;DR** — Ctrl-C-able, so NOT the SIGINT-
|
|
||||||
starvation class; main trio loop iterates fine;
|
|
||||||
ours to fix.
|
|
||||||
- **Symptom** — `test_subint_non_checkpointing_child`
|
|
||||||
hangs past the expected `_HARD_KILL_TIMEOUT`
|
|
||||||
budget even after the subint is torn down.
|
|
||||||
- **Diagnosis** — a parent-side trio task (likely
|
|
||||||
a `chan.recv()` in `process_messages`) parks on
|
|
||||||
an orphaned IPC channel; channel was torn down
|
|
||||||
without emitting a clean EOF /
|
|
||||||
`BrokenResourceError` to the waiting receiver.
|
|
||||||
- **Candidate fix directions** — listed in rough
|
|
||||||
order of preference:
|
|
||||||
1. Explicit parent-side channel abort in
|
|
||||||
`subint_proc`'s hard-kill teardown (surgical;
|
|
||||||
most likely).
|
|
||||||
2. Audit `process_messages` to add a timeout or
|
|
||||||
cancel-scope protection that catches the
|
|
||||||
orphaned-recv state.
|
|
||||||
3. Wrap subint IPC channel construction in a
|
|
||||||
sentinel that can force-close from the parent
|
|
||||||
side regardless of subint liveness.
|
|
||||||
|
|
||||||
### `tests/discovery/test_registrar.py` (modified, +52/-1 LOC)
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/discovery/test_registrar.py`
|
|
||||||
|
|
||||||
Wraps the `trio.run(main)` call at the bottom of
|
|
||||||
`test_stale_entry_is_deleted` in
|
|
||||||
`dump_on_hang(seconds=20, path=<per-method-tmp>)`.
|
|
||||||
Adds a long inline comment that:
|
|
||||||
- Enumerates variant-by-variant status
|
|
||||||
(`[trio]`/`[mp_*]` = clean; `[subint]` = hangs
|
|
||||||
+ un-Ctrl-C-able)
|
|
||||||
- Summarizes the `strace` evidence and root-cause
|
|
||||||
chain inline (so a future reader hitting this
|
|
||||||
test doesn't need to cross-ref the doc to
|
|
||||||
understand the hang shape)
|
|
||||||
- Points at
|
|
||||||
`ai/conc-anal/subint_sigint_starvation_issue.md`
|
|
||||||
for full analysis
|
|
||||||
- Cross-links to the *sibling*
|
|
||||||
`subint_cancel_delivery_hang_issue.md` so
|
|
||||||
readers can tell the two classes apart
|
|
||||||
- Explains why it's kept un-`skip`ped: the dump
|
|
||||||
file is useful if the hang ever returns after
|
|
||||||
a refactor. pytest stderr capture would
|
|
||||||
otherwise eat `faulthandler` output, hence the
|
|
||||||
file path.
|
|
||||||
|
|
||||||
### `tests/test_subint_cancellation.py` (modified, +26 LOC)
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_subint_cancellation.py`
|
|
||||||
|
|
||||||
Extends the docstring of
|
|
||||||
`test_subint_non_checkpointing_child` with a
|
|
||||||
"KNOWN ISSUE (Ctrl-C-able hang)" block:
|
|
||||||
- Describes the current hang: parent-side orphaned
|
|
||||||
IPC recv after hard-kill; distinct from the
|
|
||||||
SIGINT-starvation sibling class.
|
|
||||||
- Cites `strace` distinguishing signal: wakeup-fd
|
|
||||||
`write() = 1` (not `EAGAIN`) — i.e. main loop
|
|
||||||
iterating.
|
|
||||||
- Points at
|
|
||||||
`ai/conc-anal/subint_cancel_delivery_hang_issue.md`
|
|
||||||
for full analysis + candidate fix directions.
|
|
||||||
- Clarifies that the *other* sibling doc
|
|
||||||
(SIGINT-starvation) is NOT what this test hits.
|
|
||||||
|
|
||||||
## Non-code output
|
|
||||||
|
|
||||||
### Classification reasoning (why two docs, not one)
|
|
||||||
|
|
||||||
The user and I converged on the two-doc split after
|
|
||||||
running the suites and noticing two *qualitatively
|
|
||||||
different* hang symptoms:
|
|
||||||
|
|
||||||
1. `test_stale_entry_is_deleted[subint]` — pytest
|
|
||||||
process un-Ctrl-C-able. Ctrl-C at the terminal
|
|
||||||
does nothing. Must kill-9 from another shell.
|
|
||||||
2. `test_subint_non_checkpointing_child` — pytest
|
|
||||||
process Ctrl-C-able. One Ctrl-C at the prompt
|
|
||||||
unblocks cleanly and the test reports a hang
|
|
||||||
via pytest-timeout.
|
|
||||||
|
|
||||||
From the user: "These cannot be the same bug.
|
|
||||||
Different fix paths. Write them up separately or
|
|
||||||
we'll keep conflating them."
|
|
||||||
|
|
||||||
`strace` on the `[subint]` hang gave the decisive
|
|
||||||
signal for the first class:
|
|
||||||
|
|
||||||
```
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(5, "\2", 1) = -1 EAGAIN (Resource temporarily unavailable)
|
|
||||||
```
|
|
||||||
|
|
||||||
fd 5 is Python's signal-wakeup-fd pipe. `EAGAIN`
|
|
||||||
on a `write()` of 1 byte to a pipe means the pipe
|
|
||||||
buffer is full → reader side (main Python thread
|
|
||||||
inside `trio.run()`) isn't consuming. That's the
|
|
||||||
GIL-hostage signature.
|
|
||||||
|
|
||||||
The second class's `strace` showed `write(5, "\2",
|
|
||||||
1) = 1` — clean drain — so the main trio loop was
|
|
||||||
iterating and the hang had to be on the application
|
|
||||||
side of things, not the kernel-↔-Python signal
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
### Why the candidate fix for class 2 is "explicit parent-side channel abort"
|
|
||||||
|
|
||||||
The second hang class has the trio loop alive. A
|
|
||||||
parked `chan.recv()` that will never get bytes is
|
|
||||||
fundamentally a tractor-side resource-lifetime bug
|
|
||||||
— the IPC channel was torn down (subint destroyed)
|
|
||||||
but no one explicitly raised
|
|
||||||
`BrokenResourceError` at the parent-side receiver.
|
|
||||||
The `subint_proc` hard-kill path is the natural
|
|
||||||
place to add that notification, because it already
|
|
||||||
knows the subint is unreachable at that point.
|
|
||||||
|
|
||||||
Alternative fix paths (blanket timeouts on
|
|
||||||
`process_messages`, sentinel-wrapped channels) are
|
|
||||||
less surgical and risk masking unrelated bugs —
|
|
||||||
hence the preference ordering in the doc.
|
|
||||||
|
|
||||||
### Why we're not just patching the code now
|
|
||||||
|
|
||||||
The user explicitly deferred the fix to a later
|
|
||||||
commit: "Document both classes now, land the fix
|
|
||||||
for class 2 separately so the diff reviews clean."
|
|
||||||
This matches the incremental-commits preference
|
|
||||||
from memory.
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
---
|
|
||||||
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 "<script>", line 2 in <module>
|
|
||||||
<script>:2: DeprecationWarning: This process (pid=802985) is multi-threaded, use of fork() may lead to deadlocks in the child.
|
|
||||||
```
|
|
||||||
|
|
||||||
Walked CPython sources (local clone at `~/repos/cpython/`):
|
|
||||||
|
|
||||||
- **`Modules/posixmodule.c:728` `PyOS_AfterFork_Child()`** —
|
|
||||||
post-fork child-side cleanup. Calls
|
|
||||||
`_PyInterpreterState_DeleteExceptMain(runtime)` with
|
|
||||||
`goto fatal_error` on non-zero status. Has the
|
|
||||||
`// Ideally we could guarantee tstate is running main.`
|
|
||||||
self-acknowledging-fragile comment directly above.
|
|
||||||
|
|
||||||
- **`Python/pystate.c:1040`
|
|
||||||
`_PyInterpreterState_DeleteExceptMain()`** — the
|
|
||||||
refusal. Hard `PyStatus_ERR("not main interpreter")` gate
|
|
||||||
when `tstate->interp != interpreters->main`. Docstring
|
|
||||||
formally declares the precondition ("If there is a
|
|
||||||
current interpreter state, it *must* be the main
|
|
||||||
interpreter"). `XXX` comments acknowledge further latent
|
|
||||||
issues within.
|
|
||||||
|
|
||||||
Definitive answer to "Open Question 1" of the prototype
|
|
||||||
docstring: **no, CPython does not support `os.fork()` from
|
|
||||||
a non-main sub-interpreter**. Not because the fork syscall
|
|
||||||
is blocked (it isn't — the parent returns a valid pid),
|
|
||||||
but because the child cannot survive CPython's post-fork
|
|
||||||
initialization. This is an enforced invariant, not an
|
|
||||||
incidental limitation.
|
|
||||||
|
|
||||||
### Revert: move to stub submod + doc the finding
|
|
||||||
|
|
||||||
Per user request:
|
|
||||||
|
|
||||||
1. Reverted the working `subint_fork_proc` body to a
|
|
||||||
`NotImplementedError` stub, MOVED to its own submod
|
|
||||||
`tractor/spawn/_subint_fork.py` (keeps `_subint.py`
|
|
||||||
focused on the working `subint_proc` backend).
|
|
||||||
2. Updated `_spawn.py` to import the stub from the new
|
|
||||||
submod path; kept `'subint_fork'` in `SpawnMethodKey` +
|
|
||||||
`_methods` so `--spawn-backend=subint_fork` routes to a
|
|
||||||
clean `NotImplementedError` with pointer to the analysis
|
|
||||||
doc rather than an "invalid backend" error.
|
|
||||||
3. Wrote
|
|
||||||
`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
|
||||||
with the full annotated CPython walkthrough + an
|
|
||||||
upstream-report draft for the CPython issue tracker.
|
|
||||||
Draft has a two-tier ask: ideally "make it work"
|
|
||||||
(pre-fork tstate-swap hook or `DeleteExceptFor(interp)`
|
|
||||||
variant), minimally "give us a clean `RuntimeError` in
|
|
||||||
the parent instead of a `Fatal Python error` aborting
|
|
||||||
the child silently".
|
|
||||||
|
|
||||||
### Design discussion — main-interp-thread forkserver workaround
|
|
||||||
|
|
||||||
User proposed: set up a "subint forking server" that fork()s
|
|
||||||
on behalf of subint callers. Core insight: the CPython gate
|
|
||||||
is on `tstate->interp`, not thread identity, so **any thread
|
|
||||||
whose tstate is main-interp** can fork cleanly. A worker
|
|
||||||
thread attached to main-interp (never entering a subint)
|
|
||||||
satisfies the precondition.
|
|
||||||
|
|
||||||
Structurally this is `mp.forkserver` (which tractor already
|
|
||||||
has as `mp_forkserver`) but **in-process**: instead of a
|
|
||||||
separate Python subproc as the fork server, we'd put the
|
|
||||||
forkserver on a thread in the tractor parent process. Pros:
|
|
||||||
faster spawn (no IPC marshalling to external server + no
|
|
||||||
separate Python startup), inherits already-imported modules
|
|
||||||
for free. Cons: less crash isolation (forkserver failure
|
|
||||||
takes the whole process).
|
|
||||||
|
|
||||||
Required tractor-side refactor: move the root actor's
|
|
||||||
`trio.run()` off main-interp-main-thread (so main-thread can
|
|
||||||
run the forkserver loop). Nontrivial; approximately the same
|
|
||||||
magnitude as "Phase C".
|
|
||||||
|
|
||||||
The design would also not fully resolve the class-A
|
|
||||||
GIL-starvation issue because child actors' trio still runs
|
|
||||||
inside subints (legacy config, msgspec PEP 684 pending).
|
|
||||||
Would mitigate SIGINT-starvation specifically if signal
|
|
||||||
handling moves to the forkserver thread.
|
|
||||||
|
|
||||||
Recommended pre-commitment: a standalone CPython-only smoke
|
|
||||||
test validating the four assumptions the arch rests on,
|
|
||||||
before any tractor-side work.
|
|
||||||
|
|
||||||
### Smoke-test script drafted
|
|
||||||
|
|
||||||
Wrote `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`:
|
|
||||||
argparse-driven, four scenarios (`control_subint_thread_fork`
|
|
||||||
reproducing the known-broken case, `main_thread_fork`
|
|
||||||
baseline, `worker_thread_fork` the architectural assertion,
|
|
||||||
`full_architecture` end-to-end with trio in a subint in the
|
|
||||||
forked child). No `tractor` imports; pure CPython + `_interpreters`
|
|
||||||
+ `trio`. Bails cleanly on py<3.14. Pass/fail banners per
|
|
||||||
scenario.
|
|
||||||
|
|
||||||
User will validate on their py3.14 env next.
|
|
||||||
|
|
||||||
## Per-code-artifact provenance
|
|
||||||
|
|
||||||
### `tractor/spawn/_subint_fork.py` (new submod)
|
|
||||||
|
|
||||||
> `git show 797f57c -- tractor/spawn/_subint_fork.py`
|
|
||||||
|
|
||||||
NotImplementedError stub for the subint-fork backend. Module
|
|
||||||
docstring + fn docstring explain the attempt, the CPython
|
|
||||||
block, and why the stub is kept in-tree. No runtime behavior
|
|
||||||
beyond raising with a pointer at the conc-anal doc.
|
|
||||||
|
|
||||||
### `tractor/spawn/_spawn.py` (modified)
|
|
||||||
|
|
||||||
> `git log 26fb820..HEAD -- tractor/spawn/_spawn.py`
|
|
||||||
|
|
||||||
- Added `'subint_fork'` to `SpawnMethodKey` literal with a
|
|
||||||
block comment explaining the CPython-level block.
|
|
||||||
- Generalized the `case 'subint':` arm to `case 'subint' |
|
|
||||||
'subint_fork':` since both use the same py3.14+ gate.
|
|
||||||
- Registered `subint_fork_proc` in `_methods` with a
|
|
||||||
pointer-comment at the analysis doc.
|
|
||||||
|
|
||||||
### `tractor/spawn/_subint.py` (modified across session)
|
|
||||||
|
|
||||||
> `git log 26fb820..HEAD -- tractor/spawn/_subint.py`
|
|
||||||
|
|
||||||
- Tightened `_has_subints` gate: dual-requires public
|
|
||||||
`concurrent.interpreters` + private `_interpreters`
|
|
||||||
(tests for py3.14-or-newer on the public-API presence,
|
|
||||||
then uses the private one for legacy-config subints
|
|
||||||
because `msgspec` still blocks the public isolated mode
|
|
||||||
per jcrist/msgspec#563).
|
|
||||||
- Updated module docstring, `subint_proc()` docstring, and
|
|
||||||
gate-error messages to reflect the 3.14+ requirement and
|
|
||||||
the reason (py3.13 wedges under multi-trio usage even
|
|
||||||
though the private module exists there).
|
|
||||||
|
|
||||||
### `tractor/_testing/pytest.py` (modified)
|
|
||||||
|
|
||||||
> `git log 26fb820..HEAD -- tractor/_testing/pytest.py`
|
|
||||||
|
|
||||||
- New `skipon_spawn_backend(*start_methods, reason=...)`
|
|
||||||
pytest marker expanded into `pytest.mark.skip(reason=...)`
|
|
||||||
at collection time via
|
|
||||||
`pytest_collection_modifyitems()`.
|
|
||||||
- Implementation uses `item.iter_markers(name=...)` which
|
|
||||||
walks function + class + module scopes uniformly and
|
|
||||||
handles both `pytestmark = <single Mark>` and
|
|
||||||
`pytestmark = [mark, ...]` forms natively. ~30-LOC
|
|
||||||
single-loop refactor replacing a prior nested
|
|
||||||
conditional that had four bugs (see "Review" narrative
|
|
||||||
above).
|
|
||||||
- Added `pytest.Config` / `pytest.Function` /
|
|
||||||
`pytest.FixtureRequest` type annotations on fixture
|
|
||||||
signatures while touching the file.
|
|
||||||
|
|
||||||
### `pyproject.toml` (modified)
|
|
||||||
|
|
||||||
> `git log 26fb820..HEAD -- pyproject.toml`
|
|
||||||
|
|
||||||
Added `pytest-timeout>=2.3` to `testing` dep group with
|
|
||||||
comment pointing at the `ai/conc-anal/` docs.
|
|
||||||
|
|
||||||
### `tests/discovery/test_registrar.py`,
|
|
||||||
`tests/test_subint_cancellation.py`,
|
|
||||||
`tests/test_cancellation.py` (modified)
|
|
||||||
|
|
||||||
> `git log 26fb820..HEAD -- tests/`
|
|
||||||
|
|
||||||
Applied `@pytest.mark.timeout(30, method='thread')` on
|
|
||||||
known-hanging subint tests. Extended comments to cross-
|
|
||||||
reference the `ai/conc-anal/*.md` docs. `method='thread'`
|
|
||||||
is documented inline as load-bearing (`signal`-method
|
|
||||||
SIGALRM suffers the same GIL-starvation path that drops
|
|
||||||
SIGINT).
|
|
||||||
|
|
||||||
### `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md` (new)
|
|
||||||
|
|
||||||
> `git show 797f57c -- ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
|
||||||
|
|
||||||
Third sibling doc under `conc-anal/`. Structure: TL;DR,
|
|
||||||
context ("what we tried"), symptom (the user's exact
|
|
||||||
`Fatal Python error` output), CPython source walkthrough
|
|
||||||
with excerpted snippets from `posixmodule.c` +
|
|
||||||
`pystate.c`, chain summary, definitive answer to Open
|
|
||||||
Question 1, `## Upstream-report draft (for CPython issue
|
|
||||||
tracker)` section with a two-tier ask, references.
|
|
||||||
|
|
||||||
### `ai/conc-anal/subint_fork_from_main_thread_smoketest.py` (new, THIS turn)
|
|
||||||
|
|
||||||
Zero-tractor-import smoke test for the proposed workaround
|
|
||||||
architecture. Four argparse-driven scenarios covering the
|
|
||||||
control case + baseline + arch-critical case + end-to-end.
|
|
||||||
Pass/fail banners per scenario; clean `--help` output;
|
|
||||||
py3.13 early-exit.
|
|
||||||
|
|
||||||
## Non-code output (verbatim)
|
|
||||||
|
|
||||||
### The `strace` signature that kicked off the CPython
|
|
||||||
walkthrough
|
|
||||||
|
|
||||||
```
|
|
||||||
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
|
|
||||||
write(16, "\2", 1) = -1 EAGAIN (Resource temporarily unavailable)
|
|
||||||
rt_sigreturn({mask=[WINCH]}) = 139801964688928
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key user quotes framing the direction
|
|
||||||
|
|
||||||
> ok actually we get this [fatal error] ... see if you can
|
|
||||||
> take a look at what's going on, in particular wrt to
|
|
||||||
> cpython's sources. pretty sure there's a local copy at
|
|
||||||
> ~/repos/cpython/
|
|
||||||
|
|
||||||
(Drove the CPython walkthrough that produced the
|
|
||||||
definitive refusal chain.)
|
|
||||||
|
|
||||||
> is there any reason we can't just sidestep this "must fork
|
|
||||||
> from main thread in main subint" issue by simply ensuring
|
|
||||||
> a "subint forking server" is always setup prior to
|
|
||||||
> invoking trio in a non-main-thread subint ...
|
|
||||||
|
|
||||||
(Drove the main-interp-thread-forkserver architectural
|
|
||||||
discussion + smoke-test script design.)
|
|
||||||
|
|
||||||
### CPython source tags for quick jump-back
|
|
||||||
|
|
||||||
```
|
|
||||||
Modules/posixmodule.c:728 PyOS_AfterFork_Child()
|
|
||||||
Modules/posixmodule.c:753 // Ideally we could guarantee tstate is running main.
|
|
||||||
Modules/posixmodule.c:778 status = _PyInterpreterState_DeleteExceptMain(runtime);
|
|
||||||
|
|
||||||
Python/pystate.c:1040 _PyInterpreterState_DeleteExceptMain()
|
|
||||||
Python/pystate.c:1044-1047 tstate->interp != main → PyStatus_ERR("not main interpreter")
|
|
||||||
```
|
|
||||||
|
|
@ -12,7 +12,7 @@ import tractor
|
||||||
# disable `pbdp` prompt colors
|
# disable `pbdp` prompt colors
|
||||||
# for prompt matching in test.
|
# for prompt matching in test.
|
||||||
def disable_pdbp_color():
|
def disable_pdbp_color():
|
||||||
if os.environ['PYTHON_COLORS'] == '0':
|
if os.environ.get('PYTHON_COLORS') == '0':
|
||||||
from tractor.devx.debug import _repl
|
from tractor.devx.debug import _repl
|
||||||
_repl.TractorConfig.use_pygments = False
|
_repl.TractorConfig.use_pygments = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ classifiers = [
|
||||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
"Topic :: System :: Distributed Computing",
|
"Topic :: System :: Distributed Computing",
|
||||||
|
|
@ -83,13 +82,13 @@ testing = [
|
||||||
# test suite
|
# test suite
|
||||||
# TODO: maybe some of these layout choices?
|
# TODO: maybe some of these layout choices?
|
||||||
# https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules
|
# https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules
|
||||||
# bumped 8.3.5 → 9.0 per upstream security advisory + our
|
# bumped 8.3.5 → 9.0.3 per upstream security advisory + our
|
||||||
# local-only reliance on the post-9.0 capture-machinery shape
|
# local-only reliance on the post-9.0 capture-machinery shape
|
||||||
# (the `sys.__stderr__`-bypass print in
|
# (the `sys.__stderr__`-bypass print in
|
||||||
# `tractor._testing.trace._do_capture_snapshot` works on 8.x
|
# `tractor._testing.trace._do_capture_snapshot` works on 8.x
|
||||||
# too, but standardizing on 9.x here ensures `--show-capture`
|
# too, but standardizing on 9.x here ensures `--show-capture`
|
||||||
# interactions stay predictable across dev installs).
|
# interactions stay predictable across dev installs).
|
||||||
"pytest>=9.0",
|
"pytest>=9.0.3", # CVE-2025-71176 (insecure tmpdir) patched in 9.0.3
|
||||||
"pexpect>=4.9.0,<5",
|
"pexpect>=4.9.0,<5",
|
||||||
# per-test wall-clock bound (used via
|
# per-test wall-clock bound (used via
|
||||||
# `@pytest.mark.timeout(..., method='thread')` on the
|
# `@pytest.mark.timeout(..., method='thread')` on the
|
||||||
|
|
|
||||||
|
|
@ -96,28 +96,42 @@ def cpu_scaling_factor() -> float:
|
||||||
much to inflate time-limits when CPU-freq scaling is active on
|
much to inflate time-limits when CPU-freq scaling is active on
|
||||||
linux.
|
linux.
|
||||||
|
|
||||||
When no scaling info is available (non-linux, missing sysfs),
|
When no local scaling info is available (non-linux, missing
|
||||||
returns 1.0 (i.e. no headroom adjustment needed).
|
sysfs) the base factor is 1.0; a flat CI bump is then applied
|
||||||
|
on top (see below).
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if _non_linux:
|
factor: float = 1.
|
||||||
return 1.
|
if not _non_linux:
|
||||||
|
|
||||||
mx = get_cpu_state()
|
mx = get_cpu_state()
|
||||||
cur = get_cpu_state(setting='scaling_max_freq')
|
cur = get_cpu_state(setting='scaling_max_freq')
|
||||||
if mx is None or cur is None:
|
if (
|
||||||
return 1.
|
mx is not None
|
||||||
|
and
|
||||||
|
cur is not None
|
||||||
|
):
|
||||||
_mx_pth, max_freq = mx
|
_mx_pth, max_freq = mx
|
||||||
_cur_pth, cur_freq = cur
|
_cur_pth, cur_freq = cur
|
||||||
cpu_scaled: float = int(cur_freq) / int(max_freq)
|
cpu_scaled: float = int(cur_freq) / int(max_freq)
|
||||||
|
|
||||||
if cpu_scaled != 1.:
|
if cpu_scaled != 1.:
|
||||||
return 1. / (
|
factor = 1. / (
|
||||||
cpu_scaled * 2 # <- bc likely "dual threaded"
|
cpu_scaled * 2 # <- bc likely "dual threaded"
|
||||||
)
|
)
|
||||||
|
|
||||||
return 1.
|
# XXX, GH Actions (and most shared) CI runners are slow + noisy
|
||||||
|
# and — unlike a throttled local box — do NOT expose CPU-freq
|
||||||
|
# scaling via sysfs, so the probe above reads 1.0 and adds no
|
||||||
|
# headroom. Apply a flat CI bump so every timing-test deadline
|
||||||
|
# /assert that keys off this factor gets headroom on CI HW
|
||||||
|
# (compounds with any local-throttle factor).
|
||||||
|
#
|
||||||
|
# macOS runners are noticeably slower + noisier than the linux
|
||||||
|
# ones for our multi-actor cancel-cascade tests, so give them
|
||||||
|
# extra headroom (3x vs 2x).
|
||||||
|
if _ci_env:
|
||||||
|
factor *= 3 if _non_linux else 2
|
||||||
|
|
||||||
|
return factor
|
||||||
|
|
||||||
|
|
||||||
# session-cached sustained-load throttle multiplier — measured
|
# session-cached sustained-load throttle multiplier — measured
|
||||||
|
|
|
||||||
|
|
@ -1,652 +0,0 @@
|
||||||
'''
|
|
||||||
Integration exercises for the `tractor.spawn._main_thread_forkserver`
|
|
||||||
submodule at three tiers:
|
|
||||||
|
|
||||||
1. the low-level primitives
|
|
||||||
(`fork_from_worker_thread()` from `_main_thread_forkserver`
|
|
||||||
+ `run_subint_in_worker_thread()` from
|
|
||||||
`_subint_forkserver`) driven from inside a real
|
|
||||||
`trio.run()` in the parent process,
|
|
||||||
|
|
||||||
2. the full `main_thread_forkserver_proc` spawn backend wired
|
|
||||||
through tractor's normal actor-nursery + portal-RPC
|
|
||||||
machinery — i.e. `open_root_actor` + `open_nursery` +
|
|
||||||
`run_in_actor` against a subactor spawned via fork from a
|
|
||||||
main-interp worker thread.
|
|
||||||
|
|
||||||
Background
|
|
||||||
----------
|
|
||||||
`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
|
||||||
establishes that `os.fork()` from a non-main sub-interpreter
|
|
||||||
aborts the child at the CPython level. The sibling
|
|
||||||
`subint_fork_from_main_thread_smoketest.py` proves the escape
|
|
||||||
hatch: fork from a main-interp *worker thread* (one that has
|
|
||||||
never entered a subint) works, and the forked child can then
|
|
||||||
host its own `trio.run()` inside a fresh subint.
|
|
||||||
|
|
||||||
Those smoke-test scenarios are standalone — no trio runtime
|
|
||||||
in the *parent*. Tiers (1)+(2) here cover the primitives
|
|
||||||
driven from inside `trio.run()` in the parent, and tier (3)
|
|
||||||
(the `*_spawn_basic` test) drives the registered
|
|
||||||
`main_thread_forkserver` spawn backend end-to-end against
|
|
||||||
the tractor runtime.
|
|
||||||
|
|
||||||
Gating
|
|
||||||
------
|
|
||||||
- py3.14+ (via `concurrent.interpreters` presence)
|
|
||||||
- no `--spawn-backend` restriction — the backend-level test
|
|
||||||
flips `tractor.spawn._spawn._spawn_method` programmatically
|
|
||||||
(via `try_set_start_method('main_thread_forkserver')`) and
|
|
||||||
restores it on teardown, so these tests are independent of
|
|
||||||
the session-level CLI backend choice.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from functools import partial
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import platform
|
|
||||||
import select
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import trio
|
|
||||||
|
|
||||||
import tractor
|
|
||||||
from tractor.devx import dump_on_hang
|
|
||||||
|
|
||||||
|
|
||||||
# Gate: subint forkserver primitives require py3.14+. Check
|
|
||||||
# the public stdlib wrapper's presence (added in 3.14) rather
|
|
||||||
# than `_interpreters` directly — see
|
|
||||||
# `tractor.spawn._subint` for why.
|
|
||||||
pytest.importorskip('concurrent.interpreters')
|
|
||||||
|
|
||||||
from tractor.spawn._main_thread_forkserver import ( # noqa: E402
|
|
||||||
fork_from_worker_thread,
|
|
||||||
wait_child,
|
|
||||||
)
|
|
||||||
from tractor.spawn._subint_forkserver import ( # noqa: E402
|
|
||||||
run_subint_in_worker_thread,
|
|
||||||
)
|
|
||||||
from tractor.spawn import _spawn as _spawn_mod # noqa: E402
|
|
||||||
from tractor.spawn._spawn import try_set_start_method # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# child-side callables (passed via `child_target=` across fork)
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_CHILD_TRIO_BOOTSTRAP: str = (
|
|
||||||
'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'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _child_trio_in_subint() -> int:
|
|
||||||
'''
|
|
||||||
`child_target` for the trio-in-child scenario: drive a
|
|
||||||
trivial `trio.run()` inside a fresh legacy-config subint
|
|
||||||
on a worker thread.
|
|
||||||
|
|
||||||
Returns an exit code suitable for `os._exit()`:
|
|
||||||
- 0: subint-hosted `trio.run()` succeeded
|
|
||||||
- 3: driver thread hang (timeout inside `run_subint_in_worker_thread`)
|
|
||||||
- 4: subint bootstrap raised some other exception
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
run_subint_in_worker_thread(
|
|
||||||
_CHILD_TRIO_BOOTSTRAP,
|
|
||||||
thread_name='child-subint-trio-thread',
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
# timeout / thread-never-returned
|
|
||||||
return 3
|
|
||||||
except BaseException:
|
|
||||||
return 4
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# parent-side harnesses (run inside `trio.run()`)
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def run_fork_in_non_trio_thread(
|
|
||||||
deadline: float,
|
|
||||||
*,
|
|
||||||
child_target=None,
|
|
||||||
) -> int:
|
|
||||||
'''
|
|
||||||
From inside a parent `trio.run()`, off-load the
|
|
||||||
forkserver primitive to a main-interp worker thread via
|
|
||||||
`trio.to_thread.run_sync()` and return the forked child's
|
|
||||||
pid.
|
|
||||||
|
|
||||||
Then `wait_child()` on that pid (also off-loaded so we
|
|
||||||
don't block trio's event loop on `waitpid()`) and assert
|
|
||||||
the child exited cleanly.
|
|
||||||
|
|
||||||
'''
|
|
||||||
with trio.fail_after(deadline):
|
|
||||||
# NOTE: `fork_from_worker_thread` internally spawns its
|
|
||||||
# own dedicated `threading.Thread` (not from trio's
|
|
||||||
# cache) and joins it before returning — so we can
|
|
||||||
# safely off-load via `to_thread.run_sync` without
|
|
||||||
# worrying about the trio-thread-cache recycling the
|
|
||||||
# runner. Pass `abandon_on_cancel=False` for the
|
|
||||||
# same "bounded + clean" rationale we use in
|
|
||||||
# `_subint.subint_proc`.
|
|
||||||
pid: int = await trio.to_thread.run_sync(
|
|
||||||
partial(
|
|
||||||
fork_from_worker_thread,
|
|
||||||
child_target,
|
|
||||||
thread_name='test-subint-forkserver',
|
|
||||||
),
|
|
||||||
abandon_on_cancel=False,
|
|
||||||
)
|
|
||||||
assert pid > 0
|
|
||||||
|
|
||||||
ok, status_str = await trio.to_thread.run_sync(
|
|
||||||
partial(
|
|
||||||
wait_child,
|
|
||||||
pid,
|
|
||||||
expect_exit_ok=True,
|
|
||||||
),
|
|
||||||
abandon_on_cancel=False,
|
|
||||||
)
|
|
||||||
assert ok, (
|
|
||||||
f'forked child did not exit cleanly: '
|
|
||||||
f'{status_str}'
|
|
||||||
)
|
|
||||||
return pid
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# tests
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Bounded wall-clock via `pytest-timeout` (`method='thread'`)
|
|
||||||
# for the usual GIL-hostage safety reason documented in the
|
|
||||||
# sibling `test_subint_cancellation.py` / the class-A
|
|
||||||
# `subint_sigint_starvation_issue.md`. Each test also has an
|
|
||||||
# inner `trio.fail_after()` so assertion failures fire fast
|
|
||||||
# under normal conditions.
|
|
||||||
# @pytest.mark.timeout(30, method='thread')
|
|
||||||
def test_fork_from_worker_thread_via_trio(
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Baseline: inside `trio.run()`, call
|
|
||||||
`fork_from_worker_thread()` via `trio.to_thread.run_sync()`,
|
|
||||||
get a child pid back, reap the child cleanly.
|
|
||||||
|
|
||||||
No trio-in-child. If this regresses we know the parent-
|
|
||||||
side trio↔worker-thread plumbing is broken independent
|
|
||||||
of any child-side subint machinery.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = 10.0
|
|
||||||
with dump_on_hang(
|
|
||||||
seconds=deadline,
|
|
||||||
path='/tmp/main_thread_forkserver_baseline.dump',
|
|
||||||
):
|
|
||||||
pid: int = trio.run(
|
|
||||||
partial(run_fork_in_non_trio_thread, deadline),
|
|
||||||
)
|
|
||||||
# parent-side sanity — we got a real pid back.
|
|
||||||
assert isinstance(pid, int) and pid > 0
|
|
||||||
# by now the child has been waited on; it shouldn't be
|
|
||||||
# reap-able again.
|
|
||||||
with pytest.raises((ChildProcessError, OSError)):
|
|
||||||
os.waitpid(pid, os.WNOHANG)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.timeout(30, method='thread')
|
|
||||||
def test_fork_and_run_trio_in_child() -> None:
|
|
||||||
'''
|
|
||||||
End-to-end: inside the parent's `trio.run()`, off-load
|
|
||||||
`fork_from_worker_thread()` to a worker thread, have the
|
|
||||||
forked child then create a fresh subint and run
|
|
||||||
`trio.run()` inside it on yet another worker thread.
|
|
||||||
|
|
||||||
This is the full "forkserver + trio-in-subint-in-child"
|
|
||||||
pattern the proposed `main_thread_forkserver` spawn backend
|
|
||||||
would rest on.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = 15.0
|
|
||||||
with dump_on_hang(
|
|
||||||
seconds=deadline,
|
|
||||||
path='/tmp/main_thread_forkserver_trio_in_child.dump',
|
|
||||||
):
|
|
||||||
pid: int = trio.run(
|
|
||||||
partial(
|
|
||||||
run_fork_in_non_trio_thread,
|
|
||||||
deadline,
|
|
||||||
child_target=_child_trio_in_subint,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert isinstance(pid, int) and pid > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# tier-3 backend test: drive the registered `main_thread_forkserver`
|
|
||||||
# spawn backend end-to-end through tractor's actor-nursery +
|
|
||||||
# portal-RPC machinery.
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _trivial_rpc() -> str:
|
|
||||||
'''
|
|
||||||
Minimal subactor-side RPC body: just return a sentinel
|
|
||||||
string the parent can assert on.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return 'hello from subint-forkserver child'
|
|
||||||
|
|
||||||
|
|
||||||
async def _happy_path_forkserver(
|
|
||||||
reg_addr: tuple[str, int | str],
|
|
||||||
deadline: float,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Parent-side harness: stand up a root actor, open an actor
|
|
||||||
nursery, spawn one subactor via the currently-selected
|
|
||||||
spawn backend (which this test will have flipped to
|
|
||||||
`main_thread_forkserver`), run a trivial RPC through its
|
|
||||||
portal, assert the round-trip result.
|
|
||||||
|
|
||||||
'''
|
|
||||||
with trio.fail_after(deadline):
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(
|
|
||||||
registry_addrs=[reg_addr],
|
|
||||||
),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
portal: tractor.Portal = await an.run_in_actor(
|
|
||||||
_trivial_rpc,
|
|
||||||
name='subint-forkserver-child',
|
|
||||||
)
|
|
||||||
result: str = await portal.wait_for_result()
|
|
||||||
assert result == 'hello from subint-forkserver child'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def forkserver_spawn_method():
|
|
||||||
'''
|
|
||||||
Flip `tractor.spawn._spawn._spawn_method` to
|
|
||||||
`'main_thread_forkserver'` for the duration of a test,
|
|
||||||
then restore whatever was in place before (usually the
|
|
||||||
session-level CLI choice, typically `'trio'`).
|
|
||||||
|
|
||||||
Without this, other tests in the same session would
|
|
||||||
observe the global flip and start spawning via fork —
|
|
||||||
which is almost certainly NOT what their assertions were
|
|
||||||
written against.
|
|
||||||
|
|
||||||
'''
|
|
||||||
prev_method: str = _spawn_mod._spawn_method
|
|
||||||
prev_ctx = _spawn_mod._ctx
|
|
||||||
try_set_start_method('main_thread_forkserver')
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
_spawn_mod._spawn_method = prev_method
|
|
||||||
_spawn_mod._ctx = prev_ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.timeout(60, method='thread')
|
|
||||||
def test_main_thread_forkserver_spawn_basic(
|
|
||||||
reg_addr: tuple[str, int | str],
|
|
||||||
forkserver_spawn_method,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Happy-path: spawn ONE subactor via the
|
|
||||||
`main_thread_forkserver` backend (parent-side fork from a
|
|
||||||
main-interp worker thread), do a trivial portal-RPC
|
|
||||||
round-trip, tear the nursery down cleanly.
|
|
||||||
|
|
||||||
If this passes, the "forkserver + tractor runtime" arch
|
|
||||||
is proven end-to-end: the registered
|
|
||||||
`main_thread_forkserver_proc` spawn target successfully
|
|
||||||
forks a child, the child runs `_actor_child_main()` +
|
|
||||||
completes IPC handshake + serves an RPC, and the parent
|
|
||||||
reaps via `_ForkedProc.wait()` without regressing any of
|
|
||||||
the normal nursery teardown invariants.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = 20.0
|
|
||||||
with dump_on_hang(
|
|
||||||
seconds=deadline,
|
|
||||||
path='/tmp/main_thread_forkserver_spawn_basic.dump',
|
|
||||||
):
|
|
||||||
trio.run(
|
|
||||||
partial(
|
|
||||||
_happy_path_forkserver,
|
|
||||||
reg_addr,
|
|
||||||
deadline,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# tier-4 DRAFT: orphaned-subactor SIGINT survivability
|
|
||||||
#
|
|
||||||
# Motivating question: with `main_thread_forkserver`, the child's
|
|
||||||
# `trio.run()` lives on the fork-inherited worker thread which
|
|
||||||
# is NOT `threading.main_thread()` — so trio cannot install its
|
|
||||||
# `signal.set_wakeup_fd`-based SIGINT handler. If the parent
|
|
||||||
# goes away via `SIGKILL` (no IPC `Portal.cancel_actor()`
|
|
||||||
# possible), does SIGINT on the orphan child cleanly tear it
|
|
||||||
# down via CPython's default `KeyboardInterrupt` delivery, or
|
|
||||||
# does it hang?
|
|
||||||
#
|
|
||||||
# Working hypothesis (unverified pre-this-test): post-fork the
|
|
||||||
# child is effectively single-threaded (only the fork-worker
|
|
||||||
# tstate survived), so SIGINT → default handler → raises
|
|
||||||
# `KeyboardInterrupt` on the only thread — which happens to be
|
|
||||||
# the one driving trio's event loop — so trio observes it at
|
|
||||||
# the next checkpoint. If so, we're "fine" on this backend
|
|
||||||
# despite the missing trio SIGINT handler.
|
|
||||||
#
|
|
||||||
# Cross-backend generalization (decide after this passes):
|
|
||||||
# - applicable to any backend whose subactors are separate OS
|
|
||||||
# processes: `trio`, `mp_spawn`, `mp_forkserver`,
|
|
||||||
# `main_thread_forkserver`.
|
|
||||||
# - NOT applicable to plain `subint` (subactors are in-process
|
|
||||||
# subinterpreters, no orphan child process to SIGINT).
|
|
||||||
# - move path: lift the harness script into
|
|
||||||
# `tests/_orphan_harness.py`, parametrize on the session's
|
|
||||||
# `_spawn_method`, add `skipif _spawn_method == 'subint'`.
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_ORPHAN_HARNESS_SCRIPT: str = '''
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import trio
|
|
||||||
import tractor
|
|
||||||
from tractor.spawn._spawn import try_set_start_method
|
|
||||||
|
|
||||||
async def _sleep_forever() -> None:
|
|
||||||
print(f"CHILD_PID={os.getpid()}", flush=True)
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
async def _main(reg_addr):
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(registry_addrs=[reg_addr]),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
portal = await an.run_in_actor(
|
|
||||||
_sleep_forever,
|
|
||||||
name="orphan-test-child",
|
|
||||||
)
|
|
||||||
print(f"PARENT_READY={os.getpid()}", flush=True)
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
backend = sys.argv[1]
|
|
||||||
host = sys.argv[2]
|
|
||||||
port = int(sys.argv[3])
|
|
||||||
try_set_start_method(backend)
|
|
||||||
trio.run(_main, (host, port))
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _read_marker(
|
|
||||||
proc: subprocess.Popen,
|
|
||||||
marker: str,
|
|
||||||
timeout: float,
|
|
||||||
_buf: dict,
|
|
||||||
) -> str:
|
|
||||||
'''
|
|
||||||
Block until `<marker>=<value>\\n` appears on `proc.stdout`
|
|
||||||
and return `<value>`. Uses a per-proc byte buffer (`_buf`)
|
|
||||||
to carry partial lines across calls.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = time.monotonic() + timeout
|
|
||||||
remainder: bytes = _buf.get('remainder', b'')
|
|
||||||
prefix: bytes = f'{marker}='.encode()
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
# drain any complete lines already buffered
|
|
||||||
while b'\n' in remainder:
|
|
||||||
line, remainder = remainder.split(b'\n', 1)
|
|
||||||
if line.startswith(prefix):
|
|
||||||
_buf['remainder'] = remainder
|
|
||||||
return line[len(prefix):].decode().strip()
|
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 0.2)
|
|
||||||
if not ready:
|
|
||||||
continue
|
|
||||||
chunk: bytes = os.read(proc.stdout.fileno(), 4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
remainder += chunk
|
|
||||||
_buf['remainder'] = remainder
|
|
||||||
raise TimeoutError(
|
|
||||||
f'Never observed marker {marker!r} on harness stdout '
|
|
||||||
f'within {timeout}s'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _process_alive(pid: int) -> bool:
|
|
||||||
'''Liveness probe for a pid we do NOT parent (post-orphan).'''
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
return True
|
|
||||||
except ProcessLookupError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Known-gap test — `main_thread_forkserver` orphan-SIGINT
|
|
||||||
# handling. See
|
|
||||||
# `ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md`.
|
|
||||||
# `strict=True` so if a future fix closes the gap the
|
|
||||||
# XPASS surfaces as a FAIL and forces us to drop the
|
|
||||||
# mark intentionally.
|
|
||||||
@pytest.mark.xfail(
|
|
||||||
strict=True,
|
|
||||||
reason=(
|
|
||||||
'Orphan subactor SIGINT delivery: trio event loop '
|
|
||||||
'on non-main thread post-fork doesn\'t see the '
|
|
||||||
'external SIGINT → KBI path. See tracker doc.\n'
|
|
||||||
'ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@pytest.mark.timeout(
|
|
||||||
30,
|
|
||||||
method='thread',
|
|
||||||
)
|
|
||||||
def test_orphaned_subactor_sigint_cleanup_DRAFT(
|
|
||||||
reg_addr: tuple[str, int | str],
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
DRAFT — orphaned-subactor SIGINT survivability under the
|
|
||||||
`main_thread_forkserver` backend.
|
|
||||||
|
|
||||||
Sequence:
|
|
||||||
1. Spawn a harness subprocess that brings up a root
|
|
||||||
actor + one `sleep_forever` subactor via
|
|
||||||
`main_thread_forkserver`.
|
|
||||||
2. Read the harness's stdout for `PARENT_READY=<pid>`
|
|
||||||
and `CHILD_PID=<pid>` markers (confirms the
|
|
||||||
parent→child IPC handshake completed).
|
|
||||||
3. `SIGKILL` the parent (no IPC cancel possible — the
|
|
||||||
whole point of this test).
|
|
||||||
4. `SIGINT` the orphan child.
|
|
||||||
5. Poll `os.kill(child_pid, 0)` for up to 10s — assert
|
|
||||||
the child exits.
|
|
||||||
|
|
||||||
Empirical result (2026-04, py3.14): currently **FAILS** —
|
|
||||||
SIGINT on the orphan child doesn't unwind the trio loop,
|
|
||||||
despite trio's `KIManager` handler being correctly
|
|
||||||
installed in the subactor (the post-fork thread IS
|
|
||||||
`threading.main_thread()` on py3.14). `faulthandler` dump
|
|
||||||
shows the subactor wedged in `trio/_core/_io_epoll.py::
|
|
||||||
get_events` — the signal's supposed wakeup of the event
|
|
||||||
loop isn't firing. Full analysis + diagnostic evidence
|
|
||||||
in `ai/conc-anal/
|
|
||||||
subint_forkserver_orphan_sigint_hang_issue.md`.
|
|
||||||
|
|
||||||
The runtime's *intentional* "KBI-as-OS-cancel" path at
|
|
||||||
`tractor/spawn/_entry.py::_trio_main:164` is therefore
|
|
||||||
unreachable under this backend+config. Closing the gap is
|
|
||||||
aligned with existing design intent (make the already-
|
|
||||||
designed behavior actually fire), not a new feature.
|
|
||||||
Marked `xfail(strict=True)` so the
|
|
||||||
mark flips to XPASS→fail once the gap is closed and we'll
|
|
||||||
know to drop the mark.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if platform.system() != 'Linux':
|
|
||||||
pytest.skip(
|
|
||||||
'orphan-reparenting semantics only exercised on Linux'
|
|
||||||
)
|
|
||||||
|
|
||||||
script_path = tmp_path / '_orphan_harness.py'
|
|
||||||
script_path.write_text(_ORPHAN_HARNESS_SCRIPT)
|
|
||||||
|
|
||||||
# Offset the port so we don't race the session reg_addr with
|
|
||||||
# any concurrently-running backend test's listener.
|
|
||||||
host: str = reg_addr[0]
|
|
||||||
port: int = int(reg_addr[1]) + 17
|
|
||||||
|
|
||||||
proc: subprocess.Popen = subprocess.Popen(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
str(script_path),
|
|
||||||
'main_thread_forkserver',
|
|
||||||
host,
|
|
||||||
str(port),
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
parent_pid: int | None = None
|
|
||||||
child_pid: int | None = None
|
|
||||||
buf: dict = {}
|
|
||||||
try:
|
|
||||||
child_pid = int(_read_marker(proc, 'CHILD_PID', 15.0, buf))
|
|
||||||
parent_pid = int(_read_marker(proc, 'PARENT_READY', 15.0, buf))
|
|
||||||
|
|
||||||
# sanity: both alive before we start killing stuff
|
|
||||||
assert _process_alive(parent_pid), (
|
|
||||||
f'harness parent pid={parent_pid} gone before '
|
|
||||||
f'SIGKILL — test premise broken'
|
|
||||||
)
|
|
||||||
assert _process_alive(child_pid), (
|
|
||||||
f'orphan-candidate child pid={child_pid} gone '
|
|
||||||
f'before test started'
|
|
||||||
)
|
|
||||||
|
|
||||||
# step 3: kill parent — no IPC cancel arrives at child.
|
|
||||||
# `proc.wait()` reaps the zombie so it truly disappears
|
|
||||||
# from the process table (otherwise `os.kill(pid, 0)`
|
|
||||||
# keeps reporting it as alive).
|
|
||||||
os.kill(parent_pid, signal.SIGKILL)
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=3.0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pytest.fail(
|
|
||||||
f'harness parent pid={parent_pid} did not die '
|
|
||||||
f'after SIGKILL — test premise broken'
|
|
||||||
)
|
|
||||||
assert _process_alive(child_pid), (
|
|
||||||
f'child pid={child_pid} died along with parent — '
|
|
||||||
f'did the parent reap it before SIGKILL took? '
|
|
||||||
f'test premise requires an orphan.'
|
|
||||||
)
|
|
||||||
|
|
||||||
# step 4+5: SIGINT the orphan, poll for exit.
|
|
||||||
os.kill(child_pid, signal.SIGINT)
|
|
||||||
timeout: float = 6.0
|
|
||||||
cleanup_deadline: float = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < cleanup_deadline:
|
|
||||||
if not _process_alive(child_pid):
|
|
||||||
return # <- success path
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
pytest.fail(
|
|
||||||
f'Orphan subactor (pid={child_pid}) did NOT exit '
|
|
||||||
f'within 10s of SIGINT under `main_thread_forkserver` '
|
|
||||||
f'→ trio on non-main thread did not observe the '
|
|
||||||
f'default CPython KeyboardInterrupt; backend needs '
|
|
||||||
f'explicit SIGINT plumbing.'
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# best-effort cleanup to avoid leaking orphans across
|
|
||||||
# the test session regardless of outcome.
|
|
||||||
for pid in (parent_pid, child_pid):
|
|
||||||
if pid is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=2.0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# regression guard: variant-2 (`subint_forkserver`) placeholder
|
|
||||||
# MUST raise `NotImplementedError` today — guards against future
|
|
||||||
# commits accidentally re-aliasing the key to the variant-1
|
|
||||||
# coroutine (which was a transient state during the rename).
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
def test_subint_forkserver_key_errors_cleanly() -> None:
|
|
||||||
'''
|
|
||||||
`--spawn-backend=subint_forkserver` is reserved for the
|
|
||||||
eventual variant-2 (subint-isolated child runtime)
|
|
||||||
backend, gated on jcrist/msgspec#1026 unblocking PEP 684
|
|
||||||
isolated-mode subints upstream.
|
|
||||||
|
|
||||||
Until that lands, the dispatch entry MUST raise
|
|
||||||
`NotImplementedError` immediately rather than silently
|
|
||||||
aliasing to `main_thread_forkserver_proc`. Verify the
|
|
||||||
error message also surfaces both the working-backend
|
|
||||||
pointer and the upstream-blocker ref so an operator
|
|
||||||
arriving at the error has somewhere to go.
|
|
||||||
|
|
||||||
'''
|
|
||||||
import asyncio
|
|
||||||
from tractor.spawn._spawn import _methods
|
|
||||||
|
|
||||||
proc = _methods['subint_forkserver']
|
|
||||||
with pytest.raises(NotImplementedError) as ei:
|
|
||||||
# signature args match `main_thread_forkserver_proc`'s
|
|
||||||
# — the stub raises before touching them so dummy
|
|
||||||
# values are fine.
|
|
||||||
asyncio.run(
|
|
||||||
proc(
|
|
||||||
'x', None, None, {}, [],
|
|
||||||
('127.0.0.1', 0), {},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
msg: str = str(ei.value)
|
|
||||||
assert 'main_thread_forkserver' in msg, (
|
|
||||||
f'stub error msg should redirect to the working '
|
|
||||||
f'variant-1 backend; got: {msg!r}'
|
|
||||||
)
|
|
||||||
assert 'msgspec#1026' in msg or '1026' in msg, (
|
|
||||||
f'stub error msg should reference the upstream '
|
|
||||||
f'blocker (jcrist/msgspec#1026); got: {msg!r}'
|
|
||||||
)
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
'''
|
|
||||||
Cancellation + hard-kill semantics audit for the `subint` spawn
|
|
||||||
backend.
|
|
||||||
|
|
||||||
Exercises the escape-hatch machinery added to
|
|
||||||
`tractor.spawn._subint` (module-level `_HARD_KILL_TIMEOUT`,
|
|
||||||
bounded shields around the soft-kill / thread-join sites, daemon
|
|
||||||
driver-thread abandonment) so that future stdlib regressions or
|
|
||||||
our own refactors don't silently re-introduce the hangs first
|
|
||||||
diagnosed during the Phase B.2/B.3 bringup (issue #379).
|
|
||||||
|
|
||||||
Every test in this module:
|
|
||||||
- is wrapped in `trio.fail_after()` for a deterministic per-test
|
|
||||||
wall-clock ceiling (the whole point of these tests is to fail
|
|
||||||
fast when our escape hatches regress; an unbounded test would
|
|
||||||
defeat itself),
|
|
||||||
- arms `tractor.devx.dump_on_hang()` to capture a stack dump on
|
|
||||||
failure — without it, a hang here is opaque because pytest's
|
|
||||||
stderr capture swallows `faulthandler` output by default
|
|
||||||
(hard-won lesson from the original diagnosis),
|
|
||||||
- skips on py<3.13 (no `_interpreters`) and on any
|
|
||||||
`--spawn-backend` other than `'subint'` (these tests are
|
|
||||||
subint-specific by design — they'd be nonsense under `trio` or
|
|
||||||
`mp_*`).
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import trio
|
|
||||||
import tractor
|
|
||||||
from tractor.devx import dump_on_hang
|
|
||||||
|
|
||||||
|
|
||||||
# Gate: the `subint` backend requires py3.14+. Check the
|
|
||||||
# public stdlib wrapper's presence (added in 3.14) rather than
|
|
||||||
# the private `_interpreters` module (which exists on 3.13 but
|
|
||||||
# wedges under tractor's usage — see `tractor.spawn._subint`).
|
|
||||||
pytest.importorskip('concurrent.interpreters')
|
|
||||||
|
|
||||||
# Subint-only: read the spawn method that `pytest_configure`
|
|
||||||
# committed via `try_set_start_method()`. By the time this module
|
|
||||||
# imports, the CLI backend choice has been applied.
|
|
||||||
from tractor.spawn._spawn import _spawn_method # noqa: E402
|
|
||||||
|
|
||||||
if _spawn_method != 'subint':
|
|
||||||
pytestmark = pytest.mark.skip(
|
|
||||||
reason=(
|
|
||||||
"subint-specific cancellation audit — "
|
|
||||||
"pass `--spawn-backend=subint` to run."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# child-side task bodies (run inside the spawned subint)
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _trivial_rpc() -> str:
|
|
||||||
'''
|
|
||||||
Minimal RPC body for the baseline happy-teardown test.
|
|
||||||
'''
|
|
||||||
return 'hello from subint'
|
|
||||||
|
|
||||||
|
|
||||||
async def _spin_without_trio_checkpoints() -> None:
|
|
||||||
'''
|
|
||||||
Block the main task with NO trio-visible checkpoints so any
|
|
||||||
`Portal.cancel_actor()` arriving over IPC has nothing to hand
|
|
||||||
off to.
|
|
||||||
|
|
||||||
`threading.Event.wait(timeout)` releases the GIL (so other
|
|
||||||
threads — including trio's IO/RPC tasks — can progress) but
|
|
||||||
does NOT insert a trio checkpoint, so the subactor's main
|
|
||||||
task never notices cancellation.
|
|
||||||
|
|
||||||
This is the exact "stuck subint" scenario the hard-kill
|
|
||||||
shields exist to survive.
|
|
||||||
'''
|
|
||||||
import threading
|
|
||||||
never_set = threading.Event()
|
|
||||||
while not never_set.is_set():
|
|
||||||
# 1s re-check granularity; low enough not to waste CPU,
|
|
||||||
# high enough that even a pathologically slow
|
|
||||||
# `_HARD_KILL_TIMEOUT` won't accidentally align with a
|
|
||||||
# wake.
|
|
||||||
never_set.wait(timeout=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# parent-side harnesses (driven inside `trio.run(...)`)
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _happy_path(
|
|
||||||
reg_addr: tuple[str, int|str],
|
|
||||||
deadline: float,
|
|
||||||
) -> None:
|
|
||||||
with trio.fail_after(deadline):
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(
|
|
||||||
registry_addrs=[reg_addr],
|
|
||||||
),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
portal: tractor.Portal = await an.run_in_actor(
|
|
||||||
_trivial_rpc,
|
|
||||||
name='subint-happy',
|
|
||||||
)
|
|
||||||
result: str = await portal.wait_for_result()
|
|
||||||
assert result == 'hello from subint'
|
|
||||||
|
|
||||||
|
|
||||||
async def _spawn_stuck_then_cancel(
|
|
||||||
reg_addr: tuple[str, int|str],
|
|
||||||
deadline: float,
|
|
||||||
) -> None:
|
|
||||||
with trio.fail_after(deadline):
|
|
||||||
async with (
|
|
||||||
tractor.open_root_actor(
|
|
||||||
registry_addrs=[reg_addr],
|
|
||||||
),
|
|
||||||
tractor.open_nursery() as an,
|
|
||||||
):
|
|
||||||
await an.run_in_actor(
|
|
||||||
_spin_without_trio_checkpoints,
|
|
||||||
name='subint-stuck',
|
|
||||||
)
|
|
||||||
# Give the child time to reach its non-checkpointing
|
|
||||||
# loop before we cancel; the precise value doesn't
|
|
||||||
# matter as long as it's a handful of trio schedule
|
|
||||||
# ticks.
|
|
||||||
await trio.sleep(0.5)
|
|
||||||
an.cancel_scope.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
# tests
|
|
||||||
# ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_subint_happy_teardown(
|
|
||||||
reg_addr: tuple[str, int|str],
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Baseline: spawn a subactor, do one portal RPC, close nursery
|
|
||||||
cleanly. No cancel, no faults.
|
|
||||||
|
|
||||||
If this regresses we know something's wrong at the
|
|
||||||
spawn/teardown layer unrelated to the hard-kill escape
|
|
||||||
hatches.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = 10.0
|
|
||||||
with dump_on_hang(
|
|
||||||
seconds=deadline,
|
|
||||||
path='/tmp/subint_cancellation_happy.dump',
|
|
||||||
):
|
|
||||||
trio.run(partial(_happy_path, reg_addr, deadline))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipon_spawn_backend(
|
|
||||||
'subint',
|
|
||||||
reason=(
|
|
||||||
'XXX SUBINT HANGING TEST XXX\n'
|
|
||||||
'See oustanding issue(s)\n'
|
|
||||||
# TODO, put issue link!
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Wall-clock bound via `pytest-timeout` (`method='thread'`)
|
|
||||||
# as defense-in-depth over the inner `trio.fail_after(15)`.
|
|
||||||
# Under the orphaned-channel hang class described in
|
|
||||||
# `ai/conc-anal/subint_cancel_delivery_hang_issue.md`, SIGINT
|
|
||||||
# is still deliverable and this test *should* be unwedgeable
|
|
||||||
# by the inner trio timeout — but sibling subint-backend
|
|
||||||
# tests in this repo have also exhibited the
|
|
||||||
# `subint_sigint_starvation_issue.md` GIL-starvation flavor,
|
|
||||||
# so `method='thread'` keeps us safe in case ordering or
|
|
||||||
# load shifts the failure mode.
|
|
||||||
# @pytest.mark.timeout(
|
|
||||||
# 3, # NOTE never passes pre-3.14+ subints support.
|
|
||||||
# method='thread',
|
|
||||||
# )
|
|
||||||
def test_subint_non_checkpointing_child(
|
|
||||||
reg_addr: tuple[str, int|str],
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Cancel a subactor whose main task is stuck in a non-
|
|
||||||
checkpointing Python loop.
|
|
||||||
|
|
||||||
`Portal.cancel_actor()` may be delivered over IPC but the
|
|
||||||
main task never checkpoints to observe the Cancelled —
|
|
||||||
so the subint's `trio.run()` can't exit gracefully.
|
|
||||||
|
|
||||||
The parent `subint_proc` bounded-shield + daemon-driver-
|
|
||||||
thread combo should abandon the thread after
|
|
||||||
`_HARD_KILL_TIMEOUT` and let the parent return cleanly.
|
|
||||||
|
|
||||||
Wall-clock budget:
|
|
||||||
- ~0.5s: settle time for child to enter the stuck loop
|
|
||||||
- ~3s: `_HARD_KILL_TIMEOUT` (soft-kill wait)
|
|
||||||
- ~3s: `_HARD_KILL_TIMEOUT` (thread-join wait)
|
|
||||||
- margin
|
|
||||||
|
|
||||||
KNOWN ISSUE (Ctrl-C-able hang):
|
|
||||||
-------------------------------
|
|
||||||
This test currently hangs past the hard-kill timeout for
|
|
||||||
reasons unrelated to the subint teardown itself — after
|
|
||||||
the subint is destroyed, a parent-side trio task appears
|
|
||||||
to park on an orphaned IPC channel (no clean EOF
|
|
||||||
delivered to a waiting receive). Unlike the
|
|
||||||
SIGINT-starvation sibling case in
|
|
||||||
`test_stale_entry_is_deleted`, this hang IS Ctrl-C-able
|
|
||||||
(`strace` shows SIGINT wakeup-fd `write() = 1`, not
|
|
||||||
`EAGAIN`) — i.e. the main trio loop is still iterating
|
|
||||||
normally. That makes this *our* bug to fix, not a
|
|
||||||
CPython-level limitation.
|
|
||||||
|
|
||||||
See `ai/conc-anal/subint_cancel_delivery_hang_issue.md`
|
|
||||||
for the full analysis + candidate fix directions
|
|
||||||
(explicit parent-side channel abort in `subint_proc`
|
|
||||||
teardown being the most likely surgical fix).
|
|
||||||
|
|
||||||
The sibling `ai/conc-anal/subint_sigint_starvation_issue.md`
|
|
||||||
documents the *other* hang class (abandoned-legacy-subint
|
|
||||||
thread + shared-GIL starvation → signal-wakeup-fd pipe
|
|
||||||
fills → SIGINT silently dropped) — that one is
|
|
||||||
structurally blocked on msgspec PEP 684 adoption and is
|
|
||||||
NOT what this test is hitting.
|
|
||||||
|
|
||||||
'''
|
|
||||||
deadline: float = 15.0
|
|
||||||
with dump_on_hang(
|
|
||||||
seconds=deadline,
|
|
||||||
path='/tmp/subint_cancellation_stuck.dump',
|
|
||||||
):
|
|
||||||
trio.run(
|
|
||||||
partial(
|
|
||||||
_spawn_stuck_then_cancel,
|
|
||||||
reg_addr,
|
|
||||||
deadline,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -515,14 +515,18 @@ async def spawn_and_error(
|
||||||
ids='depth={}'.format,
|
ids='depth={}'.format,
|
||||||
)
|
)
|
||||||
@tractor_test(
|
@tractor_test(
|
||||||
# bumped from the 30s default to cover fork-based
|
# XXX this OUTER `trio.fail_after` wall MUST exceed the
|
||||||
# cancel-cascade flakes; 2 spawners × 2 errorers × depth 1+
|
# largest INNER `fail_after_w_trace()` budget set in the body
|
||||||
# cascade through 6 portal-wait_for_result paths each
|
# below (max = the MTF depth=3 == 30s case, further scaled by
|
||||||
# paying `terminate_after=1.6s` + UDS sock-unlink under
|
# `cpu_scaling_factor()` on CI/throttle). Otherwise it fires
|
||||||
# MTF/UDS contention can easily blow past 30s.
|
# FIRST and pre-empts the inner snapshot-capturing deadline,
|
||||||
|
# turning a graceful `TooSlowError`+ptree-dump into an opaque
|
||||||
|
# outer timeout-kill (the prior `timeout=10` did exactly this
|
||||||
|
# — it was *smaller* than the 12s trio depth=3 budget, so the
|
||||||
|
# depth-3 case `FAILED` on slow CI instead of dumping).
|
||||||
# Trio backend is fast and won't notice the extra budget.
|
# Trio backend is fast and won't notice the extra budget.
|
||||||
# See `ai/conc-anal/cancel_cascade_too_slow_under_main_thread_forkserver_issue.md`.
|
# See `ai/conc-anal/cancel_cascade_too_slow_under_main_thread_forkserver_issue.md`.
|
||||||
timeout=10,
|
timeout=40,
|
||||||
)
|
)
|
||||||
async def test_nested_multierrors(
|
async def test_nested_multierrors(
|
||||||
reg_addr: tuple,
|
reg_addr: tuple,
|
||||||
|
|
@ -671,6 +675,10 @@ async def test_nested_multierrors(
|
||||||
|
|
||||||
# inflate the budget by the throttle headroom probed above so
|
# inflate the budget by the throttle headroom probed above so
|
||||||
# a slow box doesn't masquerade as a deadline regression.
|
# a slow box doesn't masquerade as a deadline regression.
|
||||||
|
# NOTE, `headroom = cpu_perf_headroom()` (set above) is the
|
||||||
|
# SUPERSET of `cpu_scaling_factor()` — it folds in the static
|
||||||
|
# cpu-freq scaling + slow-CI bump AND the sustained-load
|
||||||
|
# throttle probe this depth-3 cascade was the poster child for.
|
||||||
if headroom != 1.:
|
if headroom != 1.:
|
||||||
timeout *= headroom
|
timeout *= headroom
|
||||||
|
|
||||||
|
|
@ -780,7 +788,7 @@ def test_cancel_via_SIGINT_other_task(
|
||||||
started from a seperate ``trio`` child task.
|
started from a seperate ``trio`` child task.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from .conftest import cpu_scaling_factor
|
from .conftest import cpu_perf_headroom
|
||||||
|
|
||||||
pid: int = os.getpid()
|
pid: int = os.getpid()
|
||||||
timeout: float = (
|
timeout: float = (
|
||||||
|
|
@ -790,8 +798,9 @@ def test_cancel_via_SIGINT_other_task(
|
||||||
if _friggin_windows: # smh
|
if _friggin_windows: # smh
|
||||||
timeout += 1
|
timeout += 1
|
||||||
|
|
||||||
# add latency headroom for CPU freq scaling (auto-cpufreq et al.)
|
# latency headroom for static cpu-freq scaling + sustained-load
|
||||||
headroom: float = cpu_scaling_factor()
|
# throttle + CI (auto-cpufreq et al.); see `cpu_perf_headroom()`.
|
||||||
|
headroom: float = cpu_perf_headroom()
|
||||||
if headroom != 1.:
|
if headroom != 1.:
|
||||||
timeout *= headroom
|
timeout *= headroom
|
||||||
|
|
||||||
|
|
@ -987,6 +996,12 @@ def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
|
||||||
if _friggin_windows: # smh
|
if _friggin_windows: # smh
|
||||||
timeout += 1
|
timeout += 1
|
||||||
|
|
||||||
|
# CPU-scaling / sustained-throttle / CI latency headroom — macOS
|
||||||
|
# CI especially is slow for this graceful-vs-hard-reap timing
|
||||||
|
# race; see `cpu_perf_headroom()`.
|
||||||
|
from .conftest import cpu_perf_headroom
|
||||||
|
timeout *= cpu_perf_headroom()
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
start = time.time()
|
start = time.time()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,12 @@ def time_quad_ex(
|
||||||
):
|
):
|
||||||
timeout += 1
|
timeout += 1
|
||||||
|
|
||||||
|
# inflate the cancel-deadline for static cpu-freq scaling +
|
||||||
|
# sustained-load throttle + CI latency (see `cpu_perf_headroom()`)
|
||||||
|
# so the example isn't cancelled mid-stream on a throttled/CI box.
|
||||||
|
from .conftest import cpu_perf_headroom
|
||||||
|
timeout *= cpu_perf_headroom()
|
||||||
|
|
||||||
start: float = time.time()
|
start: float = time.time()
|
||||||
results: list[int] = trio.run(partial(
|
results: list[int] = trio.run(partial(
|
||||||
cancel_after,
|
cancel_after,
|
||||||
|
|
@ -373,8 +379,8 @@ def test_a_quadruple_example(
|
||||||
# https://github.com/AdnanHodzic/auto-cpufreq?tab=readme-ov-file#example-config-file-contents
|
# https://github.com/AdnanHodzic/auto-cpufreq?tab=readme-ov-file#example-config-file-contents
|
||||||
#
|
#
|
||||||
# HENCE this below latency-headroom compensation logic..
|
# HENCE this below latency-headroom compensation logic..
|
||||||
from .conftest import cpu_scaling_factor
|
from .conftest import cpu_perf_headroom
|
||||||
headroom: float = cpu_scaling_factor()
|
headroom: float = cpu_perf_headroom()
|
||||||
if headroom != 1.:
|
if headroom != 1.:
|
||||||
this_fast = this_fast_on_linux * headroom
|
this_fast = this_fast_on_linux * headroom
|
||||||
test_log.warning(
|
test_log.warning(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import pytest
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
# XXX `cffi` dun build on py3.14 yet..
|
# XXX `cffi` dun build on py3.14 yet..
|
||||||
cffi = pytest.importorskip("cffi")
|
pytest.importorskip("cffi")
|
||||||
|
|
||||||
from tractor.ipc._ringbuf import (
|
from tractor.ipc._ringbuf import (
|
||||||
open_ringbuf,
|
open_ringbuf,
|
||||||
|
|
@ -21,6 +21,9 @@ from tractor._testing.samples import (
|
||||||
# XXX, in case you want to melt your cores, comment this skip line XD
|
# XXX, in case you want to melt your cores, comment this skip line XD
|
||||||
pytestmark = pytest.mark.skip
|
pytestmark = pytest.mark.skip
|
||||||
|
|
||||||
|
# XXX `cffi` dun build on py3.14 yet..
|
||||||
|
cffi = pytest.importorskip("cffi")
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def child_read_shm(
|
async def child_read_shm(
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ def test_wakeup_socketpair_drain_eof_patch_works():
|
||||||
# First call MUST return True; idempotent guard
|
# First call MUST return True; idempotent guard
|
||||||
# prevents False on subsequent calls within the
|
# prevents False on subsequent calls within the
|
||||||
# same process.
|
# same process.
|
||||||
assert applied is True or applied is False # idempotent
|
assert isinstance(applied, bool) # idempotent (order-dependent value)
|
||||||
|
|
||||||
# Cap wall-clock at 2s; SIGALRM raises in main
|
# Cap wall-clock at 2s; SIGALRM raises in main
|
||||||
# thread which interrupts the C-level recv loop
|
# thread which interrupts the C-level recv loop
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,10 @@ async def open_root_actor(
|
||||||
# console verbosity without touching application code.
|
# console verbosity without touching application code.
|
||||||
env_ll_report: str = ''
|
env_ll_report: str = ''
|
||||||
if env_ll := os.environ.get('TRACTOR_LOGLEVEL'):
|
if env_ll := os.environ.get('TRACTOR_LOGLEVEL'):
|
||||||
|
# capture the caller-passed value BEFORE the env-var
|
||||||
|
# clobbers it, else the override-notice below is dead
|
||||||
|
# code (the `!=` compare is always `False`).
|
||||||
|
caller_ll: str|None = loglevel
|
||||||
loglevel = env_ll
|
loglevel = env_ll
|
||||||
env_ll_report: str = (
|
env_ll_report: str = (
|
||||||
f'Detected env-var setting,\n'
|
f'Detected env-var setting,\n'
|
||||||
|
|
@ -299,14 +303,14 @@ async def open_root_actor(
|
||||||
f'loglevel={loglevel!r}\n'
|
f'loglevel={loglevel!r}\n'
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
loglevel
|
caller_ll
|
||||||
and
|
and
|
||||||
loglevel.upper() != env_ll.upper()
|
caller_ll.upper() != env_ll.upper()
|
||||||
):
|
):
|
||||||
env_ll_report += (
|
env_ll_report += (
|
||||||
f'\n'
|
f'\n'
|
||||||
f'NOTE env-var OVERRIDES caller-passed,\n'
|
f'NOTE env-var OVERRIDES caller-passed,\n'
|
||||||
f'loglevel={loglevel!r}\n'
|
f'loglevel={caller_ll!r}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
loglevel: str = (
|
loglevel: str = (
|
||||||
|
|
@ -332,6 +336,9 @@ async def open_root_actor(
|
||||||
# the `examples/debugging/<script>.py` suite under each
|
# the `examples/debugging/<script>.py` suite under each
|
||||||
# backend from `tests/devx/conftest.py::spawn`).
|
# backend from `tests/devx/conftest.py::spawn`).
|
||||||
if env_sm := os.environ.get('TRACTOR_SPAWN_METHOD'):
|
if env_sm := os.environ.get('TRACTOR_SPAWN_METHOD'):
|
||||||
|
# capture the caller-passed value BEFORE the env-var
|
||||||
|
# clobbers it (else the override-notice is dead code).
|
||||||
|
caller_sm: str|None = start_method
|
||||||
start_method: str = env_sm
|
start_method: str = env_sm
|
||||||
env_sm_report: str = (
|
env_sm_report: str = (
|
||||||
f'Detected env-var setting,\n'
|
f'Detected env-var setting,\n'
|
||||||
|
|
@ -341,15 +348,15 @@ async def open_root_actor(
|
||||||
f'start_method={env_sm!r}\n'
|
f'start_method={env_sm!r}\n'
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
start_method
|
caller_sm
|
||||||
and
|
and
|
||||||
start_method != env_sm
|
caller_sm != env_sm
|
||||||
):
|
):
|
||||||
_log.warning(
|
_log.warning(
|
||||||
env_sm_report
|
env_sm_report
|
||||||
+
|
+
|
||||||
f'NOTE env-var OVERRIDES caller-passed,\n'
|
f'NOTE env-var OVERRIDES caller-passed,\n'
|
||||||
f'`start_method={start_method!r}`\n'
|
f'`start_method={caller_sm!r}`\n'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_log.info(env_sm_report)
|
_log.info(env_sm_report)
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ from .trace import ( # noqa: F401
|
||||||
fail_after_w_trace_fixture,
|
fail_after_w_trace_fixture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Spawn-backend keys which may appear in `skipon_spawn_backend`
|
||||||
|
# marks ahead of the named backend actually being registered in
|
||||||
|
# `tractor.spawn._spawn.SpawnMethodKey`; such marks are inert
|
||||||
|
# (they can never match an active backend) but must not break
|
||||||
|
# collection.
|
||||||
|
_IN_DEV_SPAWN_BACKENDS: tuple[str, ...] = (
|
||||||
|
'subint',
|
||||||
|
'subint_forkserver',
|
||||||
|
'main_thread_forkserver',
|
||||||
|
)
|
||||||
|
|
||||||
# Sub-plugin: zombie-subactor + UDS sock-file + shm
|
# Sub-plugin: zombie-subactor + UDS sock-file + shm
|
||||||
# reaping fixtures live in `tractor._testing._reap`
|
# reaping fixtures live in `tractor._testing._reap`
|
||||||
# alongside the underlying detection/cleanup helpers.
|
# alongside the underlying detection/cleanup helpers.
|
||||||
|
|
@ -538,7 +549,11 @@ def pytest_collection_modifyitems(
|
||||||
for mark in item.iter_markers(name='skipon_spawn_backend'):
|
for mark in item.iter_markers(name='skipon_spawn_backend'):
|
||||||
skip_backends: tuple[str] = mark.args
|
skip_backends: tuple[str] = mark.args
|
||||||
for skip_backend in skip_backends:
|
for skip_backend in skip_backends:
|
||||||
assert skip_backend in get_args(SpawnMethodKey)
|
assert (
|
||||||
|
skip_backend in get_args(SpawnMethodKey)
|
||||||
|
or
|
||||||
|
skip_backend in _IN_DEV_SPAWN_BACKENDS
|
||||||
|
)
|
||||||
# ?TODO, run these through the try-set-backend checker to
|
# ?TODO, run these through the try-set-backend checker to
|
||||||
# avoid typos?
|
# avoid typos?
|
||||||
if backend in skip_backends:
|
if backend in skip_backends:
|
||||||
|
|
@ -571,6 +586,32 @@ def alert_on_finish():
|
||||||
# - sway/i3-nag?
|
# - sway/i3-nag?
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_runtime_vars():
|
||||||
|
'''
|
||||||
|
Per-test isolation of the process-global
|
||||||
|
`tractor.runtime._state._runtime_vars`.
|
||||||
|
|
||||||
|
`open_root_actor()` writes `_enable_tpts` (and other runtime
|
||||||
|
vars) into this module-global dict, but nothing resets it on
|
||||||
|
actor teardown. Under the in-process `pytest` launchpad a
|
||||||
|
uds-using test therefore leaks `_enable_tpts=['uds']` into a
|
||||||
|
sibling tcp test, which then trips the
|
||||||
|
`registry_addrs`×`enable_transports` proto-guard in
|
||||||
|
`open_root_actor()` with a `ValueError`. Snapshot + restore
|
||||||
|
around every test so no runtime-var state crosses a test
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from tractor.runtime import _state
|
||||||
|
snapshot: dict = dict(_state._runtime_vars)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_state._runtime_vars.clear()
|
||||||
|
_state._runtime_vars.update(snapshot)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def debug_mode(
|
def debug_mode(
|
||||||
request: pytest.FixtureRequest,
|
request: pytest.FixtureRequest,
|
||||||
|
|
|
||||||
|
|
@ -71,17 +71,18 @@ def dump_task_tree(
|
||||||
Do a classic `stackscope.extract()` task-tree dump to console at
|
Do a classic `stackscope.extract()` task-tree dump to console at
|
||||||
`.devx()` level.
|
`.devx()` level.
|
||||||
|
|
||||||
Also unconditionally tee the rendered tree to two
|
When `write_file`/`write_tty` are set, ALSO tee the rendered
|
||||||
capture-bypassing sinks so SIGUSR1 dumps remain visible
|
tree to capture-bypassing sinks so SIGUSR1 dumps remain
|
||||||
when the parent process has captured stdio (e.g. pytest's
|
visible when the parent process has captured stdio (e.g.
|
||||||
default `--capture=fd`):
|
pytest's default `--capture=fd`); the SIGUSR1 handler passes
|
||||||
|
`write_file=True` for exactly this reason:
|
||||||
|
|
||||||
- `/tmp/tractor-stackscope-<pid>.log` (append-mode, always
|
- `write_file` -> `/tmp/tractor-stackscope-<pid>.log`
|
||||||
written) — guaranteed-readable artifact even under CI
|
(append-mode) — guaranteed-readable artifact even under CI
|
||||||
/ `nohup` / no-tty conditions. `tail -f` to follow.
|
/ `nohup` / no-tty conditions. `tail -f` to follow.
|
||||||
- `/dev/tty` if a controlling terminal is attached —
|
- `write_tty` -> `/dev/tty` if a controlling terminal is
|
||||||
best-effort, ignored if the device is missing or write
|
attached — best-effort, ignored if the device is missing
|
||||||
fails. pytest never captures the tty.
|
or write fails. pytest never captures the tty.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import os
|
import os
|
||||||
|
|
@ -248,7 +249,7 @@ def _dump_then_relay(
|
||||||
|
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
dump_task_tree()
|
dump_task_tree(write_file=True)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
log.exception(
|
log.exception(
|
||||||
'`dump_task_tree()` raised (scheduled via '
|
'`dump_task_tree()` raised (scheduled via '
|
||||||
|
|
@ -314,7 +315,7 @@ def dump_tree_on_sig(
|
||||||
return
|
return
|
||||||
|
|
||||||
else:
|
else:
|
||||||
dump_task_tree()
|
dump_task_tree(write_file=True)
|
||||||
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
log.exception(
|
log.exception(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import sys
|
||||||
try:
|
try:
|
||||||
import cffi
|
import cffi
|
||||||
except ImportError as ie:
|
except ImportError as ie:
|
||||||
if sys.version_info < (3, 14):
|
if sys.version_info >= (3, 14):
|
||||||
ie.add_note(
|
ie.add_note(
|
||||||
f'The `cffi` pkg has no 3.14 support yet.\n'
|
f'The `cffi` pkg has no 3.14 support yet.\n'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -334,16 +334,24 @@ class Portal:
|
||||||
# `move_on_after` fired — peer didn't ack within
|
# `move_on_after` fired — peer didn't ack within
|
||||||
# bounded window. Behaviour depends on
|
# bounded window. Behaviour depends on
|
||||||
# `raise_on_timeout`:
|
# `raise_on_timeout`:
|
||||||
assert cs.cancelled_caught
|
if (
|
||||||
if raise_on_timeout:
|
cs.cancelled_caught
|
||||||
|
and
|
||||||
|
raise_on_timeout
|
||||||
|
):
|
||||||
raise ActorTooSlowError(
|
raise ActorTooSlowError(
|
||||||
f'Peer {peer_id} did not ack `Actor.cancel()`'
|
f'Peer {peer_id} did not ack its '
|
||||||
f'-RPC within bounded wait of '
|
f'`Actor.cancel()` RPC within bounded wait '
|
||||||
f'{cancel_timeout!r}s'
|
f'of {cancel_timeout!r}s'
|
||||||
)
|
)
|
||||||
|
|
||||||
# legacy fire-and-forget path: log + return False so
|
# legacy fire-and-forget path: log + return False so
|
||||||
# the caller can decide whether to escalate.
|
# the caller can decide whether to escalate.
|
||||||
|
#
|
||||||
|
# NOTE, we also land here in the (unexpected) case where
|
||||||
|
# the shielded `move_on_after` block exits WITHOUT
|
||||||
|
# `return True` and WITHOUT the deadline firing — prefer
|
||||||
|
# a soft `False` over an `assert`-crash mid-teardown.
|
||||||
log.debug(
|
log.debug(
|
||||||
f'May have failed to cancel peer?\n'
|
f'May have failed to cancel peer?\n'
|
||||||
f'\n'
|
f'\n'
|
||||||
|
|
|
||||||
|
|
@ -873,12 +873,9 @@ class Actor:
|
||||||
if self._spawn_method in (
|
if self._spawn_method in (
|
||||||
'trio',
|
'trio',
|
||||||
'subint',
|
'subint',
|
||||||
# `main_thread_forkserver` (and the future
|
# `subint_forkserver` parent-side sends a
|
||||||
# variant-2 `subint_forkserver`) parent-side
|
# `SpawnSpec` over IPC just like the other two
|
||||||
# sends a `SpawnSpec` over IPC just like the
|
# — fork child-side runtime is trio-native.
|
||||||
# other two — fork child-side runtime is
|
|
||||||
# trio-native.
|
|
||||||
'main_thread_forkserver',
|
|
||||||
'subint_forkserver',
|
'subint_forkserver',
|
||||||
):
|
):
|
||||||
|
|
||||||
|
|
@ -933,20 +930,23 @@ class Actor:
|
||||||
# TODO! -[ ] another `Struct` for rtvs..
|
# TODO! -[ ] another `Struct` for rtvs..
|
||||||
rvs: dict[str, Any] = spawnspec._runtime_vars
|
rvs: dict[str, Any] = spawnspec._runtime_vars
|
||||||
|
|
||||||
# `stackscope` SIGUSR1 handler: install when EITHER
|
# `stackscope` SIGUSR1 handler: install when ANY of
|
||||||
# `use_stackscope` is set in rt-vars OR the
|
# `_debug_mode` / `use_stackscope` rt-vars OR the
|
||||||
# `TRACTOR_ENABLE_STACKSCOPE` env var is set (lighter
|
# `TRACTOR_ENABLE_STACKSCOPE` env var is set (the
|
||||||
# test-time hang-debug path; see
|
# latter being a lighter test-time hang-debug path;
|
||||||
# `tractor._testing.pytest`'s `--enable-stackscope`
|
# see `tractor._testing.pytest`'s `--enable-stackscope`
|
||||||
# CLI flag — env var propagates via fork-inherited
|
# CLI flag — env var propagates via fork-inherited
|
||||||
# environ).
|
# environ).
|
||||||
#
|
#
|
||||||
# NOTE, intentionally NOT gated on `_debug_mode` so
|
# NOTE, NOT *exclusively* gated on `_debug_mode` so
|
||||||
# SIGUSR1 task-tree dumps work in plain (non-pdb)
|
# SIGUSR1 task-tree dumps work in plain (non-pdb)
|
||||||
# runs too — esp. in infected-`asyncio` sub-actors
|
# runs too — but we DO still install under
|
||||||
# where the default SIGUSR1 action would otherwise
|
# `_debug_mode` since otherwise the default SIGUSR1
|
||||||
# terminate the proc.
|
# action would terminate the proc, esp. nasty in
|
||||||
|
# infected-`asyncio` sub-actors mid-REPL.
|
||||||
if (
|
if (
|
||||||
|
rvs.get('_debug_mode')
|
||||||
|
or
|
||||||
rvs.get('use_stackscope')
|
rvs.get('use_stackscope')
|
||||||
or
|
or
|
||||||
os.environ.get('TRACTOR_ENABLE_STACKSCOPE')
|
os.environ.get('TRACTOR_ENABLE_STACKSCOPE')
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ class RuntimeVars(Struct):
|
||||||
key,
|
key,
|
||||||
val,
|
val,
|
||||||
) -> None:
|
) -> None:
|
||||||
breakpoint()
|
|
||||||
super().__setattr__(key, val)
|
super().__setattr__(key, val)
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,20 @@ async def _try_cancel_then_kill(
|
||||||
f' reason: {too_slow}\n'
|
f' reason: {too_slow}\n'
|
||||||
f'-> escalating to `proc.terminate()` (hard-kill)\n'
|
f'-> escalating to `proc.terminate()` (hard-kill)\n'
|
||||||
)
|
)
|
||||||
|
# XXX, the `subint` backend stores an `int` interp-id in the
|
||||||
|
# `proc` slot (not a `Process`), so it has no `.terminate()`.
|
||||||
|
# Guard here so a cancel-ack timeout doesn't `AttributeError`
|
||||||
|
# once that backend lands; its hard-kill path is a TODO.
|
||||||
|
if hasattr(proc, 'terminate'):
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
f'Cannot hard-kill sub-actor — backend proc-handle '
|
||||||
|
f'{proc!r} ({type(proc).__name__!r}) has no '
|
||||||
|
f'`.terminate()`!\n'
|
||||||
|
f' uid: {subactor.aid.reprol()!r}\n'
|
||||||
|
f'TODO: per-backend cancel-escalation.\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ActorNursery:
|
class ActorNursery:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,7 +22,6 @@ over multiple backends.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import platform
|
import platform
|
||||||
import sys
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
|
|
@ -43,7 +42,6 @@ from tractor.log import get_logger
|
||||||
from tractor.discovery._addr import (
|
from tractor.discovery._addr import (
|
||||||
UnwrappedAddress,
|
UnwrappedAddress,
|
||||||
)
|
)
|
||||||
from .._exceptions import ActorFailure
|
|
||||||
from ._reap import unlink_uds_bind_addrs
|
from ._reap import unlink_uds_bind_addrs
|
||||||
from tractor.runtime._portal import Portal
|
from tractor.runtime._portal import Portal
|
||||||
from tractor.runtime._runtime import Actor
|
from tractor.runtime._runtime import Actor
|
||||||
|
|
@ -66,34 +64,6 @@ SpawnMethodKey = Literal[
|
||||||
'trio', # supported on all platforms
|
'trio', # supported on all platforms
|
||||||
'mp_spawn',
|
'mp_spawn',
|
||||||
'mp_forkserver', # posix only
|
'mp_forkserver', # posix only
|
||||||
'subint', # py3.14+ via `concurrent.interpreters` (PEP 734)
|
|
||||||
# EXPERIMENTAL — blocked at the CPython level. The
|
|
||||||
# design goal was a `trio+fork`-safe subproc spawn via
|
|
||||||
# `os.fork()` from a trio-free launchpad sub-interpreter,
|
|
||||||
# but CPython's `PyOS_AfterFork_Child` → `_PyInterpreterState_DeleteExceptMain`
|
|
||||||
# requires fork come from the main interp. See
|
|
||||||
# `tractor.spawn._subint_fork` +
|
|
||||||
# `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
|
||||||
# + issue #379 for the full analysis.
|
|
||||||
'subint_fork',
|
|
||||||
# EXPERIMENTAL — the `subint_fork` workaround. `os.fork()`
|
|
||||||
# from a non-trio worker thread (never entered a subint)
|
|
||||||
# is CPython-legal and works cleanly; forked child runs
|
|
||||||
# `tractor._child._actor_child_main()` against a trio
|
|
||||||
# runtime, exactly like `trio_proc` but via fork instead
|
|
||||||
# of subproc-exec. See `tractor.spawn._main_thread_forkserver`.
|
|
||||||
'main_thread_forkserver',
|
|
||||||
# Variant-2: same fork machinery as `main_thread_forkserver`
|
|
||||||
# but the child enters a sub-interpreter to host its
|
|
||||||
# `trio.run()`. Gated on jcrist/msgspec#1026 unblocking
|
|
||||||
# PEP 684 isolated-mode subints upstream — until then
|
|
||||||
# `subint_forkserver_proc` is a clean `NotImplementedError`
|
|
||||||
# stub pointing at variant-1 (`main_thread_forkserver`) +
|
|
||||||
# the upstream blocker. The key is reserved here (not just
|
|
||||||
# aliased to variant-1) so once upstream lands the impl can
|
|
||||||
# flip in-place without API churn. See
|
|
||||||
# `tractor.spawn._subint_forkserver`.
|
|
||||||
'subint_forkserver',
|
|
||||||
]
|
]
|
||||||
_spawn_method: SpawnMethodKey = 'trio'
|
_spawn_method: SpawnMethodKey = 'trio'
|
||||||
|
|
||||||
|
|
@ -111,71 +81,6 @@ else:
|
||||||
await trio.lowlevel.wait_readable(proc.sentinel)
|
await trio.lowlevel.wait_readable(proc.sentinel)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_peer_or_proc_death(
|
|
||||||
ipc_server,
|
|
||||||
uid: tuple[str, str],
|
|
||||||
# TODO? not not types?
|
|
||||||
proc_wait: 'Callable[[], Awaitable]',
|
|
||||||
proc_repr: str = '',
|
|
||||||
|
|
||||||
) -> 'tuple[trio.Event, Channel]':
|
|
||||||
'''
|
|
||||||
Race `IPCServer.wait_for_peer(uid)` against the sub-proc's
|
|
||||||
own `.wait()` coroutine. Whichever completes first cancels
|
|
||||||
the other.
|
|
||||||
|
|
||||||
Used by every spawn-backend to detect a sub-actor that
|
|
||||||
*dies during boot* before completing the parent-handshake-
|
|
||||||
callback (e.g. crashed on import, exec'd-out, kernel-killed
|
|
||||||
pre-`_actor_child_main`). Without this race, the
|
|
||||||
handshake-wait — backed by an unsignalled `trio.Event` —
|
|
||||||
parks the spawning task forever and leaves the dead child
|
|
||||||
as a zombie since nobody calls `proc.wait()` to reap.
|
|
||||||
|
|
||||||
On normal handshake-complete: returns `(event, chan)`
|
|
||||||
identical to a bare `wait_for_peer`.
|
|
||||||
|
|
||||||
On proc-death-first: raises `ActorFailure` carrying the
|
|
||||||
proc's exit code, allowing the supervisor to surface a
|
|
||||||
clean error rather than hanging indefinitely.
|
|
||||||
|
|
||||||
`proc_wait` is a 0-arg async callable returning the proc's
|
|
||||||
exit-status — kept generic so each backend can pass its
|
|
||||||
own (`trio.Process.wait`, `_ForkedProc.wait`,
|
|
||||||
`proc_waiter(mp.Process)`, etc.).
|
|
||||||
|
|
||||||
`proc_repr` is an optional string used in the
|
|
||||||
`ActorFailure` message for diag.
|
|
||||||
|
|
||||||
'''
|
|
||||||
result: dict = {}
|
|
||||||
|
|
||||||
async def _await_handshake():
|
|
||||||
event, chan = await ipc_server.wait_for_peer(uid)
|
|
||||||
result['handshake'] = (event, chan)
|
|
||||||
boot_n.cancel_scope.cancel()
|
|
||||||
|
|
||||||
async def _await_death():
|
|
||||||
rc = await proc_wait()
|
|
||||||
result['died'] = rc
|
|
||||||
boot_n.cancel_scope.cancel()
|
|
||||||
|
|
||||||
async with trio.open_nursery() as boot_n:
|
|
||||||
boot_n.start_soon(_await_handshake)
|
|
||||||
boot_n.start_soon(_await_death)
|
|
||||||
|
|
||||||
if 'handshake' in result:
|
|
||||||
return result['handshake']
|
|
||||||
|
|
||||||
# only reached if proc-death won the race
|
|
||||||
raise ActorFailure(
|
|
||||||
f'Sub-actor {uid!r} died during boot '
|
|
||||||
f'(rc={result.get("died")!r}) before completing '
|
|
||||||
f'parent-handshake.\n'
|
|
||||||
f' proc: {proc_repr}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def try_set_start_method(
|
def try_set_start_method(
|
||||||
key: SpawnMethodKey
|
key: SpawnMethodKey
|
||||||
|
|
||||||
|
|
@ -208,38 +113,7 @@ def try_set_start_method(
|
||||||
case 'mp_spawn':
|
case 'mp_spawn':
|
||||||
_ctx = mp.get_context('spawn')
|
_ctx = mp.get_context('spawn')
|
||||||
|
|
||||||
case (
|
case 'trio':
|
||||||
'trio'
|
|
||||||
| 'main_thread_forkserver'
|
|
||||||
):
|
|
||||||
_ctx = None
|
|
||||||
|
|
||||||
case (
|
|
||||||
'subint'
|
|
||||||
| 'subint_fork'
|
|
||||||
| 'subint_forkserver'
|
|
||||||
):
|
|
||||||
# All subint-family backends need no `mp.context`;
|
|
||||||
# all four feature-gate on the py3.14 public
|
|
||||||
# `concurrent.interpreters` wrapper (PEP 734). See
|
|
||||||
# `tractor.spawn._subint` for the detailed
|
|
||||||
# reasoning. `subint_fork` is blocked at the
|
|
||||||
# CPython level (raises `NotImplementedError`);
|
|
||||||
# `main_thread_forkserver` is the working
|
|
||||||
# variant-1 backend; `subint_forkserver` aliases
|
|
||||||
# to it today, reserved for the future variant-2
|
|
||||||
# subint-isolated-child runtime once upstream
|
|
||||||
# msgspec#1026 unblocks.
|
|
||||||
from ._subint import _has_subints
|
|
||||||
if not _has_subints:
|
|
||||||
raise RuntimeError(
|
|
||||||
f'Spawn method {key!r} requires Python 3.14+.\n'
|
|
||||||
f'(On py3.13 the private `_interpreters` C '
|
|
||||||
f'module exists but tractor\'s spawn flow '
|
|
||||||
f'wedges — see `tractor.spawn._subint` '
|
|
||||||
f'docstring for details.)\n'
|
|
||||||
f'Current runtime: {sys.version}'
|
|
||||||
)
|
|
||||||
_ctx = None
|
_ctx = None
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
|
|
@ -591,10 +465,6 @@ async def new_proc(
|
||||||
# `hard_kill`/`proc_waiter` from this module.
|
# `hard_kill`/`proc_waiter` from this module.
|
||||||
from ._trio import trio_proc
|
from ._trio import trio_proc
|
||||||
from ._mp import mp_proc
|
from ._mp import mp_proc
|
||||||
from ._subint import subint_proc
|
|
||||||
from ._subint_fork import subint_fork_proc
|
|
||||||
from ._main_thread_forkserver import main_thread_forkserver_proc
|
|
||||||
from ._subint_forkserver import subint_forkserver_proc
|
|
||||||
|
|
||||||
|
|
||||||
# proc spawning backend target map
|
# proc spawning backend target map
|
||||||
|
|
@ -602,25 +472,4 @@ _methods: dict[SpawnMethodKey, Callable] = {
|
||||||
'trio': trio_proc,
|
'trio': trio_proc,
|
||||||
'mp_spawn': mp_proc,
|
'mp_spawn': mp_proc,
|
||||||
'mp_forkserver': mp_proc,
|
'mp_forkserver': mp_proc,
|
||||||
'subint': subint_proc,
|
|
||||||
# blocked at CPython level — see `_subint_fork.py` +
|
|
||||||
# `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`.
|
|
||||||
# Kept here so `--spawn-backend=subint_fork` routes to a
|
|
||||||
# clean `NotImplementedError` with pointer to the analysis,
|
|
||||||
# rather than an "invalid backend" error.
|
|
||||||
'subint_fork': subint_fork_proc,
|
|
||||||
# Variant-1 (working today): fork from a regular main-interp
|
|
||||||
# worker thread, child runs trio on its own main interp.
|
|
||||||
# Validated by
|
|
||||||
# `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`.
|
|
||||||
# See `tractor.spawn._main_thread_forkserver`.
|
|
||||||
'main_thread_forkserver': main_thread_forkserver_proc,
|
|
||||||
# Variant-2 (future, reserved): same fork machinery but
|
|
||||||
# child enters a sub-interpreter to host its `trio.run()`
|
|
||||||
# — gated on jcrist/msgspec#1026 unblocking PEP 684
|
|
||||||
# isolated-mode subints. Today the stub raises
|
|
||||||
# `NotImplementedError` pointing at the variant-1 backend
|
|
||||||
# + upstream blocker. See
|
|
||||||
# `tractor.spawn._subint_forkserver`.
|
|
||||||
'subint_forkserver': subint_forkserver_proc,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,474 +0,0 @@
|
||||||
# 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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Sub-interpreter (`subint`) actor spawning backend.
|
|
||||||
|
|
||||||
Spawns each sub-actor as a CPython PEP 734 sub-interpreter
|
|
||||||
(`concurrent.interpreters.Interpreter`) driven on its own OS
|
|
||||||
thread — same-process state isolation with faster start-up
|
|
||||||
than an OS subproc, while preserving tractor's existing
|
|
||||||
IPC-based actor boundary.
|
|
||||||
|
|
||||||
Availability
|
|
||||||
------------
|
|
||||||
Requires Python **3.14+**. The private `_interpreters` C
|
|
||||||
module we actually call into has shipped since 3.13, but
|
|
||||||
that vintage has a latent bug in its thread/subint
|
|
||||||
interaction which wedges tractor's spawn flow after
|
|
||||||
`_interpreters.create()` — the driver `threading.Thread`
|
|
||||||
silently never makes progress inside `_interpreters.exec()`.
|
|
||||||
(Minimal standalone reproductions with threading +
|
|
||||||
`_interpreters.exec()` work fine on 3.13; only our
|
|
||||||
multi-trio-task usage triggers the hang. 3.14 fixes it.)
|
|
||||||
On older runtimes the module still imports (so the registry
|
|
||||||
stays introspectable) but `subint_proc()` raises.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
import trio
|
|
||||||
from trio import TaskStatus
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE: we reach into the *private* `_interpreters` C module
|
|
||||||
# for the actual subint create/exec/destroy calls rather than
|
|
||||||
# `concurrent.interpreters`' public API because the public API
|
|
||||||
# only exposes PEP 734's `'isolated'` config (per-interp GIL).
|
|
||||||
# Under `'isolated'`, any C extension missing the
|
|
||||||
# `Py_mod_multiple_interpreters` slot (PEP 684) refuses to
|
|
||||||
# import; in our stack that's `msgspec` — which tractor uses
|
|
||||||
# pervasively in the IPC layer — so isolated-mode subints
|
|
||||||
# can't finish booting the sub-actor's `trio.run()`. msgspec
|
|
||||||
# PEP 684 support is open upstream at jcrist/msgspec#563.
|
|
||||||
#
|
|
||||||
# Dropping to the `'legacy'` config keeps the main GIL + lets
|
|
||||||
# existing C extensions load normally while preserving the
|
|
||||||
# state isolation we actually care about for the actor model
|
|
||||||
# (separate `sys.modules` / `__main__` / globals).
|
|
||||||
#
|
|
||||||
# But — we feature-gate on the **public** `concurrent.interpreters`
|
|
||||||
# module (3.14+) even though we only call into the private
|
|
||||||
# `_interpreters` module. Reason: the private module has
|
|
||||||
# shipped since 3.13, but the thread/subint interactions
|
|
||||||
# tractor relies on (`threading.Thread` driving
|
|
||||||
# `_interpreters.exec(..., legacy)` while a trio loop runs in
|
|
||||||
# the parent + another inside the subint + IPC between them)
|
|
||||||
# hang silently on 3.13 and only work cleanly on 3.14. See
|
|
||||||
# docstring above for the empirical details. Using the public
|
|
||||||
# module's existence as the gate keeps this check honest.
|
|
||||||
#
|
|
||||||
# Migration path: when msgspec (jcrist/msgspec#563) and any
|
|
||||||
# other PEP 684-holdout C deps opt-in, we can switch to the
|
|
||||||
# public `concurrent.interpreters.create()` API (isolated
|
|
||||||
# mode) and pick up per-interp-GIL parallelism for free.
|
|
||||||
#
|
|
||||||
# References:
|
|
||||||
# - PEP 734 (`concurrent.interpreters` public API):
|
|
||||||
# https://peps.python.org/pep-0734/
|
|
||||||
# - PEP 684 (per-interpreter GIL / `Py_mod_multiple_interpreters`):
|
|
||||||
# https://peps.python.org/pep-0684/
|
|
||||||
# - stdlib docs (3.14+):
|
|
||||||
# https://docs.python.org/3.14/library/concurrent.interpreters.html
|
|
||||||
# - CPython public wrapper source (`Lib/concurrent/interpreters/`):
|
|
||||||
# https://github.com/python/cpython/tree/main/Lib/concurrent/interpreters
|
|
||||||
# - CPython private C ext source
|
|
||||||
# (`Modules/_interpretersmodule.c`):
|
|
||||||
# https://github.com/python/cpython/blob/main/Modules/_interpretersmodule.c
|
|
||||||
# - msgspec PEP 684 upstream tracker:
|
|
||||||
# https://github.com/jcrist/msgspec/issues/563
|
|
||||||
try:
|
|
||||||
# gate: presence of the public 3.14 stdlib wrapper (we
|
|
||||||
# don't actually use it below, see NOTE above).
|
|
||||||
from concurrent import interpreters as _public_interpreters # noqa: F401 # type: ignore
|
|
||||||
# actual driver: the private C module (also present on
|
|
||||||
# 3.13 but we refuse that version — see gate above).
|
|
||||||
import _interpreters # type: ignore
|
|
||||||
_has_subints: bool = True
|
|
||||||
except ImportError:
|
|
||||||
_interpreters = None # type: ignore
|
|
||||||
_has_subints: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
from tractor.log import get_logger
|
|
||||||
from tractor.msg import (
|
|
||||||
types as msgtypes,
|
|
||||||
pretty_struct,
|
|
||||||
)
|
|
||||||
from tractor.runtime._state import current_actor
|
|
||||||
from tractor.runtime._portal import Portal
|
|
||||||
from ._spawn import cancel_on_completion
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from tractor.discovery._addr import UnwrappedAddress
|
|
||||||
from tractor.ipc import (
|
|
||||||
_server,
|
|
||||||
)
|
|
||||||
from tractor.runtime._runtime import Actor
|
|
||||||
from tractor.runtime._supervise import ActorNursery
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger('tractor')
|
|
||||||
|
|
||||||
|
|
||||||
# How long we'll wait (in seconds) inside the shielded soft-kill
|
|
||||||
# / teardown blocks before abandoning the sub-interpreter to its
|
|
||||||
# fate. See the "hard-kill" comments at the two shield sites.
|
|
||||||
#
|
|
||||||
# Unbounded shields are a Bad Idea with subints: because CPython
|
|
||||||
# doesn't deliver SIGINT into sub-interpreters and the legacy
|
|
||||||
# config shares the main GIL, a stuck subint can otherwise lock
|
|
||||||
# the parent trio loop (and the user's Ctrl-C) indefinitely.
|
|
||||||
_HARD_KILL_TIMEOUT: float = 3.0
|
|
||||||
|
|
||||||
|
|
||||||
async def subint_proc(
|
|
||||||
name: str,
|
|
||||||
actor_nursery: ActorNursery,
|
|
||||||
subactor: Actor,
|
|
||||||
errors: dict[tuple[str, str], Exception],
|
|
||||||
|
|
||||||
# passed through to actor main
|
|
||||||
bind_addrs: list[UnwrappedAddress],
|
|
||||||
parent_addr: UnwrappedAddress,
|
|
||||||
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
|
||||||
*,
|
|
||||||
infect_asyncio: bool = False,
|
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
|
||||||
proc_kwargs: dict[str, any] = {}
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Create a new sub-actor hosted inside a PEP 734
|
|
||||||
sub-interpreter running on a dedicated OS thread,
|
|
||||||
reusing tractor's existing UDS/TCP IPC handshake
|
|
||||||
for parent<->child channel setup.
|
|
||||||
|
|
||||||
Supervision model mirrors `trio_proc()`:
|
|
||||||
- parent awaits `ipc_server.wait_for_peer()` for the
|
|
||||||
child to connect back; on success yields a `Portal`
|
|
||||||
via `task_status.started()`
|
|
||||||
- on graceful shutdown we await the sub-interpreter's
|
|
||||||
`trio.run()` completing naturally (driven by the
|
|
||||||
child's actor runtime)
|
|
||||||
- on cancellation we send `Portal.cancel_actor()` and
|
|
||||||
then wait for the subint's trio loop to exit cleanly
|
|
||||||
— unblocking the worker thread so the `Interpreter`
|
|
||||||
can be closed
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not _has_subints:
|
|
||||||
raise RuntimeError(
|
|
||||||
f'The {"subint"!r} spawn backend requires Python 3.14+.\n'
|
|
||||||
f'(On py3.13 the private `_interpreters` C module '
|
|
||||||
f'exists but tractor\'s spawn flow wedges — see '
|
|
||||||
f'`tractor.spawn._subint` docstring for details.)\n'
|
|
||||||
f'Current runtime: {sys.version}'
|
|
||||||
)
|
|
||||||
|
|
||||||
interp_id: int = _interpreters.create('legacy')
|
|
||||||
log.runtime(
|
|
||||||
f'Created sub-interpreter (legacy cfg) for sub-actor\n'
|
|
||||||
f'(>\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
uid: tuple[str, str] = subactor.aid.uid
|
|
||||||
loglevel: str | None = subactor.loglevel
|
|
||||||
|
|
||||||
# Build a bootstrap code string driven via `_interpreters.exec()`.
|
|
||||||
# All of `uid` (`tuple[str, str]`), `loglevel` (`str|None`),
|
|
||||||
# `parent_addr` (`tuple[str, int|str]` — see `UnwrappedAddress`)
|
|
||||||
# and `infect_asyncio` (`bool`) `repr()` to valid Python
|
|
||||||
# literals, so we can embed them directly.
|
|
||||||
#
|
|
||||||
# ?TODO, future SpawnSpec enrichment: if we ever want to pass
|
|
||||||
# non-`repr()`-roundtrippable values (a pre-built `SpawnSpec`
|
|
||||||
# struct, a credential token, a callable) we should switch to
|
|
||||||
# `_interpreters.set___main___attrs(interp_id, {...})` — the
|
|
||||||
# API anyio uses in `to_interpreter._Worker.call()`. Pattern:
|
|
||||||
# https://github.com/agronholm/anyio/blob/master/src/anyio/to_interpreter.py
|
|
||||||
# (the `set___main___attrs` site). See also tracking issue
|
|
||||||
# `#379`.
|
|
||||||
bootstrap: str = (
|
|
||||||
'from tractor._child import _actor_child_main\n'
|
|
||||||
'_actor_child_main(\n'
|
|
||||||
f' uid={uid!r},\n'
|
|
||||||
f' loglevel={loglevel!r},\n'
|
|
||||||
f' parent_addr={parent_addr!r},\n'
|
|
||||||
f' infect_asyncio={infect_asyncio!r},\n'
|
|
||||||
f' spawn_method={"subint"!r},\n'
|
|
||||||
')\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
cancelled_during_spawn: bool = False
|
|
||||||
subint_exited = trio.Event()
|
|
||||||
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
|
||||||
|
|
||||||
# Capture a trio token so the driver thread can signal
|
|
||||||
# `subint_exited.set()` back into the parent trio loop.
|
|
||||||
trio_token = trio.lowlevel.current_trio_token()
|
|
||||||
|
|
||||||
def _subint_target() -> None:
|
|
||||||
'''
|
|
||||||
Dedicated OS-thread target: runs `_interpreters.exec()`
|
|
||||||
once and exits.
|
|
||||||
|
|
||||||
We intentionally use a plain `threading.Thread` here
|
|
||||||
rather than `trio.to_thread.run_sync()` because trio's
|
|
||||||
thread cache would *recycle* the same OS thread for
|
|
||||||
subsequent jobs — leaving CPython's subinterpreter
|
|
||||||
tstate attached to that cached worker and blocking
|
|
||||||
`_interpreters.destroy()` in the teardown block below.
|
|
||||||
A dedicated thread truly exits after `exec()` returns,
|
|
||||||
releasing the tstate so destroy can proceed.
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
_interpreters.exec(interp_id, bootstrap)
|
|
||||||
# XXX without this catch, a hard subint-bootstrap
|
|
||||||
# failure (e.g. `ImportError` because the actor module
|
|
||||||
# isn't importable inside the subint, or a syntax error
|
|
||||||
# in the bootstrap str) goes only to Python's default
|
|
||||||
# thread-excepthook and is INVISIBLE to the parent
|
|
||||||
# task. At minimum, log via `log.exception` so the
|
|
||||||
# operator sees what failed.
|
|
||||||
# ?TODO, surface the captured exc to the parent task
|
|
||||||
# via a `nonlocal err` slot inspected after
|
|
||||||
# `subint_exited.wait()` — see anyio's
|
|
||||||
# `to_interpreter._interp_call` `(retval, is_exception)`
|
|
||||||
# tuple pattern +
|
|
||||||
# `_subint_forkserver.run_subint_in_worker_thread._drive`'s
|
|
||||||
# equivalent which already does this. Skipped here for
|
|
||||||
# now: re-raise from the parent must coordinate with
|
|
||||||
# the existing `trio.Cancelled` paths around the
|
|
||||||
# `subint_exited.wait()` calls (lines 327, 362).
|
|
||||||
# NOTE: this whole dedicated-thread machinery may go
|
|
||||||
# away under #450 (PEP 684 isolated mode), in which
|
|
||||||
# case `trio.to_thread.run_sync(Interpreter.exec, ...)`
|
|
||||||
# would handle exception propagation natively.
|
|
||||||
except BaseException:
|
|
||||||
log.exception(
|
|
||||||
f'subint bootstrap raised — interp_id={interp_id}'
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
trio.from_thread.run_sync(
|
|
||||||
subint_exited.set,
|
|
||||||
trio_token=trio_token,
|
|
||||||
)
|
|
||||||
except trio.RunFinishedError:
|
|
||||||
# parent trio loop has already exited (proc
|
|
||||||
# teardown); nothing to signal.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# NOTE: `daemon=True` so a stuck subint can never block
|
|
||||||
# process exit — if our `_HARD_KILL_TIMEOUT` paths below
|
|
||||||
# have to abandon this thread, Python's interpreter
|
|
||||||
# shutdown won't wait for it forever. Tradeoff: any
|
|
||||||
# subint state still live at abandon-time may leak.
|
|
||||||
driver_thread = threading.Thread(
|
|
||||||
target=_subint_target,
|
|
||||||
name=f'subint-driver[{interp_id}]',
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
driver_thread.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
event, chan = await ipc_server.wait_for_peer(uid)
|
|
||||||
except trio.Cancelled:
|
|
||||||
cancelled_during_spawn = True
|
|
||||||
raise
|
|
||||||
|
|
||||||
portal = Portal(chan)
|
|
||||||
actor_nursery._children[uid] = (
|
|
||||||
subactor,
|
|
||||||
interp_id, # proxy for the normal `proc` slot
|
|
||||||
portal,
|
|
||||||
)
|
|
||||||
|
|
||||||
sspec = msgtypes.SpawnSpec(
|
|
||||||
_parent_main_data=subactor._parent_main_data,
|
|
||||||
enable_modules=subactor.enable_modules,
|
|
||||||
reg_addrs=subactor.reg_addrs,
|
|
||||||
bind_addrs=bind_addrs,
|
|
||||||
_runtime_vars=_runtime_vars,
|
|
||||||
)
|
|
||||||
log.runtime(
|
|
||||||
f'Sending spawn spec to subint child\n'
|
|
||||||
f'{{}}=> {chan.aid.reprol()!r}\n'
|
|
||||||
f'\n'
|
|
||||||
f'{pretty_struct.pformat(sspec)}\n'
|
|
||||||
)
|
|
||||||
await chan.send(sspec)
|
|
||||||
|
|
||||||
curr_actor: Actor = current_actor()
|
|
||||||
curr_actor._actoruid2nursery[uid] = actor_nursery
|
|
||||||
|
|
||||||
task_status.started(portal)
|
|
||||||
|
|
||||||
with trio.CancelScope(shield=True):
|
|
||||||
await actor_nursery._join_procs.wait()
|
|
||||||
|
|
||||||
async with trio.open_nursery() as lifecycle_n:
|
|
||||||
if portal in actor_nursery._cancel_after_result_on_exit:
|
|
||||||
lifecycle_n.start_soon(
|
|
||||||
cancel_on_completion,
|
|
||||||
portal,
|
|
||||||
subactor,
|
|
||||||
errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Soft-kill analog: wait for the subint to exit
|
|
||||||
# naturally; on cancel, send a graceful cancel
|
|
||||||
# via the IPC portal and then wait for the
|
|
||||||
# driver thread to finish so `_interpreters.destroy()`
|
|
||||||
# won't race with a running interpreter.
|
|
||||||
try:
|
|
||||||
await subint_exited.wait()
|
|
||||||
except trio.Cancelled:
|
|
||||||
# Bounded shield: we want to ATTEMPT a
|
|
||||||
# graceful cancel via the portal, but we
|
|
||||||
# MUST NOT let the shield trap user
|
|
||||||
# Ctrl-C / parent teardown forever if the
|
|
||||||
# subint is already unreachable (e.g., the
|
|
||||||
# IPC channel was broken — which is exactly
|
|
||||||
# what `test_ipc_channel_break_during_stream`
|
|
||||||
# exercises). After `_HARD_KILL_TIMEOUT` we
|
|
||||||
# drop the shield and let `Cancelled`
|
|
||||||
# propagate; the outer teardown will force
|
|
||||||
# things along.
|
|
||||||
with (
|
|
||||||
trio.CancelScope(shield=True),
|
|
||||||
trio.move_on_after(
|
|
||||||
_HARD_KILL_TIMEOUT,
|
|
||||||
) as cs,
|
|
||||||
):
|
|
||||||
log.cancel(
|
|
||||||
f'Soft-killing subint sub-actor\n'
|
|
||||||
f'c)=> {chan.aid.reprol()}\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await portal.cancel_actor()
|
|
||||||
except (
|
|
||||||
trio.BrokenResourceError,
|
|
||||||
trio.ClosedResourceError,
|
|
||||||
):
|
|
||||||
# channel already down — subint will
|
|
||||||
# exit on its own timeline (or won't,
|
|
||||||
# in which case the timeout below
|
|
||||||
# is our escape).
|
|
||||||
pass
|
|
||||||
await subint_exited.wait()
|
|
||||||
if cs.cancelled_caught:
|
|
||||||
log.warning(
|
|
||||||
f'Soft-kill of subint sub-actor timed '
|
|
||||||
f'out after {_HARD_KILL_TIMEOUT}s — '
|
|
||||||
f'subint may still be running; '
|
|
||||||
f'escalating to thread-abandon.\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
f' |_aid={chan.aid.reprol()}\n'
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
lifecycle_n.cancel_scope.cancel()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Ensure the driver thread is *fully* joined before
|
|
||||||
# destroying the subint. `subint_exited.set()` fires
|
|
||||||
# from inside the thread but returns to trio before
|
|
||||||
# the thread's bootstrap cleanup finishes; calling
|
|
||||||
# `destroy()` too eagerly can race with tstate
|
|
||||||
# teardown. Off-load the blocking `.join()` to a
|
|
||||||
# cache thread (which carries no subint tstate of
|
|
||||||
# its own, so no cache conflict).
|
|
||||||
#
|
|
||||||
# Bounded shield: if the driver thread never exits
|
|
||||||
# (soft-kill failed above, subint stuck in
|
|
||||||
# non-checkpointing Python, etc.) we MUST abandon
|
|
||||||
# it rather than wedge the parent forever. The
|
|
||||||
# thread is `daemon=True` so proc-exit won't block
|
|
||||||
# on it either. Subsequent `_interpreters.destroy()`
|
|
||||||
# on a still-running subint raises `InterpreterError`
|
|
||||||
# which we log and swallow — the abandoned subint
|
|
||||||
# will be torn down by process exit.
|
|
||||||
with (
|
|
||||||
trio.CancelScope(shield=True),
|
|
||||||
trio.move_on_after(_HARD_KILL_TIMEOUT) as cs,
|
|
||||||
):
|
|
||||||
if driver_thread.is_alive():
|
|
||||||
# XXX `abandon_on_cancel=True` is load-bearing:
|
|
||||||
# the default (False) makes `to_thread.run_sync`
|
|
||||||
# ignore the enclosing `move_on_after` and
|
|
||||||
# block until `driver_thread.join()` returns —
|
|
||||||
# which is exactly what we can't wait for here.
|
|
||||||
await trio.to_thread.run_sync(
|
|
||||||
driver_thread.join,
|
|
||||||
abandon_on_cancel=True,
|
|
||||||
)
|
|
||||||
if cs.cancelled_caught:
|
|
||||||
# Disambiguate "thread leaked but subint already
|
|
||||||
# done" from "thread alive because subint is
|
|
||||||
# genuinely wedged" — pattern borrowed from
|
|
||||||
# trio-parallel's `_sint.SintWorker.is_alive()`.
|
|
||||||
still_running: bool = _interpreters.is_running(
|
|
||||||
interp_id,
|
|
||||||
)
|
|
||||||
log.warning(
|
|
||||||
f'Subint driver thread did not exit within '
|
|
||||||
f'{_HARD_KILL_TIMEOUT}s — abandoning.\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
f' |_thread={driver_thread.name}\n'
|
|
||||||
f' |_subint_still_running={still_running}\n'
|
|
||||||
f'(This usually means portal-cancel could '
|
|
||||||
f'not be delivered — e.g., IPC channel was '
|
|
||||||
f'already broken. The subint will continue '
|
|
||||||
f'running until process exit terminates the '
|
|
||||||
f'daemon thread.)'
|
|
||||||
)
|
|
||||||
|
|
||||||
with trio.CancelScope(shield=True):
|
|
||||||
try:
|
|
||||||
_interpreters.destroy(interp_id)
|
|
||||||
log.runtime(
|
|
||||||
f'Destroyed sub-interpreter\n'
|
|
||||||
f')>\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
)
|
|
||||||
except _interpreters.InterpreterError as e:
|
|
||||||
log.warning(
|
|
||||||
f'Could not destroy sub-interpreter '
|
|
||||||
f'{interp_id}: {e}\n'
|
|
||||||
f'(expected if the driver thread was '
|
|
||||||
f'abandoned above; the subint is still '
|
|
||||||
f'running and will be reaped at process '
|
|
||||||
f'exit.)'
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if not cancelled_during_spawn:
|
|
||||||
actor_nursery._children.pop(uid, None)
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
# 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.'
|
|
||||||
)
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
# 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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Variant-2 (future) "subint forkserver" placeholder — reserved
|
|
||||||
for the eventual subint-isolated-child runtime variant.
|
|
||||||
|
|
||||||
> **Status:** reserved key, stub impl. Today
|
|
||||||
> `--spawn-backend=subint_forkserver` raises a clean
|
|
||||||
> `NotImplementedError` from `subint_forkserver_proc()`
|
|
||||||
> below, pointing at variant-1
|
|
||||||
> (`--spawn-backend=main_thread_forkserver`, see
|
|
||||||
> `tractor.spawn._main_thread_forkserver`) and the upstream
|
|
||||||
> blocker
|
|
||||||
> ([jcrist/msgspec#1026](https://github.com/jcrist/msgspec/issues/1026)).
|
|
||||||
> The key is reserved here (not aliased to variant-1) so the
|
|
||||||
> literal subint-hosted-child impl can flip in-place once
|
|
||||||
> msgspec#1026 unblocks PEP 684 isolated-mode subints
|
|
||||||
> upstream — no API churn at the call site.
|
|
||||||
|
|
||||||
Future arch — what subints would buy us
|
|
||||||
---------------------------------------
|
|
||||||
|
|
||||||
When msgspec#1026 unblocks isolated-mode subints (PEP 684
|
|
||||||
per-interp GIL), three concrete wins land — these are the
|
|
||||||
reason the `'subint_forkserver'` key is reserved as a
|
|
||||||
distinct backend rather than just folded into
|
|
||||||
`'main_thread_forkserver'`:
|
|
||||||
|
|
||||||
**(1) Cheaper forks (smaller main-interp COW image)**
|
|
||||||
|
|
||||||
Today (variant 1) the parent's main interp carries the full
|
|
||||||
tractor stack: trio runtime, msgspec codecs, IPC layer,
|
|
||||||
every user module the actor imported. When the forkserver
|
|
||||||
worker calls `os.fork()` the child inherits ALL of that as
|
|
||||||
COW memory — even though most gets overwritten when the
|
|
||||||
child boots its own `trio.run()`.
|
|
||||||
|
|
||||||
Variant 2 moves the parent's `trio.run()` into a subint (its
|
|
||||||
own `sys.modules` / `__main__` / globals). The main interp
|
|
||||||
**stays minimal** — just the forkserver-thread plumbing +
|
|
||||||
bare CPython. The main interp becomes the *literal*
|
|
||||||
forkserver: an intentionally-empty execution context whose
|
|
||||||
only job is to call `os.fork()` cleanly. Inherited COW image
|
|
||||||
shrinks proportionally.
|
|
||||||
|
|
||||||
**(2) True parallelism between forkserver and trio
|
|
||||||
(per-interp GIL)**
|
|
||||||
|
|
||||||
Variant-1 today: the forkserver worker and the trio.run()
|
|
||||||
thread share the main GIL — when one runs the other waits.
|
|
||||||
Spawn requests briefly stall trio while the worker takes
|
|
||||||
the GIL to call `os.fork()`. PEP 684 isolated-mode gives
|
|
||||||
each subint its own GIL: forkserver thread on main + trio
|
|
||||||
on subint actually run in parallel. Spawn latency drops,
|
|
||||||
trio loop doesn't notice the fork happening.
|
|
||||||
|
|
||||||
**(3) Multi-actor-per-process (the architectural prize)**
|
|
||||||
|
|
||||||
The bigger payoff and the reason `_subint.py` (the in-thread
|
|
||||||
`subint` backend) exists in parallel with this module. With
|
|
||||||
per-interp-GIL subints, one process can host:
|
|
||||||
|
|
||||||
- main interp: forkserver thread + bookkeeping
|
|
||||||
- subint A: actor 1's `trio.run()`
|
|
||||||
- subint B: actor 2's `trio.run()`
|
|
||||||
- subint C: ...
|
|
||||||
|
|
||||||
`os.fork()` becomes the **last-resort** spawn — used only
|
|
||||||
when a new OS process is actually required (cgroups,
|
|
||||||
namespaces, security boundary, multi-host distribution).
|
|
||||||
Within a single process, subint-per-actor is radically
|
|
||||||
cheaper: no fork, no COW, no inherited-fd cleanup — just
|
|
||||||
`_interpreters.create()` + `_interpreters.exec()`.
|
|
||||||
|
|
||||||
The three backends converge on a coherent story:
|
|
||||||
|
|
||||||
- `subint` → in-process spawn (cheap, GIL-isolated),
|
|
||||||
- `main_thread_forkserver` → cross-process spawn today
|
|
||||||
(variant 1, working),
|
|
||||||
- `subint_forkserver` → cross-process spawn with
|
|
||||||
isolated-subint child (variant 2, this module, future).
|
|
||||||
|
|
||||||
What lives here today
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
- `run_subint_in_worker_thread()` — companion primitive to
|
|
||||||
`_main_thread_forkserver.fork_from_worker_thread()`. Creates
|
|
||||||
a fresh `legacy`-config sub-interpreter and drives a given
|
|
||||||
bootstrap code string through `_interpreters.exec()` on a
|
|
||||||
dedicated worker thread; destroys the subint after the
|
|
||||||
thread joins. Used today by the
|
|
||||||
`subint_fork_from_main_thread_smoketest.py` feasibility
|
|
||||||
check; will be wired into the variant-2
|
|
||||||
`subint_forkserver_proc` spawn-coroutine when it lands.
|
|
||||||
- (legacy re-exports of fork primitives kept for backward-
|
|
||||||
compatible imports until external consumers migrate to
|
|
||||||
`_main_thread_forkserver`)
|
|
||||||
|
|
||||||
What will live here when variant 2 ships
|
|
||||||
----------------------------------------
|
|
||||||
|
|
||||||
- `subint_forkserver_proc()` — the variant-2 spawn-backend
|
|
||||||
coroutine. Same fork machinery as variant 1, but the
|
|
||||||
fork-child enters a fresh subint (via
|
|
||||||
`run_subint_in_worker_thread`) before booting its
|
|
||||||
`trio.run()`. Net effect: child runtime is GIL-isolated
|
|
||||||
from the parent + any sibling actors in the same process.
|
|
||||||
- A stub `subint_forkserver_proc` is added in a follow-up
|
|
||||||
commit that raises `NotImplementedError(...)` pointing at
|
|
||||||
this docstring + jcrist/msgspec#1026 + tractor #379, so
|
|
||||||
`--spawn-backend=subint_forkserver` errors cleanly today
|
|
||||||
rather than silently aliasing variant 1.
|
|
||||||
|
|
||||||
See also
|
|
||||||
--------
|
|
||||||
|
|
||||||
- `tractor.spawn._main_thread_forkserver` — variant 1,
|
|
||||||
working today; for the full design rationale, fork-
|
|
||||||
semantics analysis, and trio×fork hazard breakdown.
|
|
||||||
- `tractor.spawn._subint` — the in-thread `subint` backend
|
|
||||||
(one process, one actor per subint, no fork).
|
|
||||||
- `tractor.spawn._subint_fork` — RFC stub for the
|
|
||||||
fork-from-non-main-subint strategy that is blocked at the
|
|
||||||
CPython level.
|
|
||||||
- [#379](https://github.com/goodboy/tractor/issues/379)
|
|
||||||
— subint backend umbrella tracking issue.
|
|
||||||
- [jcrist/msgspec#1026](https://github.com/jcrist/msgspec/issues/1026)
|
|
||||||
— upstream blocker for PEP 684 isolated-mode subints.
|
|
||||||
- [#450](https://github.com/goodboy/tractor/issues/450) —
|
|
||||||
thread-constraints audit follow-up tied to msgspec#1026.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
import threading
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
import trio
|
|
||||||
from trio import TaskStatus
|
|
||||||
|
|
||||||
from tractor.log import get_logger
|
|
||||||
from ._subint import _has_subints
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from tractor.discovery._addr import UnwrappedAddress
|
|
||||||
from tractor.runtime._portal import Portal
|
|
||||||
from tractor.runtime._runtime import Actor
|
|
||||||
from tractor.runtime._supervise import ActorNursery
|
|
||||||
|
|
||||||
# Private CPython subint API — used by `run_subint_in_worker_thread`
|
|
||||||
# below. Imported only when 3.14+ is detected (via `_has_subints`
|
|
||||||
# from `_subint`); on older runtimes the symbol is `None` and
|
|
||||||
# the function raises a clean `RuntimeError` on entry.
|
|
||||||
if _has_subints:
|
|
||||||
import _interpreters # type: ignore
|
|
||||||
else:
|
|
||||||
_interpreters = None # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger('tractor')
|
|
||||||
|
|
||||||
|
|
||||||
def run_subint_in_worker_thread(
|
|
||||||
bootstrap: str,
|
|
||||||
*,
|
|
||||||
thread_name: str = 'subint-trio',
|
|
||||||
join_timeout: float = 10.0,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Create a fresh legacy-config sub-interpreter and drive
|
|
||||||
the given `bootstrap` code string through
|
|
||||||
`_interpreters.exec()` on a dedicated worker thread.
|
|
||||||
|
|
||||||
Naming mirrors `fork_from_worker_thread()`:
|
|
||||||
"<action>_in_worker_thread" — the action here is "run a
|
|
||||||
subint", not "run trio" per se. Typical `bootstrap`
|
|
||||||
content does import `trio` + call `trio.run()`, but
|
|
||||||
nothing about this primitive requires trio; it's a
|
|
||||||
generic "host a subint on a worker thread" helper.
|
|
||||||
Intended mainly for use inside a fork-child (see
|
|
||||||
`tractor.spawn._subint_forkserver` module docstring) but
|
|
||||||
works anywhere.
|
|
||||||
|
|
||||||
See `tractor.spawn._subint.subint_proc` for the matching
|
|
||||||
pattern tractor uses at the sub-actor level.
|
|
||||||
|
|
||||||
Destroys the subint after the thread joins.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not _has_subints:
|
|
||||||
raise RuntimeError(
|
|
||||||
'subint-forkserver primitives require Python '
|
|
||||||
'3.14+.'
|
|
||||||
)
|
|
||||||
|
|
||||||
interp_id: int = _interpreters.create('legacy')
|
|
||||||
log.runtime(
|
|
||||||
f'Created child-side subint for trio.run()\n'
|
|
||||||
f'(>\n'
|
|
||||||
f' |_interp_id={interp_id}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
err: BaseException | None = None
|
|
||||||
|
|
||||||
def _drive() -> None:
|
|
||||||
nonlocal err
|
|
||||||
try:
|
|
||||||
_interpreters.exec(interp_id, bootstrap)
|
|
||||||
except BaseException as e:
|
|
||||||
err = e
|
|
||||||
log.exception(
|
|
||||||
f'Failed to .exec() in subint ??\n'
|
|
||||||
f'_interpreters.exec(\n'
|
|
||||||
f' interp_id={interp_id!r},\n'
|
|
||||||
f' bootstrap={bootstrap!r},\n'
|
|
||||||
f') => {err!r}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
worker: threading.Thread = threading.Thread(
|
|
||||||
target=_drive,
|
|
||||||
name=thread_name,
|
|
||||||
daemon=False,
|
|
||||||
)
|
|
||||||
worker.start()
|
|
||||||
worker.join(timeout=join_timeout)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_interpreters.destroy(interp_id)
|
|
||||||
except _interpreters.InterpreterError as e:
|
|
||||||
log.warning(
|
|
||||||
f'Could not destroy child-side subint '
|
|
||||||
f'{interp_id}: {e}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if worker.is_alive():
|
|
||||||
raise RuntimeError(
|
|
||||||
f'child-side subint trio-driver thread '
|
|
||||||
f'{thread_name!r} did not return within '
|
|
||||||
f'{join_timeout}s.'
|
|
||||||
)
|
|
||||||
if err is not None:
|
|
||||||
raise err
|
|
||||||
|
|
||||||
|
|
||||||
async def subint_forkserver_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:
|
|
||||||
'''
|
|
||||||
PLACEHOLDER — variant-2 (subint-isolated child runtime)
|
|
||||||
spawn-backend coroutine. Reserved for the eventual impl
|
|
||||||
that uses `run_subint_in_worker_thread()` in the fork-child
|
|
||||||
to host the child's `trio.run()` inside a fresh subint.
|
|
||||||
|
|
||||||
Today this stub raises immediately so
|
|
||||||
`--spawn-backend=subint_forkserver` errors cleanly with a
|
|
||||||
pointer to the working variant-1 backend
|
|
||||||
(`main_thread_forkserver`) and the upstream blocker
|
|
||||||
([jcrist/msgspec#1026](https://github.com/jcrist/msgspec/issues/1026)).
|
|
||||||
|
|
||||||
See this module's top-level docstring for the future-arch
|
|
||||||
design + what lives here when the variant-2 impl lands.
|
|
||||||
|
|
||||||
'''
|
|
||||||
raise NotImplementedError(
|
|
||||||
f'`{ "subint_forkserver"!r}` spawn backend is reserved '
|
|
||||||
f'for the future variant-2 (subint-isolated child '
|
|
||||||
f'runtime) — gated on jcrist/msgspec#1026 unblocking '
|
|
||||||
f'PEP 684 isolated-mode subints upstream.\n'
|
|
||||||
f'\n'
|
|
||||||
f'For the working fork-based backend today, use '
|
|
||||||
f'`--spawn-backend=main_thread_forkserver` (variant '
|
|
||||||
f'1: fork from a regular main-interp worker thread, '
|
|
||||||
f'child runs trio on its own main interp).\n'
|
|
||||||
f'\n'
|
|
||||||
f'See:\n'
|
|
||||||
f' - tractor.spawn._main_thread_forkserver — the '
|
|
||||||
f'working variant-1 impl + design rationale\n'
|
|
||||||
f' - tractor.spawn._subint_forkserver — this '
|
|
||||||
f'module\'s docstring for the variant-2 future-arch\n'
|
|
||||||
f' - https://github.com/goodboy/tractor/issues/379 '
|
|
||||||
f'(subint umbrella)\n'
|
|
||||||
f' - https://github.com/jcrist/msgspec/issues/1026 '
|
|
||||||
f'(upstream PEP 684 blocker)'
|
|
||||||
)
|
|
||||||
|
|
@ -39,7 +39,7 @@ from tractor.runtime._state import (
|
||||||
current_actor,
|
current_actor,
|
||||||
is_root_process,
|
is_root_process,
|
||||||
debug_mode,
|
debug_mode,
|
||||||
# get_runtime_vars,
|
get_runtime_vars,
|
||||||
)
|
)
|
||||||
from tractor.log import get_logger
|
from tractor.log import get_logger
|
||||||
from tractor.discovery._addr import UnwrappedAddress
|
from tractor.discovery._addr import UnwrappedAddress
|
||||||
|
|
|
||||||
16
uv.lock
16
uv.lock
|
|
@ -559,7 +559,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
|
@ -568,9 +568,9 @@ dependencies = [
|
||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -793,7 +793,7 @@ dev = [
|
||||||
{ name = "prompt-toolkit", specifier = ">=3.0.50" },
|
{ name = "prompt-toolkit", specifier = ">=3.0.50" },
|
||||||
{ name = "psutil", specifier = ">=7.0.0" },
|
{ name = "psutil", specifier = ">=7.0.0" },
|
||||||
{ name = "pyperclip", specifier = ">=1.9.0" },
|
{ name = "pyperclip", specifier = ">=1.9.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-timeout", specifier = ">=2.3" },
|
{ name = "pytest-timeout", specifier = ">=2.3" },
|
||||||
{ name = "stackscope", specifier = ">=0.2.2,<0.3" },
|
{ name = "stackscope", specifier = ">=0.2.2,<0.3" },
|
||||||
{ name = "typing-extensions", specifier = ">=4.14.1" },
|
{ name = "typing-extensions", specifier = ">=4.14.1" },
|
||||||
|
|
@ -816,7 +816,7 @@ sync-pause = [{ name = "greenback", marker = "python_full_version == '3.13.*'",
|
||||||
testing = [
|
testing = [
|
||||||
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
||||||
{ name = "psutil", specifier = ">=7.0.0" },
|
{ name = "psutil", specifier = ">=7.0.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-timeout", specifier = ">=2.3" },
|
{ name = "pytest-timeout", specifier = ">=2.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -834,7 +834,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trio"
|
name = "trio"
|
||||||
version = "0.33.0"
|
version = "0.29.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
|
|
@ -844,9 +844,9 @@ dependencies = [
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio" },
|
||||||
{ name = "sortedcontainers" },
|
{ name = "sortedcontainers" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/47/f62e62a1a6f37909aed0bf8f5d5411e06fa03846cfcb64540cd1180ccc9f/trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf", size = 588952, upload-time = "2025-02-14T07:13:50.724Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920, upload-time = "2025-02-14T07:13:48.696Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue