Compare commits
62 Commits
a4d6318ca7
...
4106ba73ea
| Author | SHA1 | Date |
|---|---|---|
|
|
4106ba73ea | |
|
|
eceed29d4a | |
|
|
e312a68d8a | |
|
|
4d0555435b | |
|
|
ab86f7613d | |
|
|
458a35cf09 | |
|
|
7cd47ef7fb | |
|
|
76d12060aa | |
|
|
506617c695 | |
|
|
8ac3dfeb85 | |
|
|
c20b05e181 | |
|
|
9993db0193 | |
|
|
35da808905 | |
|
|
70d58c4bd2 | |
|
|
1af2121057 | |
|
|
e3f4f5a387 | |
|
|
d093c31979 | |
|
|
1e357dcf08 | |
|
|
e31eb8d7c9 | |
|
|
8bcbe730bf | |
|
|
5e85f184e0 | |
|
|
f5f37b69e6 | |
|
|
a72deef709 | |
|
|
dcd5c1ff40 | |
|
|
76605d5609 | |
|
|
7804a9feac | |
|
|
63ab7c986b | |
|
|
26914fde75 | |
|
|
cf2e71d87f | |
|
|
25e400d526 | |
|
|
82332fbceb | |
|
|
de4f470b6c | |
|
|
0f48ed2eb9 | |
|
|
eee79a0357 | |
|
|
4b2a0886c3 | |
|
|
3b26b59dad | |
|
|
f3cea714bc | |
|
|
985ea76de5 | |
|
|
5998774535 | |
|
|
a6cbac954d | |
|
|
189f4e3ffc | |
|
|
a65fded4c6 | |
|
|
4a3254583b | |
|
|
2ed5e6a6e8 | |
|
|
34d9d482e4 | |
|
|
09466a1e9d | |
|
|
99541feec7 | |
|
|
c041518bdb | |
|
|
31cbd11a5b | |
|
|
8a8d01e076 | |
|
|
03bf2b931e | |
|
|
b8f243e98d | |
|
|
d2ea8aa2de | |
|
|
d318f1f8f4 | |
|
|
64ddc42ad8 | |
|
|
b524ee4633 | |
|
|
b1a0753a3f | |
|
|
ba86d482e3 | |
|
|
d3d6f646f9 | |
|
|
9cf3d588e7 | |
|
|
e75e29b1dc | |
|
|
a7b1ee34ef |
|
|
@ -1,8 +1,16 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(date *)",
|
|
||||||
"Bash(cp .claude/*)",
|
"Bash(cp .claude/*)",
|
||||||
|
"Read(.claude/**)",
|
||||||
|
"Read(.claude/skills/run-tests/**)",
|
||||||
|
"Write(.claude/**/*commit_msg*)",
|
||||||
|
"Write(.claude/git_commit_msg_LATEST.md)",
|
||||||
|
"Skill(run-tests)",
|
||||||
|
"Skill(close-wkt)",
|
||||||
|
"Skill(open-wkt)",
|
||||||
|
"Skill(prompt-io)",
|
||||||
|
"Bash(date *)",
|
||||||
"Bash(git diff *)",
|
"Bash(git diff *)",
|
||||||
"Bash(git log *)",
|
"Bash(git log *)",
|
||||||
"Bash(git status)",
|
"Bash(git status)",
|
||||||
|
|
@ -23,14 +31,12 @@
|
||||||
"Bash(UV_PROJECT_ENVIRONMENT=py* uv sync:*)",
|
"Bash(UV_PROJECT_ENVIRONMENT=py* uv sync:*)",
|
||||||
"Bash(UV_PROJECT_ENVIRONMENT=py* uv run:*)",
|
"Bash(UV_PROJECT_ENVIRONMENT=py* uv run:*)",
|
||||||
"Bash(echo EXIT:$?:*)",
|
"Bash(echo EXIT:$?:*)",
|
||||||
"Write(.claude/*commit_msg*)",
|
"Bash(echo \"EXIT=$?\")",
|
||||||
"Write(.claude/git_commit_msg_LATEST.md)",
|
"Read(//tmp/**)"
|
||||||
"Skill(run-tests)",
|
|
||||||
"Skill(close-wkt)",
|
|
||||||
"Skill(open-wkt)",
|
|
||||||
"Skill(prompt-io)"
|
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
},
|
||||||
|
"prefersReducedMotion": false,
|
||||||
|
"outputStyle": "default"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,3 +229,69 @@ Unlike asyncio, trio allows checkpoints in
|
||||||
that does `await` can itself be cancelled (e.g.
|
that does `await` can itself be cancelled (e.g.
|
||||||
by nursery shutdown). Watch for cleanup code that
|
by nursery shutdown). Watch for cleanup code that
|
||||||
assumes it will run to completion.
|
assumes it will run to completion.
|
||||||
|
|
||||||
|
### Unbounded waits in cleanup paths
|
||||||
|
|
||||||
|
Any `await <event>.wait()` in a teardown path is
|
||||||
|
a latent deadlock unless the event's setter is
|
||||||
|
GUARANTEED to fire. If the setter depends on
|
||||||
|
external state (peer disconnects, child process
|
||||||
|
exit, subsequent task completion) that itself
|
||||||
|
depends on the current task's progress, you have
|
||||||
|
a mutual wait.
|
||||||
|
|
||||||
|
Rule: **bound every `await X.wait()` in cleanup
|
||||||
|
paths with `trio.move_on_after()`** unless you
|
||||||
|
can prove the setter is unconditionally reachable
|
||||||
|
from the state at the await site. Concrete recent
|
||||||
|
example: `ipc_server.wait_for_no_more_peers()` in
|
||||||
|
`async_main`'s finally (see
|
||||||
|
`ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`
|
||||||
|
"probe iteration 3") — it was unbounded, and when
|
||||||
|
one peer-handler was stuck the wait-for-no-more-
|
||||||
|
peers event never fired, deadlocking the whole
|
||||||
|
actor-tree teardown cascade.
|
||||||
|
|
||||||
|
### The capture-pipe-fill hang pattern (grep this first)
|
||||||
|
|
||||||
|
When investigating any hang in the test suite
|
||||||
|
**especially under fork-based backends**, first
|
||||||
|
check whether the hang reproduces under `pytest
|
||||||
|
-s` (`--capture=no`). If `-s` makes it go away
|
||||||
|
you're not looking at a trio concurrency bug —
|
||||||
|
you're looking at a Linux pipe-buffer fill.
|
||||||
|
|
||||||
|
Mechanism: pytest replaces fds 1,2 with pipe
|
||||||
|
write-ends. Fork-child subactors inherit those
|
||||||
|
fds. High-volume error-log tracebacks (cancel
|
||||||
|
cascade spew) fill the 64KB pipe buffer. Child
|
||||||
|
`write()` blocks. Child can't exit. Parent's
|
||||||
|
`waitpid`/pidfd wait blocks. Deadlock cascades up
|
||||||
|
the tree.
|
||||||
|
|
||||||
|
Pre-existing guards in `tests/conftest.py` encode
|
||||||
|
this knowledge — grep these BEFORE blaming
|
||||||
|
concurrency:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/conftest.py:258
|
||||||
|
if loglevel in ('trace', 'debug'):
|
||||||
|
# XXX: too much logging will lock up the subproc (smh)
|
||||||
|
loglevel: str = 'info'
|
||||||
|
|
||||||
|
# tests/conftest.py:316
|
||||||
|
# can lock up on the `_io.BufferedReader` and hang..
|
||||||
|
stderr: str = proc.stderr.read().decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
Full post-mortem +
|
||||||
|
`ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`
|
||||||
|
for the canonical reproduction. Cost several
|
||||||
|
investigation sessions before catching it —
|
||||||
|
because the capture-pipe symptom was masked by
|
||||||
|
deeper cascade-deadlocks. Once the cascades were
|
||||||
|
fixed, the tree tore down enough to generate
|
||||||
|
pipe-filling log volume → capture-pipe finally
|
||||||
|
surfaced. Grep-note for future-self: **if a
|
||||||
|
multi-subproc tractor test hangs, `pytest -s`
|
||||||
|
first, conc-anal second.**
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,19 @@ allowed-tools:
|
||||||
- Bash(python -m pytest *)
|
- Bash(python -m pytest *)
|
||||||
- Bash(python -c *)
|
- Bash(python -c *)
|
||||||
- Bash(python --version *)
|
- Bash(python --version *)
|
||||||
- Bash(git rev-parse *)
|
- Bash(UV_PROJECT_ENVIRONMENT=py* uv run python *)
|
||||||
|
- Bash(UV_PROJECT_ENVIRONMENT=py* uv run pytest *)
|
||||||
- Bash(UV_PROJECT_ENVIRONMENT=py* uv sync *)
|
- Bash(UV_PROJECT_ENVIRONMENT=py* uv sync *)
|
||||||
|
- Bash(UV_PROJECT_ENVIRONMENT=py* uv pip show *)
|
||||||
|
- Bash(git rev-parse *)
|
||||||
- Bash(ls *)
|
- Bash(ls *)
|
||||||
- Bash(cat *)
|
- Bash(cat *)
|
||||||
|
- Bash(jq * .pytest_cache/*)
|
||||||
- Read
|
- Read
|
||||||
- Grep
|
- Grep
|
||||||
- Glob
|
- Glob
|
||||||
- Task
|
- Task
|
||||||
|
- AskUserQuestion
|
||||||
---
|
---
|
||||||
|
|
||||||
Run the `tractor` test suite using `pytest`. Follow this
|
Run the `tractor` test suite using `pytest`. Follow this
|
||||||
|
|
@ -90,41 +95,104 @@ python -m pytest tests/ -x --tb=short --no-header --tpt-proto uds
|
||||||
python -m pytest tests/ -x --tb=short --no-header -k "cancel and not slow"
|
python -m pytest tests/ -x --tb=short --no-header -k "cancel and not slow"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Pre-flight checks (before running tests)
|
## 3. Pre-flight: venv detection (MANDATORY)
|
||||||
|
|
||||||
### Worktree venv detection
|
**Always verify a `uv` venv is active before running
|
||||||
|
`python` or `pytest`.** This project uses
|
||||||
|
`UV_PROJECT_ENVIRONMENT=py<MINOR>` naming (e.g.
|
||||||
|
`py313`) — never `.venv`.
|
||||||
|
|
||||||
If running inside a git worktree (`git rev-parse
|
### Step 1: detect active venv
|
||||||
--git-common-dir` differs from `--git-dir`), verify
|
|
||||||
the Python being used is from the **worktree's own
|
Run this check first:
|
||||||
venv**, not the main repo's. Check:
|
|
||||||
|
```sh
|
||||||
|
python -c "
|
||||||
|
import sys, os
|
||||||
|
venv = os.environ.get('VIRTUAL_ENV', '')
|
||||||
|
prefix = sys.prefix
|
||||||
|
print(f'VIRTUAL_ENV={venv}')
|
||||||
|
print(f'sys.prefix={prefix}')
|
||||||
|
print(f'executable={sys.executable}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: interpret results
|
||||||
|
|
||||||
|
**Case A — venv is active** (`VIRTUAL_ENV` is set
|
||||||
|
and points to a `py<MINOR>/` dir under the project
|
||||||
|
root or worktree):
|
||||||
|
|
||||||
|
Use bare `python` / `python -m pytest` for all
|
||||||
|
commands. This is the normal, fast path.
|
||||||
|
|
||||||
|
**Case B — no venv active** (`VIRTUAL_ENV` is empty
|
||||||
|
or `sys.prefix` points to a system Python):
|
||||||
|
|
||||||
|
Use `AskUserQuestion` to ask the user:
|
||||||
|
|
||||||
|
> "No uv venv is active. Should I activate one
|
||||||
|
> via `UV_PROJECT_ENVIRONMENT=py<MINOR> uv sync`,
|
||||||
|
> or would you prefer to activate your shell venv
|
||||||
|
> first?"
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1. **"Create/sync venv"** — run
|
||||||
|
`UV_PROJECT_ENVIRONMENT=py<MINOR> uv sync` where
|
||||||
|
`<MINOR>` is detected from `python --version`
|
||||||
|
(e.g. `313` for 3.13). Then use
|
||||||
|
`py<MINOR>/bin/python` for all subsequent
|
||||||
|
commands in this session.
|
||||||
|
2. **"I'll activate it myself"** — stop and let the
|
||||||
|
user `source py<MINOR>/bin/activate` or similar.
|
||||||
|
|
||||||
|
**Case C — inside a git worktree** (`git rev-parse
|
||||||
|
--git-common-dir` differs from `--git-dir`):
|
||||||
|
|
||||||
|
Verify Python resolves from the **worktree's own
|
||||||
|
venv**, not the main repo's:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python -c "import tractor; print(tractor.__file__)"
|
python -c "import tractor; print(tractor.__file__)"
|
||||||
```
|
```
|
||||||
|
|
||||||
If the path points outside the worktree (e.g. to
|
If the path points outside the worktree, create a
|
||||||
the main repo), set up a local venv first:
|
worktree-local venv:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
UV_PROJECT_ENVIRONMENT=py<MINOR> uv sync
|
UV_PROJECT_ENVIRONMENT=py<MINOR> uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
where `<MINOR>` matches the active cpython minor
|
Then use `py<MINOR>/bin/python` for all commands.
|
||||||
version (detect via `python --version`, e.g.
|
|
||||||
`py313` for 3.13, `py314` for 3.14). Then use
|
|
||||||
`py<MINOR>/bin/python` for all subsequent commands.
|
|
||||||
|
|
||||||
**Why this matters**: without a worktree-local venv,
|
**Why this matters**: without the correct venv,
|
||||||
subprocesses spawned by tractor resolve modules from
|
subprocesses spawned by tractor resolve modules
|
||||||
the main repo's editable install, causing spurious
|
from the wrong editable install, causing spurious
|
||||||
`AttributeError` / `ModuleNotFoundError` for code
|
`AttributeError` / `ModuleNotFoundError`.
|
||||||
that only exists on the worktree's branch.
|
|
||||||
|
|
||||||
### Import + collection checks
|
### Fallback: `uv run`
|
||||||
|
|
||||||
Always run these, especially after refactors or
|
If the user can't or won't activate a venv, all
|
||||||
module moves — they catch import errors instantly:
|
`python` and `pytest` commands can be prefixed
|
||||||
|
with `UV_PROJECT_ENVIRONMENT=py<MINOR> uv run`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# instead of: python -m pytest tests/ -x
|
||||||
|
UV_PROJECT_ENVIRONMENT=py313 uv run pytest tests/ -x
|
||||||
|
|
||||||
|
# instead of: python -c 'import tractor'
|
||||||
|
UV_PROJECT_ENVIRONMENT=py313 uv run python -c 'import tractor'
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv run` auto-discovers the project and venv,
|
||||||
|
but is slower than a pre-activated venv due to
|
||||||
|
lock-file resolution on each invocation. Prefer
|
||||||
|
activating the venv when possible.
|
||||||
|
|
||||||
|
### Step 3: import + collection checks
|
||||||
|
|
||||||
|
After venv is confirmed, always run these
|
||||||
|
(especially after refactors or module moves):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. package import smoke check
|
# 1. package import smoke check
|
||||||
|
|
@ -137,6 +205,101 @@ python -m pytest tests/ -x -q --co 2>&1 | tail -5
|
||||||
If either fails, fix the import error before running
|
If either fails, fix the import error before running
|
||||||
any actual tests.
|
any actual tests.
|
||||||
|
|
||||||
|
### Step 4: zombie-actor / stale-registry check (MANDATORY)
|
||||||
|
|
||||||
|
The tractor runtime's default registry address is
|
||||||
|
**`127.0.0.1:1616`** (TCP) / `/tmp/registry@1616.sock`
|
||||||
|
(UDS). Whenever any prior test run — especially one
|
||||||
|
using a fork-based backend like `subint_forkserver` —
|
||||||
|
leaks a child actor process, that zombie keeps the
|
||||||
|
registry port bound and **every subsequent test
|
||||||
|
session fails to bind**, often presenting as 50+
|
||||||
|
unrelated failures ("all tests broken"!) across
|
||||||
|
backends.
|
||||||
|
|
||||||
|
**This has to be checked before the first run AND
|
||||||
|
after any cancelled/SIGINT'd run** — signal failures
|
||||||
|
in the middle of a test can leave orphan children.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. TCP registry — any listener on :1616? (primary signal)
|
||||||
|
ss -tlnp 2>/dev/null | grep ':1616' || echo 'TCP :1616 free'
|
||||||
|
|
||||||
|
# 2. leftover actor/forkserver procs — scoped to THIS
|
||||||
|
# repo's python path, so we don't false-flag legit
|
||||||
|
# long-running tractor-using apps (e.g. `piker`,
|
||||||
|
# downstream projects that embed tractor).
|
||||||
|
pgrep -af "$(pwd)/py[0-9]*/bin/python.*_actor_child_main|subint-forkserv" \
|
||||||
|
| grep -v 'grep\|pgrep' \
|
||||||
|
|| echo 'no leaked actor procs from this repo'
|
||||||
|
|
||||||
|
# 3. stale UDS registry sockets
|
||||||
|
ls -la /tmp/registry@*.sock 2>/dev/null \
|
||||||
|
|| echo 'no leaked UDS registry sockets'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interpretation:**
|
||||||
|
|
||||||
|
- **TCP :1616 free AND no stale sockets** → clean,
|
||||||
|
proceed. The actor-procs probe is secondary — false
|
||||||
|
positives are common (piker, any other tractor-
|
||||||
|
embedding app); only cleanup if `:1616` is bound or
|
||||||
|
sockets linger.
|
||||||
|
- **TCP :1616 bound OR stale sockets present** →
|
||||||
|
surface PIDs + cmdlines to the user, offer cleanup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. GRACEFUL FIRST (tractor is structured concurrent — it
|
||||||
|
# catches SIGINT as an OS-cancel in `_trio_main` and
|
||||||
|
# cascades Portal.cancel_actor via IPC to every descendant.
|
||||||
|
# So always try SIGINT first with a bounded timeout; only
|
||||||
|
# escalate to SIGKILL if graceful cleanup doesn't complete).
|
||||||
|
pkill -INT -f "$(pwd)/py[0-9]*/bin/python.*_actor_child_main|subint-forkserv"
|
||||||
|
|
||||||
|
# 2. bounded wait for graceful teardown (usually sub-second).
|
||||||
|
# Loop until the processes exit, or timeout. Keep the
|
||||||
|
# bound tight — hung/abrupt-killed descendants usually
|
||||||
|
# hang forever, so don't wait more than a few seconds.
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
pgrep -f "$(pwd)/py[0-9]*/bin/python.*_actor_child_main|subint-forkserv" >/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. ESCALATE TO SIGKILL only if graceful didn't finish.
|
||||||
|
if pgrep -f "$(pwd)/py[0-9]*/bin/python.*_actor_child_main|subint-forkserv" >/dev/null; then
|
||||||
|
echo 'graceful teardown timed out — escalating to SIGKILL'
|
||||||
|
pkill -9 -f "$(pwd)/py[0-9]*/bin/python.*_actor_child_main|subint-forkserv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. if a test zombie holds :1616 specifically and doesn't
|
||||||
|
# match the above pattern, find its PID the hard way:
|
||||||
|
ss -tlnp 2>/dev/null | grep ':1616' # prints `users:(("<name>",pid=NNNN,...))`
|
||||||
|
# then (same SIGINT-first ladder):
|
||||||
|
# kill -INT <NNNN>; sleep 1; kill -9 <NNNN> 2>/dev/null
|
||||||
|
|
||||||
|
# 5. remove stale UDS sockets
|
||||||
|
rm -f /tmp/registry@*.sock
|
||||||
|
|
||||||
|
# 6. re-verify
|
||||||
|
ss -tlnp 2>/dev/null | grep ':1616' || echo 'TCP :1616 now free'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never ignore stale registry state.** If you see the
|
||||||
|
"all tests failing" pattern — especially
|
||||||
|
`trio.TooSlowError` / connection refused / address in
|
||||||
|
use on many unrelated tests — check registry **before**
|
||||||
|
spelunking into test code. The failure signature will
|
||||||
|
be identical across backends because they're all
|
||||||
|
fighting for the same port.
|
||||||
|
|
||||||
|
**False-positive warning for step 2:** a plain
|
||||||
|
`pgrep -af '_actor_child_main'` will also match
|
||||||
|
legit long-running tractor-embedding apps (e.g.
|
||||||
|
`piker` at `~/repos/piker/py*/bin/python3 -m
|
||||||
|
tractor._child ...`). Always scope to the current
|
||||||
|
repo's python path, or only use step 1 (`:1616`) as
|
||||||
|
the authoritative signal.
|
||||||
|
|
||||||
## 4. Run and report
|
## 4. Run and report
|
||||||
|
|
||||||
- Run the constructed command.
|
- Run the constructed command.
|
||||||
|
|
@ -217,7 +380,48 @@ python -c 'import tractor' && python -m pytest tests/ -x -q --co 2>&1 | tail -3
|
||||||
python -m pytest tests/test_local.py tests/test_rpc.py tests/test_spawning.py tests/discovery/test_registrar.py -x --tb=short --no-header
|
python -m pytest tests/test_local.py tests/test_rpc.py tests/test_spawning.py tests/discovery/test_registrar.py -x --tb=short --no-header
|
||||||
```
|
```
|
||||||
|
|
||||||
### Re-run last failures only:
|
### Inspect last failures (without re-running):
|
||||||
|
|
||||||
|
When the user asks "what failed?", "show failures",
|
||||||
|
or wants to check the last-failed set before
|
||||||
|
re-running — read the pytest cache directly. This
|
||||||
|
is instant and avoids test collection overhead.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -c "
|
||||||
|
import json, pathlib, sys
|
||||||
|
p = pathlib.Path('.pytest_cache/v/cache/lastfailed')
|
||||||
|
if not p.exists():
|
||||||
|
print('No lastfailed cache found.'); sys.exit()
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
# filter to real test node IDs (ignore junk
|
||||||
|
# entries that can accumulate from system paths)
|
||||||
|
tests = sorted(k for k in data if k.startswith('tests/'))
|
||||||
|
if not tests:
|
||||||
|
print('No failures recorded.')
|
||||||
|
else:
|
||||||
|
print(f'{len(tests)} last-failed test(s):')
|
||||||
|
for t in tests:
|
||||||
|
print(f' {t}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why not `--cache-show` or `--co --lf`?**
|
||||||
|
|
||||||
|
- `pytest --cache-show 'cache/lastfailed'` works
|
||||||
|
but dumps raw dict repr including junk entries
|
||||||
|
(stale system paths that leak into the cache).
|
||||||
|
- `pytest --co --lf` actually *collects* tests which
|
||||||
|
triggers import resolution and is slow (~0.5s+).
|
||||||
|
Worse, when cached node IDs don't exactly match
|
||||||
|
current parametrize IDs (e.g. param names changed
|
||||||
|
between runs), pytest falls back to collecting
|
||||||
|
the *entire file*, giving false positives.
|
||||||
|
- Reading the JSON directly is instant, filterable
|
||||||
|
to `tests/`-prefixed entries, and shows exactly
|
||||||
|
what pytest recorded — no interpretation.
|
||||||
|
|
||||||
|
**After inspecting**, re-run the failures:
|
||||||
```sh
|
```sh
|
||||||
python -m pytest --lf -x --tb=short --no-header
|
python -m pytest --lf -x --tb=short --no-header
|
||||||
```
|
```
|
||||||
|
|
@ -247,3 +451,73 @@ by your changes — note them and move on.
|
||||||
**Rule of thumb**: if a test fails with `TooSlowError`,
|
**Rule of thumb**: if a test fails with `TooSlowError`,
|
||||||
`trio.TooSlowError`, or `pexpect.TIMEOUT` and you didn't
|
`trio.TooSlowError`, or `pexpect.TIMEOUT` and you didn't
|
||||||
touch the relevant code path, it's flaky — skip it.
|
touch the relevant code path, it's flaky — skip it.
|
||||||
|
|
||||||
|
## 9. The pytest-capture hang pattern (CHECK THIS FIRST)
|
||||||
|
|
||||||
|
**Symptom:** a tractor test hangs indefinitely under
|
||||||
|
default `pytest` but passes instantly when you add
|
||||||
|
`-s` (`--capture=no`).
|
||||||
|
|
||||||
|
**Cause:** tractor subactors (especially under fork-
|
||||||
|
based backends) inherit pytest's stdout/stderr
|
||||||
|
capture pipes via fds 1,2. Under high-volume error
|
||||||
|
logging (e.g. multi-level cancel cascade, nested
|
||||||
|
`run_in_actor` failures, anything triggering
|
||||||
|
`RemoteActorError` + `ExceptionGroup` traceback
|
||||||
|
spew), the **64KB Linux pipe buffer fills** faster
|
||||||
|
than pytest drains it. Subactor writes block → can't
|
||||||
|
finish exit → parent's `waitpid`/pidfd wait blocks →
|
||||||
|
deadlock cascades up the tree.
|
||||||
|
|
||||||
|
**Pre-existing guards in the tractor harness** that
|
||||||
|
encode this same knowledge — grep these FIRST
|
||||||
|
before spelunking:
|
||||||
|
|
||||||
|
- `tests/conftest.py:258-260` (in the `daemon`
|
||||||
|
fixture): `# XXX: too much logging will lock up
|
||||||
|
the subproc (smh)` — downgrades `trace`/`debug`
|
||||||
|
loglevel to `info` to prevent the hang.
|
||||||
|
- `tests/conftest.py:316`: `# can lock up on the
|
||||||
|
_io.BufferedReader and hang..` — noted on the
|
||||||
|
`proc.stderr.read()` post-SIGINT.
|
||||||
|
|
||||||
|
**Debug recipe (in priority order):**
|
||||||
|
|
||||||
|
1. **Try `-s` first.** If the hang disappears with
|
||||||
|
`pytest -s`, you've confirmed it's capture-pipe
|
||||||
|
fill. Skip spelunking.
|
||||||
|
2. **Lower the loglevel.** Default `--ll=error` on
|
||||||
|
this project; if you've bumped it to `debug` /
|
||||||
|
`info`, try dropping back. Each log level
|
||||||
|
multiplies pipe-pressure under fault cascades.
|
||||||
|
3. **If you MUST use default capture + high log
|
||||||
|
volume**, redirect subactor stdout/stderr in the
|
||||||
|
child prelude (e.g.
|
||||||
|
`tractor.spawn._subint_forkserver._child_target`
|
||||||
|
post-`_close_inherited_fds`) to `/dev/null` or a
|
||||||
|
file.
|
||||||
|
|
||||||
|
**Signature tells you it's THIS bug (vs. a real
|
||||||
|
code hang):**
|
||||||
|
|
||||||
|
- Multi-actor test under fork-based backend
|
||||||
|
(`subint_forkserver`, eventually `trio_proc` too
|
||||||
|
under enough log volume).
|
||||||
|
- Multiple `RemoteActorError` / `ExceptionGroup`
|
||||||
|
tracebacks in the error path.
|
||||||
|
- Test passes with `-s` in the 5-10s range, hangs
|
||||||
|
past pytest-timeout (usually 30+ s) without `-s`.
|
||||||
|
- Subactor processes visible via `pgrep -af
|
||||||
|
subint-forkserv` or similar after the hang —
|
||||||
|
they're alive but blocked on `write()` to an
|
||||||
|
inherited stdout fd.
|
||||||
|
|
||||||
|
**Historical reference:** this deadlock cost a
|
||||||
|
multi-session investigation (4 genuine cascade
|
||||||
|
fixes landed along the way) that only surfaced the
|
||||||
|
capture-pipe issue AFTER the deeper fixes let the
|
||||||
|
tree actually tear down enough to produce pipe-
|
||||||
|
filling log volume. Full post-mortem in
|
||||||
|
`ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`.
|
||||||
|
Lesson codified here so future-me grep-finds the
|
||||||
|
workaround before digging.
|
||||||
|
|
|
||||||
|
|
@ -106,46 +106,55 @@ venv.bak/
|
||||||
# all files under
|
# all files under
|
||||||
.git/
|
.git/
|
||||||
|
|
||||||
# any commit-msg gen tmp files
|
# require very explicit staging for anything we **really**
|
||||||
.claude/skills/commit-msg/msgs/
|
# want put/kept in repo.
|
||||||
.claude/git_commit_msg_LATEST.md
|
notes_to_self/
|
||||||
.claude/*_commit_*.md
|
snippets/
|
||||||
.claude/*_commit*.toml
|
|
||||||
.claude/*_commit*.txt
|
|
||||||
.claude/skills/commit-msg/msgs/*
|
|
||||||
|
|
||||||
.claude/skills/pr-msg/msgs/*
|
# ------- AI shiz -------
|
||||||
# XXX, for rn, so i can telescope this file.
|
# `ai.skillz` symlinks,
|
||||||
!/.claude/skills/pr-msg/pr_msg_LATEST.md
|
# (machine-local, deploy via deploy-skill.sh)
|
||||||
|
|
||||||
# review-skill ephemeral ctx (per-PR, single-use)
|
|
||||||
.claude/review_context.md
|
|
||||||
.claude/review_regression.md
|
|
||||||
|
|
||||||
# per-skill session/conf (machine-local)
|
|
||||||
.claude/skills/*/conf.toml
|
|
||||||
|
|
||||||
# ai.skillz symlinks (machine-local, deploy via deploy-skill.sh)
|
|
||||||
.claude/skills/py-codestyle
|
.claude/skills/py-codestyle
|
||||||
.claude/skills/code-review-changes
|
|
||||||
.claude/skills/close-wkt
|
.claude/skills/close-wkt
|
||||||
.claude/skills/open-wkt
|
|
||||||
.claude/skills/plan-io
|
.claude/skills/plan-io
|
||||||
.claude/skills/prompt-io
|
.claude/skills/prompt-io
|
||||||
.claude/skills/resolve-conflicts
|
.claude/skills/resolve-conflicts
|
||||||
.claude/skills/inter-skill-review
|
.claude/skills/inter-skill-review
|
||||||
.claude/skills/yt-url-lookup
|
|
||||||
|
|
||||||
# hybrid skills — symlinked SKILL.md + references
|
# /open-wkt specifics
|
||||||
.claude/skills/commit-msg/SKILL.md
|
.claude/skills/open-wkt
|
||||||
.claude/skills/pr-msg/SKILL.md
|
.claude/wkts/
|
||||||
.claude/skills/pr-msg/references
|
claude_wkts
|
||||||
|
|
||||||
|
# /code-review-changes specifics
|
||||||
|
.claude/skills/code-review-changes
|
||||||
|
# review-skill ephemeral ctx (per-PR, single-use)
|
||||||
|
.claude/review_context.md
|
||||||
|
.claude/review_regression.md
|
||||||
|
|
||||||
|
# /pr-msg specifics
|
||||||
|
.claude/skills/pr-msg/*
|
||||||
|
# repo-specific
|
||||||
|
!.claude/skills/pr-msg/format-reference.md
|
||||||
|
# XXX, so u can nvim-telescope this file.
|
||||||
|
# !.claude/skills/pr-msg/pr_msg_LATEST.md
|
||||||
|
|
||||||
|
# /commit-msg specifics
|
||||||
|
# - any commit-msg gen tmp files
|
||||||
|
.claude/*_commit_*.md
|
||||||
|
.claude/*_commit*.txt
|
||||||
|
.claude/skills/commit-msg/*
|
||||||
|
!.claude/skills/commit-msg/style-duie-reference.md
|
||||||
|
|
||||||
|
# use prompt-io instead?
|
||||||
|
.claude/plans
|
||||||
|
|
||||||
# nix develop --profile .nixdev
|
# nix develop --profile .nixdev
|
||||||
.nixdev*
|
.nixdev*
|
||||||
|
|
||||||
# :Obsession .
|
# :Obsession .
|
||||||
Session.vim
|
Session.vim
|
||||||
|
|
||||||
# `gish` local `.md`-files
|
# `gish` local `.md`-files
|
||||||
# TODO? better all around automation!
|
# TODO? better all around automation!
|
||||||
# -[ ] it'd be handy to also commit and sync with wtv git service?
|
# -[ ] it'd be handy to also commit and sync with wtv git service?
|
||||||
|
|
@ -159,7 +168,3 @@ gh/
|
||||||
|
|
||||||
# LLM conversations that should remain private
|
# LLM conversations that should remain private
|
||||||
docs/conversations/
|
docs/conversations/
|
||||||
|
|
||||||
# Claude worktrees
|
|
||||||
.claude/wkts/
|
|
||||||
claude_wkts
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# `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.
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
# `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.
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
#!/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._subint_forkserver import (
|
||||||
|
fork_from_worker_thread,
|
||||||
|
run_subint_in_worker_thread,
|
||||||
|
wait_child,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# 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())
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
# `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.
|
||||||
|
|
@ -0,0 +1,849 @@
|
||||||
|
# `subint_forkserver` backend: `test_cancellation.py` multi-level cancel cascade hang
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Revisit `subint_forkserver` thread-cache constraints once msgspec PEP 684 support lands
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
# `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.)
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
---
|
||||||
|
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).
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
---
|
||||||
|
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`.
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-7[1m]
|
||||||
|
service: claude
|
||||||
|
session: subints-phase-b-hardening-and-fork-block
|
||||||
|
timestamp: 2026-04-22T20:07:23Z
|
||||||
|
git_ref: 797f57c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260422T200723Z_797f57c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Session-spanning work on the Phase B `subint` spawn-backend.
|
||||||
|
Three distinct sub-phases in one log:
|
||||||
|
|
||||||
|
1. **Py3.13 gate tightening** — diagnose a reproducible hang
|
||||||
|
of subint spawn flow under py3.13 (works on py3.14), trace
|
||||||
|
to a private `_interpreters` module vintage issue, tighten
|
||||||
|
our feature gate from "`_interpreters` present" to "public
|
||||||
|
`concurrent.interpreters` present" (i.e. py3.14+).
|
||||||
|
2. **Test-harness hardening** — add `pytest-timeout` dep, put
|
||||||
|
`@pytest.mark.timeout(30, method='thread')` on the
|
||||||
|
three known-hanging subint tests cataloged in
|
||||||
|
`ai/conc-anal/subint_sigint_starvation_issue.md`. Separately,
|
||||||
|
code-review the user's in-flight `skipon_spawn_backend`
|
||||||
|
marker implementation; find four bugs; refactor to use
|
||||||
|
`item.iter_markers()`.
|
||||||
|
3. **`subint_fork` prototype → CPython-block finding** — draft
|
||||||
|
a WIP `subint_fork_proc` backend using a sub-interpreter as
|
||||||
|
a launchpad for `os.fork()` (to sidestep trio#1614). User
|
||||||
|
tests on py3.14, hits
|
||||||
|
`Fatal Python error: _PyInterpreterState_DeleteExceptMain:
|
||||||
|
not main interpreter`. Walk CPython sources (local clone at
|
||||||
|
`~/repos/cpython/`) to pinpoint the refusal
|
||||||
|
(`Modules/posixmodule.c:728` → `Python/pystate.c:1040`).
|
||||||
|
Revert implementation to a `NotImplementedError` stub in a
|
||||||
|
new `_subint_fork.py` submodule, document the finding in a
|
||||||
|
third `conc-anal/` doc with an upstream-report draft for
|
||||||
|
the CPython issue tracker. Finally, discuss user's proposed
|
||||||
|
workaround architecture (main-interp worker-thread
|
||||||
|
forkserver) and draft a standalone smoke-test script for
|
||||||
|
feasibility validation.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
All three sub-phases landed concrete artifacts:
|
||||||
|
|
||||||
|
**Sub-phase 1** — `_subint.py` + `_spawn.py` gates + error
|
||||||
|
messages updated to require py3.14+ via the public
|
||||||
|
`concurrent.interpreters` module presence check. Module
|
||||||
|
docstring revised to explain the empirical reason
|
||||||
|
(py3.13's private `_interpreters` vintage wedges under
|
||||||
|
multi-trio-task usage even though minimal standalone
|
||||||
|
reproducers work fine there). Test-module
|
||||||
|
`pytest.importorskip` likewise switched.
|
||||||
|
|
||||||
|
**Sub-phase 2** — `pytest-timeout>=2.3` added to `testing`
|
||||||
|
dep group. `@pytest.mark.timeout(30, method='thread')`
|
||||||
|
applied on:
|
||||||
|
- `tests/discovery/test_registrar.py::test_stale_entry_is_deleted`
|
||||||
|
- `tests/test_cancellation.py::test_cancel_while_childs_child_in_sync_sleep`
|
||||||
|
- `tests/test_cancellation.py::test_multierror_fast_nursery`
|
||||||
|
- `tests/test_subint_cancellation.py::test_subint_non_checkpointing_child`
|
||||||
|
|
||||||
|
`method='thread'` documented inline as load-bearing — the
|
||||||
|
GIL-starvation path that drops `SIGINT` would equally drop
|
||||||
|
`SIGALRM`, so only a watchdog-thread timeout can reliably
|
||||||
|
escape.
|
||||||
|
|
||||||
|
`skipon_spawn_backend` plugin refactored into a single
|
||||||
|
`iter_markers`-driven loop in `pytest_collection_modifyitems`
|
||||||
|
(~30 LOC replacing ~30 LOC of nested conditionals). Four
|
||||||
|
bugs dissolved: wrong `.get()` key, module-level `pytestmark`
|
||||||
|
suppressing per-test marks, unhandled `pytestmark = [list]`
|
||||||
|
form, `pytest.Makr` typo. Marker help text updated to
|
||||||
|
document the variadic backend-list + `reason=` kwarg
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Sub-phase 3** — Prototype drafted (then reverted):
|
||||||
|
|
||||||
|
- `tractor/spawn/_subint_fork.py` — new dedicated submodule
|
||||||
|
housing the `subint_fork_proc` stub. Module docstring +
|
||||||
|
fn docstring explain the attempt, the CPython-level
|
||||||
|
block, and the reason for keeping the stub in-tree
|
||||||
|
(documentation of the attempt + starting point if CPython
|
||||||
|
ever lifts the restriction).
|
||||||
|
- `tractor/spawn/_spawn.py` — `'subint_fork'` registered as a
|
||||||
|
`SpawnMethodKey` literal + in `_methods`, so
|
||||||
|
`--spawn-backend=subint_fork` routes to a clean
|
||||||
|
`NotImplementedError` pointing at the analysis doc rather
|
||||||
|
than an "invalid backend" error.
|
||||||
|
- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md` —
|
||||||
|
third sibling conc-anal doc. Full annotated CPython
|
||||||
|
source walkthrough from user-visible
|
||||||
|
`Fatal Python error` → `Modules/posixmodule.c:728
|
||||||
|
PyOS_AfterFork_Child()` → `Python/pystate.c:1040
|
||||||
|
_PyInterpreterState_DeleteExceptMain()` gate. Includes a
|
||||||
|
copy-paste-ready upstream-report draft for the CPython
|
||||||
|
issue tracker with a two-tier ask (ideally "make it work",
|
||||||
|
minimally "cleaner error than `Fatal Python error`
|
||||||
|
aborting the child").
|
||||||
|
- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py` —
|
||||||
|
standalone zero-tractor-import CPython-level smoke test
|
||||||
|
for the user's proposed workaround architecture
|
||||||
|
(forkserver on a main-interp worker thread). Four
|
||||||
|
argparse-driven scenarios: `control_subint_thread_fork`
|
||||||
|
(reproduces the known-broken case as a test-harness
|
||||||
|
sanity), `main_thread_fork` (baseline), `worker_thread_fork`
|
||||||
|
(architectural assertion), `full_architecture`
|
||||||
|
(end-to-end trio-in-subint in forked child). User will
|
||||||
|
run on py3.14 next.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
See `git log 26fb820..HEAD --stat` for the canonical list.
|
||||||
|
New files this session:
|
||||||
|
- `tractor/spawn/_subint_fork.py`
|
||||||
|
- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
||||||
|
- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`
|
||||||
|
|
||||||
|
Modified (diff pointers in raw log):
|
||||||
|
- `tractor/spawn/_subint.py` (py3.14 gate)
|
||||||
|
- `tractor/spawn/_spawn.py` (`subint_fork` registration)
|
||||||
|
- `tractor/_testing/pytest.py` (`skipon_spawn_backend` refactor)
|
||||||
|
- `pyproject.toml` (`pytest-timeout` dep)
|
||||||
|
- `tests/discovery/test_registrar.py`,
|
||||||
|
`tests/test_cancellation.py`,
|
||||||
|
`tests/test_subint_cancellation.py` (timeout marks,
|
||||||
|
cross-refs to conc-anal docs)
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
Several back-and-forth iterations with user-driven
|
||||||
|
adjustments during the session:
|
||||||
|
|
||||||
|
- User corrected my initial mis-classification of
|
||||||
|
`test_cancel_while_childs_child_in_sync_sleep[subint-False]`
|
||||||
|
as Ctrl-C-able — second strace showed `EAGAIN`, putting
|
||||||
|
it squarely in class A (GIL-starvation). Re-analysis
|
||||||
|
preserved in the raw log.
|
||||||
|
- User independently fixed the `.get(reason)` → `.get('reason', reason)`
|
||||||
|
bug in the marker plugin before my review; preserved their
|
||||||
|
fix.
|
||||||
|
- User suggested moving the `subint_fork_proc` stub from
|
||||||
|
the bottom of `_subint.py` into its own
|
||||||
|
`_subint_fork.py` submodule — applied.
|
||||||
|
- User asked to keep the forkserver-architecture
|
||||||
|
discussion as background for the smoke-test rather than
|
||||||
|
committing to a tractor-side refactor until the smoke
|
||||||
|
test validates the CPython-level assumptions.
|
||||||
|
|
||||||
|
Commit messages in this range (b025c982 … 797f57c) were
|
||||||
|
drafted via `/commit-msg` + `rewrap.py --width 67`; user
|
||||||
|
landed them with the usual review.
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
---
|
||||||
|
model: claude-opus-4-7[1m]
|
||||||
|
service: claude
|
||||||
|
timestamp: 2026-04-22T20:07:23Z
|
||||||
|
git_ref: 797f57c
|
||||||
|
diff_cmd: git log 26fb820..HEAD # all session commits since the destroy-race fix log
|
||||||
|
---
|
||||||
|
|
||||||
|
Session-spanning conversation covering the Phase B hardening
|
||||||
|
of the `subint` spawn-backend and an investigation into a
|
||||||
|
proposed `subint_fork` follow-up which turned out to be
|
||||||
|
blocked at the CPython level. This log is a narrative capture
|
||||||
|
of the substantive turns (not every message) and references
|
||||||
|
the concrete code + docs the session produced. Per diff-ref
|
||||||
|
mode the actual code diffs are pointed at via `git log` on
|
||||||
|
each ref rather than duplicated inline.
|
||||||
|
|
||||||
|
## Narrative of the substantive turns
|
||||||
|
|
||||||
|
### Py3.13 hang / gate tightening
|
||||||
|
|
||||||
|
Diagnosed a reproducible hang of the `subint` backend under
|
||||||
|
py3.13 (test_spawning tests wedge after root-actor bringup).
|
||||||
|
Root cause: py3.13's vintage of the private `_interpreters` C
|
||||||
|
module has a latent thread/subint-interaction issue that
|
||||||
|
`_interpreters.exec()` silently fails to progress under
|
||||||
|
tractor's multi-trio usage pattern — even though a minimal
|
||||||
|
standalone `threading.Thread` + `_interpreters.exec()`
|
||||||
|
reproducer works fine on the same Python. Empirically
|
||||||
|
py3.14 fixes it.
|
||||||
|
|
||||||
|
Fix (from this session): tighten the `_has_subints` gate in
|
||||||
|
`tractor.spawn._subint` from "private module importable" to
|
||||||
|
"public `concurrent.interpreters` present" — which is 3.14+
|
||||||
|
only. This leaves `subint_proc()` unchanged in behavior (we
|
||||||
|
still call the *private* `_interpreters.create('legacy')`
|
||||||
|
etc. under the hood) but refuses to engage on 3.13.
|
||||||
|
|
||||||
|
Also tightened the matching gate in
|
||||||
|
`tractor.spawn._spawn.try_set_start_method('subint')` and
|
||||||
|
rev'd the corresponding error messages from "3.13+" to
|
||||||
|
"3.14+" with a sentence explaining why. Test-module
|
||||||
|
`pytest.importorskip` switched from `_interpreters` →
|
||||||
|
`concurrent.interpreters` to match.
|
||||||
|
|
||||||
|
### `pytest-timeout` dep + `skipon_spawn_backend` marker plumbing
|
||||||
|
|
||||||
|
Added `pytest-timeout>=2.3` to the `testing` dep group with
|
||||||
|
an inline comment pointing at the `ai/conc-anal/*.md` docs.
|
||||||
|
Applied `@pytest.mark.timeout(30, method='thread')` (the
|
||||||
|
`method='thread'` is load-bearing — `signal`-method
|
||||||
|
`SIGALRM` suffers the same GIL-starvation path that drops
|
||||||
|
`SIGINT` in the class-A hang pattern) to the three known-
|
||||||
|
hanging subint tests cataloged in
|
||||||
|
`subint_sigint_starvation_issue.md`.
|
||||||
|
|
||||||
|
Separately code-reviewed the user's newly-staged
|
||||||
|
`skipon_spawn_backend` pytest marker implementation in
|
||||||
|
`tractor/_testing/pytest.py`. Found four bugs:
|
||||||
|
|
||||||
|
1. `modmark.kwargs.get(reason)` called `.get()` with the
|
||||||
|
*variable* `reason` as the dict key instead of the string
|
||||||
|
`'reason'` — user-supplied `reason=` was never picked up.
|
||||||
|
(User had already fixed this locally via `.get('reason',
|
||||||
|
reason)` by the time my review happened — preserved that
|
||||||
|
fix.)
|
||||||
|
2. The module-level `pytestmark` branch suppressed per-test
|
||||||
|
marker handling (the `else:` was an `else:` rather than
|
||||||
|
independent iteration).
|
||||||
|
3. `mod_pytestmark.mark` assumed a single
|
||||||
|
`MarkDecorator` — broke on the valid-pytest `pytestmark =
|
||||||
|
[mark, mark]` list form.
|
||||||
|
4. Typo: `pytest.Makr` → `pytest.Mark`.
|
||||||
|
|
||||||
|
Refactored the hook to use `item.iter_markers(name=...)`
|
||||||
|
which walks function + class + module scopes uniformly and
|
||||||
|
handles both `pytestmark` forms natively. ~30 LOC replaced
|
||||||
|
the original ~30 LOC of nested conditionals, all four bugs
|
||||||
|
dissolved. Also updated the marker help string to reflect
|
||||||
|
the variadic `*start_methods` + `reason=` surface.
|
||||||
|
|
||||||
|
### `subint_fork_proc` prototype attempt
|
||||||
|
|
||||||
|
User's hypothesis: the known trio+`fork()` issues
|
||||||
|
(python-trio/trio#1614) could be sidestepped by using a
|
||||||
|
sub-interpreter purely as a launchpad — `os.fork()` from a
|
||||||
|
subint that has never imported trio → child is in a
|
||||||
|
trio-free context. In the child `execv()` back into
|
||||||
|
`python -m tractor._child` and the downstream handshake
|
||||||
|
matches `trio_proc()` identically.
|
||||||
|
|
||||||
|
Drafted the prototype at `tractor/spawn/_subint.py`'s bottom
|
||||||
|
(originally — later moved to its own submod, see below):
|
||||||
|
launchpad-subint creation, bootstrap code-string with
|
||||||
|
`os.fork()` + `execv()`, driver-thread orchestration,
|
||||||
|
parent-side `ipc_server.wait_for_peer()` dance. Registered
|
||||||
|
`'subint_fork'` as a new `SpawnMethodKey` literal, added
|
||||||
|
`case 'subint' | 'subint_fork':` feature-gate arm in
|
||||||
|
`try_set_start_method()`, added entry in `_methods` dict.
|
||||||
|
|
||||||
|
### CPython-level block discovered
|
||||||
|
|
||||||
|
User tested on py3.14 and saw:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fatal Python error: _PyInterpreterState_DeleteExceptMain: not main interpreter
|
||||||
|
Python runtime state: initialized
|
||||||
|
|
||||||
|
Current thread 0x00007f6b71a456c0 [subint-fork-lau] (most recent call first):
|
||||||
|
File "<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")
|
||||||
|
```
|
||||||
|
|
@ -24,7 +24,7 @@ Part of this work should include,
|
||||||
is happening under the hood with how cpython implements subints.
|
is happening under the hood with how cpython implements subints.
|
||||||
|
|
||||||
* default configuration should encourage state isolation as with
|
* default configuration should encourage state isolation as with
|
||||||
with subprocs, but explicit public escape hatches to enable rigorously
|
subprocs, but explicit public escape hatches to enable rigorously
|
||||||
managed shm channels for high performance apps.
|
managed shm channels for high performance apps.
|
||||||
|
|
||||||
- all tests should be (able to be) parameterized to use the new
|
- all tests should be (able to be) parameterized to use the new
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ name = "tractor"
|
||||||
version = "0.1.0a6dev0"
|
version = "0.1.0a6dev0"
|
||||||
description = 'structured concurrent `trio`-"actors"'
|
description = 'structured concurrent `trio`-"actors"'
|
||||||
authors = [{ name = "Tyler Goodlet", email = "goodboy_foss@protonmail.com" }]
|
authors = [{ name = "Tyler Goodlet", email = "goodboy_foss@protonmail.com" }]
|
||||||
requires-python = ">=3.12, <3.14"
|
requires-python = ">=3.13, <3.15"
|
||||||
readme = "docs/README.rst"
|
readme = "docs/README.rst"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
keywords = [
|
keywords = [
|
||||||
|
|
@ -31,6 +31,7 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Topic :: System :: Distributed Computing",
|
"Topic :: System :: Distributed Computing",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
@ -43,11 +44,12 @@ dependencies = [
|
||||||
"tricycle>=0.4.1,<0.5",
|
"tricycle>=0.4.1,<0.5",
|
||||||
"wrapt>=1.16.0,<2",
|
"wrapt>=1.16.0,<2",
|
||||||
"colorlog>=6.8.2,<7",
|
"colorlog>=6.8.2,<7",
|
||||||
|
|
||||||
# built-in multi-actor `pdb` REPL
|
# built-in multi-actor `pdb` REPL
|
||||||
"pdbp>=1.8.2,<2", # windows only (from `pdbp`)
|
"pdbp>=1.8.2,<2", # windows only (from `pdbp`)
|
||||||
|
|
||||||
# typed IPC msging
|
# typed IPC msging
|
||||||
"msgspec>=0.21.0",
|
"msgspec>=0.20.0",
|
||||||
"cffi>=1.17.1",
|
|
||||||
"bidict>=0.23.1",
|
"bidict>=0.23.1",
|
||||||
"multiaddr>=0.2.0",
|
"multiaddr>=0.2.0",
|
||||||
"platformdirs>=4.4.0",
|
"platformdirs>=4.4.0",
|
||||||
|
|
@ -63,10 +65,13 @@ dev = [
|
||||||
]
|
]
|
||||||
devx = [
|
devx = [
|
||||||
# `tractor.devx` tooling
|
# `tractor.devx` tooling
|
||||||
"greenback>=1.2.1,<2",
|
|
||||||
"stackscope>=0.2.2,<0.3",
|
"stackscope>=0.2.2,<0.3",
|
||||||
# ^ requires this?
|
# ^ requires this?
|
||||||
"typing-extensions>=4.14.1",
|
"typing-extensions>=4.14.1",
|
||||||
|
# {include-group = 'sync_pause'}, # XXX, no 3.14 yet!
|
||||||
|
]
|
||||||
|
sync_pause = [
|
||||||
|
"greenback>=1.2.1,<2", # TODO? 3.14 greenlet on nix?
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
# test suite
|
# test suite
|
||||||
|
|
@ -74,16 +79,29 @@ testing = [
|
||||||
# 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
|
||||||
"pytest>=8.3.5",
|
"pytest>=8.3.5",
|
||||||
"pexpect>=4.9.0,<5",
|
"pexpect>=4.9.0,<5",
|
||||||
|
# per-test wall-clock bound (used via
|
||||||
|
# `@pytest.mark.timeout(..., method='thread')` on the
|
||||||
|
# known-hanging `subint`-backend audit tests; see
|
||||||
|
# `ai/conc-anal/subint_*_issue.md`).
|
||||||
|
"pytest-timeout>=2.3",
|
||||||
]
|
]
|
||||||
repl = [
|
repl = [
|
||||||
"pyperclip>=1.9.0",
|
"pyperclip>=1.9.0",
|
||||||
"prompt-toolkit>=3.0.50",
|
"prompt-toolkit>=3.0.50",
|
||||||
"xonsh>=0.22.2",
|
"xonsh>=0.22.8",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"ruff>=0.9.6"
|
"ruff>=0.9.6"
|
||||||
]
|
]
|
||||||
|
# XXX, used for linux-only hi perf eventfd+shm channels
|
||||||
|
# now mostly moved over to `hotbaud`.
|
||||||
|
eventfd = [
|
||||||
|
"cffi>=1.17.1",
|
||||||
|
]
|
||||||
|
subints = [
|
||||||
|
"msgspec>=0.21.0",
|
||||||
|
]
|
||||||
# TODO, add these with sane versions; were originally in
|
# TODO, add these with sane versions; were originally in
|
||||||
# `requirements-docs.txt`..
|
# `requirements-docs.txt`..
|
||||||
# docs = [
|
# docs = [
|
||||||
|
|
@ -92,10 +110,26 @@ lint = [
|
||||||
# ]
|
# ]
|
||||||
# ------ dependency-groups ------
|
# ------ dependency-groups ------
|
||||||
|
|
||||||
|
[tool.uv.dependency-groups]
|
||||||
|
# for subints, we require 3.14+ due to 2 issues,
|
||||||
|
# - hanging behaviour for various multi-task teardown cases (see
|
||||||
|
# "Availability" section in the `tractor.spawn._subints` doc string).
|
||||||
|
# - `msgspec` support which is oustanding per PEP 684 upstream tracker:
|
||||||
|
# https://github.com/jcrist/msgspec/issues/563
|
||||||
|
#
|
||||||
|
# https://docs.astral.sh/uv/concepts/projects/dependencies/#group-requires-python
|
||||||
|
subints = {requires-python = ">=3.14"}
|
||||||
|
eventfd = {requires-python = ">=3.13, <3.14"}
|
||||||
|
sync_pause = {requires-python = ">=3.13, <3.14"}
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# XXX NOTE, only for @goodboy's hacking on `pprint(sort_dicts=False)`
|
# XXX NOTE, only for @goodboy's hacking on `pprint(sort_dicts=False)`
|
||||||
# for the `pp` alias..
|
# for the `pp` alias..
|
||||||
|
# ------ gh upstream ------
|
||||||
|
# xonsh = { git = 'https://github.com/anki-code/xonsh.git', branch = 'prompt_next_suggestion' }
|
||||||
|
# ^ https://github.com/xonsh/xonsh/pull/6048
|
||||||
|
# xonsh = { git = 'https://github.com/xonsh/xonsh.git', branch = 'main' }
|
||||||
|
xonsh = { path = "../xonsh", editable = true }
|
||||||
|
|
||||||
# [tool.uv.sources.pdbp]
|
# [tool.uv.sources.pdbp]
|
||||||
# XXX, in case we need to tmp patch again.
|
# XXX, in case we need to tmp patch again.
|
||||||
|
|
@ -164,6 +198,7 @@ all_bullets = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = '6.0'
|
minversion = '6.0'
|
||||||
|
timeout = 200 # per-test hard limit
|
||||||
# https://docs.pytest.org/en/stable/reference/reference.html#configuration-options
|
# https://docs.pytest.org/en/stable/reference/reference.html#configuration-options
|
||||||
testpaths = [
|
testpaths = [
|
||||||
'tests'
|
'tests'
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,9 @@ def pytest_addoption(
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session', autouse=True)
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
def loglevel(request) -> str:
|
def loglevel(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
) -> str:
|
||||||
import tractor
|
import tractor
|
||||||
orig = tractor.log._default_loglevel
|
orig = tractor.log._default_loglevel
|
||||||
level = tractor.log._default_loglevel = request.config.option.loglevel
|
level = tractor.log._default_loglevel = request.config.option.loglevel
|
||||||
|
|
@ -156,7 +158,7 @@ def loglevel(request) -> str:
|
||||||
|
|
||||||
@pytest.fixture(scope='function')
|
@pytest.fixture(scope='function')
|
||||||
def test_log(
|
def test_log(
|
||||||
request,
|
request: pytest.FixtureRequest,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
) -> tractor.log.StackLevelAdapter:
|
) -> tractor.log.StackLevelAdapter:
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,12 @@ def spawn(
|
||||||
ids='ctl-c={}'.format,
|
ids='ctl-c={}'.format,
|
||||||
)
|
)
|
||||||
def ctlc(
|
def ctlc(
|
||||||
request,
|
request: pytest.FixtureRequest,
|
||||||
ci_env: bool,
|
ci_env: bool,
|
||||||
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
||||||
use_ctlc = request.param
|
use_ctlc: bool = request.param
|
||||||
|
|
||||||
node = request.node
|
node = request.node
|
||||||
markers = node.own_markers
|
markers = node.own_markers
|
||||||
for mark in markers:
|
for mark in markers:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import psutil
|
||||||
import pytest
|
import pytest
|
||||||
import subprocess
|
import subprocess
|
||||||
import tractor
|
import tractor
|
||||||
|
from tractor.devx import dump_on_hang
|
||||||
from tractor.trionics import collapse_eg
|
from tractor.trionics import collapse_eg
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
from tractor.discovery._addr import wrap_address
|
from tractor.discovery._addr import wrap_address
|
||||||
|
|
@ -131,6 +132,10 @@ async def say_hello_use_wait(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.timeout(
|
||||||
|
3,
|
||||||
|
method='thread',
|
||||||
|
)
|
||||||
@tractor_test
|
@tractor_test
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'func',
|
'func',
|
||||||
|
|
@ -515,7 +520,31 @@ async def kill_transport(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Wall-clock bound via `pytest-timeout` (`method='thread'`).
|
||||||
|
# Under `--spawn-backend=subint` this test can wedge in an
|
||||||
|
# un-Ctrl-C-able state (abandoned-subint + shared-GIL
|
||||||
|
# starvation → signal-wakeup-fd pipe fills → SIGINT silently
|
||||||
|
# dropped; see `ai/conc-anal/subint_sigint_starvation_issue.md`).
|
||||||
|
# `method='thread'` is specifically required because `signal`-
|
||||||
|
# method SIGALRM suffers the same GIL-starvation path and
|
||||||
|
# wouldn't fire the Python-level handler.
|
||||||
|
# At timeout the plugin hard-kills the pytest process — that's
|
||||||
|
# the intended behavior here; the alternative is an unattended
|
||||||
|
# suite run that never returns.
|
||||||
|
@pytest.mark.timeout(
|
||||||
|
3, # NOTE should be a 2.1s happy path.
|
||||||
|
method='thread',
|
||||||
|
)
|
||||||
|
@pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint',
|
||||||
|
reason=(
|
||||||
|
'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
'See oustanding issue(s)\n'
|
||||||
|
# TODO, put issue link!
|
||||||
|
)
|
||||||
|
)
|
||||||
# @pytest.mark.parametrize('use_signal', [False, True])
|
# @pytest.mark.parametrize('use_signal', [False, True])
|
||||||
|
#
|
||||||
def test_stale_entry_is_deleted(
|
def test_stale_entry_is_deleted(
|
||||||
debug_mode: bool,
|
debug_mode: bool,
|
||||||
daemon: subprocess.Popen,
|
daemon: subprocess.Popen,
|
||||||
|
|
@ -562,4 +591,53 @@ def test_stale_entry_is_deleted(
|
||||||
await ptl.cancel_actor()
|
await ptl.cancel_actor()
|
||||||
await an.cancel()
|
await an.cancel()
|
||||||
|
|
||||||
trio.run(main)
|
# TODO, remove once the `[subint]` variant no longer hangs.
|
||||||
|
#
|
||||||
|
# Status (as of Phase B hard-kill landing):
|
||||||
|
#
|
||||||
|
# - `[trio]`/`[mp_*]` variants: completes normally; `dump_on_hang`
|
||||||
|
# is a no-op safety net here.
|
||||||
|
#
|
||||||
|
# - `[subint]` variant: hangs indefinitely AND is un-Ctrl-C-able.
|
||||||
|
# `strace -p <pytest_pid>` while in the hang reveals a silently-
|
||||||
|
# dropped SIGINT — the C signal handler tries to write the
|
||||||
|
# signum byte to Python's signal-wakeup fd and gets `EAGAIN`,
|
||||||
|
# meaning the pipe is full (nobody's draining it).
|
||||||
|
#
|
||||||
|
# Root-cause chain: our hard-kill in `spawn._subint` abandoned
|
||||||
|
# the driver OS-thread (which is `daemon=True`) after the soft-
|
||||||
|
# kill timeout, but the *sub-interpreter* inside that thread is
|
||||||
|
# still running `trio.run()` — `_interpreters.destroy()` can't
|
||||||
|
# force-stop a running subint (raises `InterpreterError`), and
|
||||||
|
# legacy-config subints share the main GIL. The abandoned subint
|
||||||
|
# starves the parent's trio event loop from iterating often
|
||||||
|
# enough to drain its wakeup pipe → SIGINT silently drops.
|
||||||
|
#
|
||||||
|
# This is structurally a CPython-level limitation: there's no
|
||||||
|
# public force-destroy primitive for a running subint. We
|
||||||
|
# escape on the harness side via a SIGINT-loop in the `daemon`
|
||||||
|
# fixture teardown (killing the bg registrar subproc closes its
|
||||||
|
# end of the IPC, which eventually unblocks a recv in main trio,
|
||||||
|
# which lets the loop drain the wakeup pipe). Long-term fix path:
|
||||||
|
# msgspec PEP 684 support (jcrist/msgspec#563) → isolated-mode
|
||||||
|
# subints with per-interp GIL.
|
||||||
|
#
|
||||||
|
# Full analysis:
|
||||||
|
# `ai/conc-anal/subint_sigint_starvation_issue.md`
|
||||||
|
#
|
||||||
|
# See also the *sibling* hang class documented in
|
||||||
|
# `ai/conc-anal/subint_cancel_delivery_hang_issue.md` — same
|
||||||
|
# subint backend, different root cause (Ctrl-C-able hang, main
|
||||||
|
# trio loop iterating fine; ours to fix, not CPython's).
|
||||||
|
# Reproduced by `tests/test_subint_cancellation.py
|
||||||
|
# ::test_subint_non_checkpointing_child`.
|
||||||
|
#
|
||||||
|
# Kept here (and not behind a `pytestmark.skip`) so we can still
|
||||||
|
# inspect the dump file if the hang ever returns after a refactor.
|
||||||
|
# `pytest`'s stderr capture eats `faulthandler` output otherwise,
|
||||||
|
# so we route `dump_on_hang` to a file.
|
||||||
|
with dump_on_hang(
|
||||||
|
seconds=20,
|
||||||
|
path=f'/tmp/test_stale_entry_is_deleted_{start_method}.dump',
|
||||||
|
):
|
||||||
|
trio.run(main)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
'''
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,604 @@
|
||||||
|
'''
|
||||||
|
Integration exercises for the `tractor.spawn._subint_forkserver`
|
||||||
|
submodule at three tiers:
|
||||||
|
|
||||||
|
1. the low-level primitives
|
||||||
|
(`fork_from_worker_thread()` +
|
||||||
|
`run_subint_in_worker_thread()`) driven from inside a real
|
||||||
|
`trio.run()` in the parent process,
|
||||||
|
|
||||||
|
2. the full `subint_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
|
||||||
|
`subint_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('subint_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._subint_forkserver import ( # noqa: E402
|
||||||
|
fork_from_worker_thread,
|
||||||
|
run_subint_in_worker_thread,
|
||||||
|
wait_child,
|
||||||
|
)
|
||||||
|
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/subint_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 `subint_forkserver` spawn backend
|
||||||
|
would rest on.
|
||||||
|
|
||||||
|
'''
|
||||||
|
deadline: float = 15.0
|
||||||
|
with dump_on_hang(
|
||||||
|
seconds=deadline,
|
||||||
|
path='/tmp/subint_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 `subint_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
|
||||||
|
`subint_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
|
||||||
|
`'subint_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('subint_forkserver')
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_spawn_mod._spawn_method = prev_method
|
||||||
|
_spawn_mod._ctx = prev_ctx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.timeout(60, method='thread')
|
||||||
|
def test_subint_forkserver_spawn_basic(
|
||||||
|
reg_addr: tuple[str, int | str],
|
||||||
|
forkserver_spawn_method,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Happy-path: spawn ONE subactor via the
|
||||||
|
`subint_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
|
||||||
|
`subint_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/subint_forkserver_spawn_basic.dump',
|
||||||
|
):
|
||||||
|
trio.run(
|
||||||
|
partial(
|
||||||
|
_happy_path_forkserver,
|
||||||
|
reg_addr,
|
||||||
|
deadline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# tier-4 DRAFT: orphaned-subactor SIGINT survivability
|
||||||
|
#
|
||||||
|
# Motivating question: with `subint_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`,
|
||||||
|
# `subint_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
|
||||||
|
|
||||||
|
|
||||||
|
# Regressed back to xfail: previously passed after the
|
||||||
|
# fork-child FD-hygiene fix in `_close_inherited_fds()`,
|
||||||
|
# but the recent `wait_for_no_more_peers(move_on_after=3.0)`
|
||||||
|
# bound in `async_main`'s teardown added up to 3s to the
|
||||||
|
# orphan subactor's exit timeline, pushing it past the
|
||||||
|
# test's 10s poll window. Real fix requires making the
|
||||||
|
# bounded wait faster when the actor is orphaned, or
|
||||||
|
# increasing the test's poll window. See tracker doc
|
||||||
|
# `ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md`.
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason=(
|
||||||
|
'Regressed to xfail after `wait_for_no_more_peers` '
|
||||||
|
'bound added ~3s teardown latency. Needs either '
|
||||||
|
'faster orphan-side teardown or 15s test poll window.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@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
|
||||||
|
`subint_forkserver` backend.
|
||||||
|
|
||||||
|
Sequence:
|
||||||
|
1. Spawn a harness subprocess that brings up a root
|
||||||
|
actor + one `sleep_forever` subactor via
|
||||||
|
`subint_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),
|
||||||
|
'subint_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 `subint_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
|
||||||
|
|
@ -21,6 +21,16 @@ _non_linux: bool = platform.system() != 'Linux'
|
||||||
_friggin_windows: bool = platform.system() == 'Windows'
|
_friggin_windows: bool = platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint',
|
||||||
|
reason=(
|
||||||
|
'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
'See oustanding issue(s)\n'
|
||||||
|
# TODO, put issue link!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def assert_err(delay=0):
|
async def assert_err(delay=0):
|
||||||
await trio.sleep(delay)
|
await trio.sleep(delay)
|
||||||
assert 0
|
assert 0
|
||||||
|
|
@ -110,8 +120,17 @@ def test_remote_error(reg_addr, args_err):
|
||||||
assert exc.boxed_type == errtype
|
assert exc.boxed_type == errtype
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.mark.skipon_spawn_backend(
|
||||||
|
# 'subint',
|
||||||
|
# reason=(
|
||||||
|
# 'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
# 'See oustanding issue(s)\n'
|
||||||
|
# # TODO, put issue link!
|
||||||
|
# )
|
||||||
|
# )
|
||||||
def test_multierror(
|
def test_multierror(
|
||||||
reg_addr: tuple[str, int],
|
reg_addr: tuple[str, int],
|
||||||
|
start_method: str,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
||||||
|
|
@ -141,15 +160,28 @@ def test_multierror(
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('delay', (0, 0.5))
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'num_subactors', range(25, 26),
|
'delay',
|
||||||
|
(0, 0.5),
|
||||||
|
ids='delays={}'.format,
|
||||||
)
|
)
|
||||||
def test_multierror_fast_nursery(reg_addr, start_method, num_subactors, delay):
|
@pytest.mark.parametrize(
|
||||||
"""Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
'num_subactors',
|
||||||
|
range(25, 26),
|
||||||
|
ids= 'num_subs={}'.format,
|
||||||
|
)
|
||||||
|
def test_multierror_fast_nursery(
|
||||||
|
reg_addr: tuple,
|
||||||
|
start_method: str,
|
||||||
|
num_subactors: int,
|
||||||
|
delay: float,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
||||||
more then one actor errors and also with a delay before failure
|
more then one actor errors and also with a delay before failure
|
||||||
to test failure during an ongoing spawning.
|
to test failure during an ongoing spawning.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
|
|
@ -189,8 +221,15 @@ async def do_nothing():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('mechanism', ['nursery_cancel', KeyboardInterrupt])
|
@pytest.mark.parametrize(
|
||||||
def test_cancel_single_subactor(reg_addr, mechanism):
|
'mechanism', [
|
||||||
|
'nursery_cancel',
|
||||||
|
KeyboardInterrupt,
|
||||||
|
])
|
||||||
|
def test_cancel_single_subactor(
|
||||||
|
reg_addr: tuple,
|
||||||
|
mechanism: str|KeyboardInterrupt,
|
||||||
|
):
|
||||||
'''
|
'''
|
||||||
Ensure a ``ActorNursery.start_actor()`` spawned subactor
|
Ensure a ``ActorNursery.start_actor()`` spawned subactor
|
||||||
cancels when the nursery is cancelled.
|
cancels when the nursery is cancelled.
|
||||||
|
|
@ -232,9 +271,13 @@ async def stream_forever():
|
||||||
await trio.sleep(0.01)
|
await trio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
@tractor_test(
|
||||||
async def test_cancel_infinite_streamer(start_method):
|
timeout=6,
|
||||||
|
)
|
||||||
|
async def test_cancel_infinite_streamer(
|
||||||
|
reg_addr: tuple,
|
||||||
|
start_method: str,
|
||||||
|
):
|
||||||
# stream for at most 1 seconds
|
# stream for at most 1 seconds
|
||||||
with (
|
with (
|
||||||
trio.fail_after(4),
|
trio.fail_after(4),
|
||||||
|
|
@ -257,6 +300,14 @@ async def test_cancel_infinite_streamer(start_method):
|
||||||
assert n.cancelled
|
assert n.cancelled
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.mark.skipon_spawn_backend(
|
||||||
|
# 'subint',
|
||||||
|
# reason=(
|
||||||
|
# 'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
# 'See oustanding issue(s)\n'
|
||||||
|
# # TODO, put issue link!
|
||||||
|
# )
|
||||||
|
# )
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'num_actors_and_errs',
|
'num_actors_and_errs',
|
||||||
[
|
[
|
||||||
|
|
@ -286,9 +337,12 @@ async def test_cancel_infinite_streamer(start_method):
|
||||||
'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail',
|
'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@tractor_test
|
@tractor_test(
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
async def test_some_cancels_all(
|
async def test_some_cancels_all(
|
||||||
num_actors_and_errs: tuple,
|
num_actors_and_errs: tuple,
|
||||||
|
reg_addr: tuple,
|
||||||
start_method: str,
|
start_method: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
):
|
):
|
||||||
|
|
@ -370,7 +424,10 @@ async def test_some_cancels_all(
|
||||||
pytest.fail("Should have gotten a remote assertion error?")
|
pytest.fail("Should have gotten a remote assertion error?")
|
||||||
|
|
||||||
|
|
||||||
async def spawn_and_error(breadth, depth) -> None:
|
async def spawn_and_error(
|
||||||
|
breadth: int,
|
||||||
|
depth: int,
|
||||||
|
) -> None:
|
||||||
name = tractor.current_actor().name
|
name = tractor.current_actor().name
|
||||||
async with tractor.open_nursery() as nursery:
|
async with tractor.open_nursery() as nursery:
|
||||||
for i in range(breadth):
|
for i in range(breadth):
|
||||||
|
|
@ -395,8 +452,31 @@ async def spawn_and_error(breadth, depth) -> None:
|
||||||
await nursery.run_in_actor(*args, **kwargs)
|
await nursery.run_in_actor(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint_forkserver',
|
||||||
|
reason=(
|
||||||
|
'Passes cleanly with `pytest -s` (no stdout capture) '
|
||||||
|
'but hangs under default `--capture=fd` due to '
|
||||||
|
'pytest-capture-pipe buffer fill from high-volume '
|
||||||
|
'subactor error-log traceback output inherited via fds '
|
||||||
|
'1,2 in fork children. Fix direction: redirect subactor '
|
||||||
|
'stdout/stderr to `/dev/null` in `_child_target` / '
|
||||||
|
'`_actor_child_main` so forkserver children don\'t hold '
|
||||||
|
'pytest\'s capture pipe open. See `ai/conc-anal/'
|
||||||
|
'subint_forkserver_test_cancellation_leak_issue.md` '
|
||||||
|
'"Update — pytest capture pipe is the final gate".'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@pytest.mark.timeout(
|
||||||
|
10,
|
||||||
|
method='thread',
|
||||||
|
)
|
||||||
@tractor_test
|
@tractor_test
|
||||||
async def test_nested_multierrors(loglevel, start_method):
|
async def test_nested_multierrors(
|
||||||
|
reg_addr: tuple,
|
||||||
|
loglevel: str,
|
||||||
|
start_method: str,
|
||||||
|
):
|
||||||
'''
|
'''
|
||||||
Test that failed actor sets are wrapped in `BaseExceptionGroup`s. This
|
Test that failed actor sets are wrapped in `BaseExceptionGroup`s. This
|
||||||
test goes only 2 nurseries deep but we should eventually have tests
|
test goes only 2 nurseries deep but we should eventually have tests
|
||||||
|
|
@ -483,20 +563,24 @@ async def test_nested_multierrors(loglevel, start_method):
|
||||||
|
|
||||||
@no_windows
|
@no_windows
|
||||||
def test_cancel_via_SIGINT(
|
def test_cancel_via_SIGINT(
|
||||||
loglevel,
|
reg_addr: tuple,
|
||||||
start_method,
|
loglevel: str,
|
||||||
spawn_backend,
|
start_method: str,
|
||||||
):
|
):
|
||||||
"""Ensure that a control-C (SIGINT) signal cancels both the parent and
|
'''
|
||||||
|
Ensure that a control-C (SIGINT) signal cancels both the parent and
|
||||||
child processes in trionic fashion
|
child processes in trionic fashion
|
||||||
"""
|
|
||||||
|
'''
|
||||||
pid: int = os.getpid()
|
pid: int = os.getpid()
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
with trio.fail_after(2):
|
with trio.fail_after(2):
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery(
|
||||||
|
registry_addrs=[reg_addr],
|
||||||
|
) as tn:
|
||||||
await tn.start_actor('sucka')
|
await tn.start_actor('sucka')
|
||||||
if 'mp' in spawn_backend:
|
if 'mp' in start_method:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
os.kill(pid, signal.SIGINT)
|
os.kill(pid, signal.SIGINT)
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
@ -507,6 +591,7 @@ def test_cancel_via_SIGINT(
|
||||||
|
|
||||||
@no_windows
|
@no_windows
|
||||||
def test_cancel_via_SIGINT_other_task(
|
def test_cancel_via_SIGINT_other_task(
|
||||||
|
reg_addr: tuple,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
start_method: str,
|
start_method: str,
|
||||||
spawn_backend: str,
|
spawn_backend: str,
|
||||||
|
|
@ -535,7 +620,9 @@ def test_cancel_via_SIGINT_other_task(
|
||||||
async def spawn_and_sleep_forever(
|
async def spawn_and_sleep_forever(
|
||||||
task_status=trio.TASK_STATUS_IGNORED
|
task_status=trio.TASK_STATUS_IGNORED
|
||||||
):
|
):
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery(
|
||||||
|
registry_addrs=[reg_addr],
|
||||||
|
) as tn:
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
await tn.run_in_actor(
|
await tn.run_in_actor(
|
||||||
sleep_forever,
|
sleep_forever,
|
||||||
|
|
@ -580,6 +667,14 @@ async def spawn_sub_with_sync_blocking_task():
|
||||||
print('exiting first subactor layer..\n')
|
print('exiting first subactor layer..\n')
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.mark.skipon_spawn_backend(
|
||||||
|
# 'subint',
|
||||||
|
# reason=(
|
||||||
|
# 'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
# 'See oustanding issue(s)\n'
|
||||||
|
# # TODO, put issue link!
|
||||||
|
# )
|
||||||
|
# )
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'man_cancel_outer',
|
'man_cancel_outer',
|
||||||
[
|
[
|
||||||
|
|
@ -694,7 +789,7 @@ def test_cancel_while_childs_child_in_sync_sleep(
|
||||||
|
|
||||||
|
|
||||||
def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
|
def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
|
||||||
start_method,
|
start_method: str,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
This is a very subtle test which demonstrates how cancellation
|
This is a very subtle test which demonstrates how cancellation
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,15 @@ from tractor._testing import (
|
||||||
|
|
||||||
from .conftest import cpu_scaling_factor
|
from .conftest import cpu_scaling_factor
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint',
|
||||||
|
reason=(
|
||||||
|
'XXX SUBINT GIL-CONTENTION HANGING TEST XXX\n'
|
||||||
|
'See oustanding issue(s)\n'
|
||||||
|
# TODO, put issue link!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# XXX TODO cases:
|
# XXX TODO cases:
|
||||||
# - [x] WE cancelled the peer and thus should not see any raised
|
# - [x] WE cancelled the peer and thus should not see any raised
|
||||||
# `ContextCancelled` as it should be reaped silently?
|
# `ContextCancelled` as it should be reaped silently?
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ import tractor
|
||||||
from tractor.experimental import msgpub
|
from tractor.experimental import msgpub
|
||||||
from tractor._testing import tractor_test
|
from tractor._testing import tractor_test
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint',
|
||||||
|
reason=(
|
||||||
|
'XXX SUBINT HANGING TEST XXX\n'
|
||||||
|
'See oustanding issue(s)\n'
|
||||||
|
# TODO, put issue link!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_type_checks():
|
def test_type_checks():
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import trio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
# XXX `cffi` dun build on py3.14 yet..
|
||||||
|
cffi = pytest.importorskip("cffi")
|
||||||
|
|
||||||
from tractor.ipc._ringbuf import (
|
from tractor.ipc._ringbuf import (
|
||||||
open_ringbuf,
|
open_ringbuf,
|
||||||
RBToken,
|
RBToken,
|
||||||
|
|
@ -14,7 +18,7 @@ from tractor._testing.samples import (
|
||||||
generate_sample_messages,
|
generate_sample_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
# in case you don't want to melt your cores, uncomment dis!
|
# XXX, in case you want to melt your cores, comment this skip line XD
|
||||||
pytestmark = pytest.mark.skip
|
pytestmark = pytest.mark.skip
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ from tractor.ipc._shm import (
|
||||||
attach_shm_list,
|
attach_shm_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipon_spawn_backend(
|
||||||
|
'subint',
|
||||||
|
reason=(
|
||||||
|
'XXX SUBINT GIL-CONTENTION HANGING TEST XXX\n'
|
||||||
|
'See oustanding issue(s)\n'
|
||||||
|
# TODO, put issue link!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def child_attach_shml_alot(
|
async def child_attach_shml_alot(
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,23 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This is the "bootloader" for actors started using the native trio backend.
|
The "bootloader" for sub-actors spawned via the native `trio`
|
||||||
|
backend (the default `python -m tractor._child` CLI entry) and
|
||||||
|
the in-process `subint` backend (`tractor.spawn._subint`).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .runtime._runtime import Actor
|
from .runtime._runtime import Actor
|
||||||
from .spawn._entry import _trio_main
|
from .spawn._entry import _trio_main
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .discovery._addr import UnwrappedAddress
|
||||||
|
from .spawn._spawn import SpawnMethodKey
|
||||||
|
|
||||||
|
|
||||||
def parse_uid(arg):
|
def parse_uid(arg):
|
||||||
name, uuid = literal_eval(arg) # ensure 2 elements
|
name, uuid = literal_eval(arg) # ensure 2 elements
|
||||||
|
|
@ -39,6 +46,36 @@ def parse_ipaddr(arg):
|
||||||
return arg
|
return arg
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_child_main(
|
||||||
|
uid: tuple[str, str],
|
||||||
|
loglevel: str | None,
|
||||||
|
parent_addr: UnwrappedAddress | None,
|
||||||
|
infect_asyncio: bool,
|
||||||
|
spawn_method: SpawnMethodKey = 'trio',
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Construct the child `Actor` and dispatch to `_trio_main()`.
|
||||||
|
|
||||||
|
Shared entry shape used by both the `python -m tractor._child`
|
||||||
|
CLI (trio/mp subproc backends) and the `subint` backend, which
|
||||||
|
invokes this from inside a fresh `concurrent.interpreters`
|
||||||
|
sub-interpreter via `Interpreter.call()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
subactor = Actor(
|
||||||
|
name=uid[0],
|
||||||
|
uuid=uid[1],
|
||||||
|
loglevel=loglevel,
|
||||||
|
spawn_method=spawn_method,
|
||||||
|
)
|
||||||
|
_trio_main(
|
||||||
|
subactor,
|
||||||
|
parent_addr=parent_addr,
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
__tracebackhide__: bool = True
|
__tracebackhide__: bool = True
|
||||||
|
|
||||||
|
|
@ -49,15 +86,10 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--asyncio", action='store_true')
|
parser.add_argument("--asyncio", action='store_true')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
subactor = Actor(
|
_actor_child_main(
|
||||||
name=args.uid[0],
|
uid=args.uid,
|
||||||
uuid=args.uid[1],
|
|
||||||
loglevel=args.loglevel,
|
loglevel=args.loglevel,
|
||||||
spawn_method="trio"
|
|
||||||
)
|
|
||||||
|
|
||||||
_trio_main(
|
|
||||||
subactor,
|
|
||||||
parent_addr=args.parent_addr,
|
parent_addr=args.parent_addr,
|
||||||
infect_asyncio=args.asyncio,
|
infect_asyncio=args.asyncio,
|
||||||
|
spawn_method='trio',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,20 @@ from ._exceptions import (
|
||||||
logger = log.get_logger('tractor')
|
logger = log.get_logger('tractor')
|
||||||
|
|
||||||
|
|
||||||
|
# Spawn backends under which `debug_mode=True` is supported.
|
||||||
|
# Requirement: the spawned subactor's root runtime must be
|
||||||
|
# trio-native so `tractor.devx.debug._tty_lock` works. Matches
|
||||||
|
# both the enable-site in `open_root_actor` and the cleanup-
|
||||||
|
# site reset of `_runtime_vars['_debug_mode']` — keep them in
|
||||||
|
# lockstep when adding backends.
|
||||||
|
_DEBUG_COMPATIBLE_BACKENDS: tuple[str, ...] = (
|
||||||
|
'trio',
|
||||||
|
# forkserver children run `_trio_main` in their own OS
|
||||||
|
# process — same child-side runtime shape as `trio_proc`.
|
||||||
|
'subint_forkserver',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO: stick this in a `@acm` defined in `devx.debug`?
|
# TODO: stick this in a `@acm` defined in `devx.debug`?
|
||||||
# -[ ] also maybe consider making this a `wrapt`-deco to
|
# -[ ] also maybe consider making this a `wrapt`-deco to
|
||||||
# save an indent level?
|
# save an indent level?
|
||||||
|
|
@ -293,10 +307,14 @@ async def open_root_actor(
|
||||||
)
|
)
|
||||||
loglevel: str = loglevel.upper()
|
loglevel: str = loglevel.upper()
|
||||||
|
|
||||||
|
# Debug-mode is currently only supported for backends whose
|
||||||
|
# subactor root runtime is trio-native (so `tractor.devx.
|
||||||
|
# debug._tty_lock` works). See `_DEBUG_COMPATIBLE_BACKENDS`
|
||||||
|
# module-const for the list.
|
||||||
if (
|
if (
|
||||||
debug_mode
|
debug_mode
|
||||||
and
|
and
|
||||||
_spawn._spawn_method == 'trio'
|
_spawn._spawn_method in _DEBUG_COMPATIBLE_BACKENDS
|
||||||
):
|
):
|
||||||
_state._runtime_vars['_debug_mode'] = True
|
_state._runtime_vars['_debug_mode'] = True
|
||||||
|
|
||||||
|
|
@ -318,7 +336,9 @@ async def open_root_actor(
|
||||||
|
|
||||||
elif debug_mode:
|
elif debug_mode:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Debug mode is only supported for the `trio` backend!"
|
f'Debug mode currently supported only for '
|
||||||
|
f'{_DEBUG_COMPATIBLE_BACKENDS!r} spawn backends, not '
|
||||||
|
f'{_spawn._spawn_method!r}.'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert loglevel
|
assert loglevel
|
||||||
|
|
@ -619,7 +639,7 @@ async def open_root_actor(
|
||||||
if (
|
if (
|
||||||
debug_mode
|
debug_mode
|
||||||
and
|
and
|
||||||
_spawn._spawn_method == 'trio'
|
_spawn._spawn_method in _DEBUG_COMPATIBLE_BACKENDS
|
||||||
):
|
):
|
||||||
_state._runtime_vars['_debug_mode'] = False
|
_state._runtime_vars['_debug_mode'] = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,18 @@ def pytest_addoption(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(
|
||||||
backend = config.option.spawn_backend
|
config: pytest.Config,
|
||||||
|
):
|
||||||
|
backend: str = config.option.spawn_backend
|
||||||
from tractor.spawn._spawn import try_set_start_method
|
from tractor.spawn._spawn import try_set_start_method
|
||||||
try_set_start_method(backend)
|
try:
|
||||||
|
try_set_start_method(backend)
|
||||||
|
except RuntimeError as err:
|
||||||
|
# e.g. `--spawn-backend=subint` on Python < 3.14 — turn the
|
||||||
|
# runtime gate error into a clean pytest usage error so the
|
||||||
|
# suite exits with a helpful banner instead of a traceback.
|
||||||
|
raise pytest.UsageError(str(err)) from err
|
||||||
|
|
||||||
# register custom marks to avoid warnings see,
|
# register custom marks to avoid warnings see,
|
||||||
# https://docs.pytest.org/en/stable/how-to/writing_plugins.html#registering-custom-markers
|
# https://docs.pytest.org/en/stable/how-to/writing_plugins.html#registering-custom-markers
|
||||||
|
|
@ -235,10 +243,52 @@ def pytest_configure(config):
|
||||||
'markers',
|
'markers',
|
||||||
'no_tpt(proto_key): test will (likely) not behave with tpt backend'
|
'no_tpt(proto_key): test will (likely) not behave with tpt backend'
|
||||||
)
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
'markers',
|
||||||
|
'skipon_spawn_backend(*start_methods, reason=None): '
|
||||||
|
'skip this test under any of the given `--spawn-backend` '
|
||||||
|
'values; useful for backend-specific known-hang / -borked '
|
||||||
|
'cases (e.g. the `subint` GIL-starvation class documented '
|
||||||
|
'in `ai/conc-anal/subint_sigint_starvation_issue.md`).'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(
|
||||||
|
config: pytest.Config,
|
||||||
|
items: list[pytest.Function],
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Expand any `@pytest.mark.skipon_spawn_backend('<backend>'[,
|
||||||
|
...], reason='...')` markers into concrete
|
||||||
|
`pytest.mark.skip(reason=...)` calls for tests whose
|
||||||
|
backend-arg set contains the active `--spawn-backend`.
|
||||||
|
|
||||||
|
Uses `item.iter_markers(name=...)` which walks function +
|
||||||
|
class + module-level marks in the correct scope order (and
|
||||||
|
handles both the single-`MarkDecorator` and `list[Mark]`
|
||||||
|
forms of a module-level `pytestmark`) — so the same marker
|
||||||
|
works at any level a user puts it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
backend: str = config.option.spawn_backend
|
||||||
|
default_reason: str = f'Borked on --spawn-backend={backend!r}'
|
||||||
|
for item in items:
|
||||||
|
for mark in item.iter_markers(name='skipon_spawn_backend'):
|
||||||
|
if backend in mark.args:
|
||||||
|
reason: str = mark.kwargs.get(
|
||||||
|
'reason',
|
||||||
|
default_reason,
|
||||||
|
)
|
||||||
|
item.add_marker(pytest.mark.skip(reason=reason))
|
||||||
|
# first matching mark wins; no value in stacking
|
||||||
|
# multiple `skip`s on the same item.
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def debug_mode(request) -> bool:
|
def debug_mode(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
) -> bool:
|
||||||
'''
|
'''
|
||||||
Flag state for whether `--tpdb` (for `tractor`-py-debugger)
|
Flag state for whether `--tpdb` (for `tractor`-py-debugger)
|
||||||
was passed to the test run.
|
was passed to the test run.
|
||||||
|
|
@ -252,12 +302,16 @@ def debug_mode(request) -> bool:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def spawn_backend(request) -> str:
|
def spawn_backend(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
) -> str:
|
||||||
return request.config.option.spawn_backend
|
return request.config.option.spawn_backend
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def tpt_protos(request) -> list[str]:
|
def tpt_protos(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
) -> list[str]:
|
||||||
|
|
||||||
# allow quoting on CLI
|
# allow quoting on CLI
|
||||||
proto_keys: list[str] = [
|
proto_keys: list[str] = [
|
||||||
|
|
@ -285,7 +339,7 @@ def tpt_protos(request) -> list[str]:
|
||||||
autouse=True,
|
autouse=True,
|
||||||
)
|
)
|
||||||
def tpt_proto(
|
def tpt_proto(
|
||||||
request,
|
request: pytest.FixtureRequest,
|
||||||
tpt_protos: list[str],
|
tpt_protos: list[str],
|
||||||
) -> str:
|
) -> str:
|
||||||
proto_key: str = tpt_protos[0]
|
proto_key: str = tpt_protos[0]
|
||||||
|
|
@ -337,7 +391,6 @@ def pytest_generate_tests(
|
||||||
metafunc: pytest.Metafunc,
|
metafunc: pytest.Metafunc,
|
||||||
):
|
):
|
||||||
spawn_backend: str = metafunc.config.option.spawn_backend
|
spawn_backend: str = metafunc.config.option.spawn_backend
|
||||||
|
|
||||||
if not spawn_backend:
|
if not spawn_backend:
|
||||||
# XXX some weird windows bug with `pytest`?
|
# XXX some weird windows bug with `pytest`?
|
||||||
spawn_backend = 'trio'
|
spawn_backend = 'trio'
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ from .pformat import (
|
||||||
pformat_caller_frame as pformat_caller_frame,
|
pformat_caller_frame as pformat_caller_frame,
|
||||||
pformat_boxed_tb as pformat_boxed_tb,
|
pformat_boxed_tb as pformat_boxed_tb,
|
||||||
)
|
)
|
||||||
|
from ._debug_hangs import (
|
||||||
|
dump_on_hang as dump_on_hang,
|
||||||
|
track_resource_deltas as track_resource_deltas,
|
||||||
|
resource_delta_fixture as resource_delta_fixture,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO, move this to a new `.devx._pdbp` mod?
|
# TODO, move this to a new `.devx._pdbp` mod?
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Hang-diagnostic helpers for concurrent / multi-interpreter code.
|
||||||
|
|
||||||
|
Collected from the `subint` spawn backend bringup (issue #379)
|
||||||
|
where silent test-suite hangs needed careful teardown
|
||||||
|
instrumentation to diagnose. This module bottles up the
|
||||||
|
techniques that actually worked so future hangs are faster
|
||||||
|
to corner.
|
||||||
|
|
||||||
|
Two primitives:
|
||||||
|
|
||||||
|
1. `dump_on_hang()` — context manager wrapping
|
||||||
|
`faulthandler.dump_traceback_later()` with the critical
|
||||||
|
gotcha baked in: write the dump to a **file**, not
|
||||||
|
`sys.stderr`. Under `pytest` (and any other output
|
||||||
|
capturer) stderr gets swallowed and the dump is easy to
|
||||||
|
miss — burning hours convinced you're looking at the wrong
|
||||||
|
thing.
|
||||||
|
|
||||||
|
2. `track_resource_deltas()` — context manager (+ optional
|
||||||
|
autouse-fixture factory) logging per-block deltas of
|
||||||
|
`threading.active_count()` and — if running on py3.13+ —
|
||||||
|
`len(_interpreters.list_all())`. Lets you quickly rule out
|
||||||
|
leak-accumulation theories when a suite hangs more
|
||||||
|
frequently as it progresses (if counts don't grow, it's
|
||||||
|
not a leak; look for a race on shared cleanup instead).
|
||||||
|
|
||||||
|
See issue #379 / commit `26fb820` for the worked example.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
import faulthandler
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
Iterator,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import _interpreters # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
_interpreters = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'dump_on_hang',
|
||||||
|
'track_resource_deltas',
|
||||||
|
'resource_delta_fixture',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def dump_on_hang(
|
||||||
|
seconds: float = 30.0,
|
||||||
|
*,
|
||||||
|
path: str | Path = '/tmp/tractor_hang.dump',
|
||||||
|
all_threads: bool = True,
|
||||||
|
|
||||||
|
) -> Iterator[str]:
|
||||||
|
'''
|
||||||
|
Arm `faulthandler` to dump all-thread tracebacks to
|
||||||
|
`path` after `seconds` if the with-block hasn't exited.
|
||||||
|
|
||||||
|
*Writes to a file, not stderr* — `pytest`'s stderr
|
||||||
|
capture silently eats stderr-destined `faulthandler`
|
||||||
|
output, and the same happens under any framework that
|
||||||
|
redirects file-descriptors. Pointing the dump at a real
|
||||||
|
file sidesteps that.
|
||||||
|
|
||||||
|
Yields the resolved file path so it's easy to read back.
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
::
|
||||||
|
|
||||||
|
from tractor.devx import dump_on_hang
|
||||||
|
|
||||||
|
def test_hang():
|
||||||
|
with dump_on_hang(
|
||||||
|
seconds=15,
|
||||||
|
path='/tmp/my_test_hang.dump',
|
||||||
|
) as dump_path:
|
||||||
|
trio.run(main)
|
||||||
|
# if it hangs, inspect dump_path afterward
|
||||||
|
|
||||||
|
'''
|
||||||
|
dump_path = Path(path)
|
||||||
|
f = dump_path.open('w')
|
||||||
|
try:
|
||||||
|
faulthandler.dump_traceback_later(
|
||||||
|
seconds,
|
||||||
|
repeat=False,
|
||||||
|
file=f,
|
||||||
|
exit=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield str(dump_path)
|
||||||
|
finally:
|
||||||
|
faulthandler.cancel_dump_traceback_later()
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot() -> tuple[int, int]:
|
||||||
|
'''
|
||||||
|
Return `(thread_count, subint_count)`.
|
||||||
|
|
||||||
|
Subint count reported as `0` on pythons lacking the
|
||||||
|
private `_interpreters` stdlib module (i.e. py<3.13).
|
||||||
|
|
||||||
|
'''
|
||||||
|
threads: int = threading.active_count()
|
||||||
|
subints: int = (
|
||||||
|
len(_interpreters.list_all())
|
||||||
|
if _interpreters is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
return threads, subints
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def track_resource_deltas(
|
||||||
|
label: str = '',
|
||||||
|
*,
|
||||||
|
writer: Callable[[str], None] | None = None,
|
||||||
|
|
||||||
|
) -> Iterator[tuple[int, int]]:
|
||||||
|
'''
|
||||||
|
Log `(threads, subints)` deltas across the with-block.
|
||||||
|
|
||||||
|
`writer` defaults to `sys.stderr.write` (+ trailing
|
||||||
|
newline); pass a custom callable to route elsewhere
|
||||||
|
(e.g., a log handler or an append-to-file).
|
||||||
|
|
||||||
|
Yields the pre-entry snapshot so callers can assert
|
||||||
|
against the expected counts if they want.
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
::
|
||||||
|
|
||||||
|
from tractor.devx import track_resource_deltas
|
||||||
|
|
||||||
|
async def test_foo():
|
||||||
|
with track_resource_deltas(label='test_foo'):
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
...
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# test_foo: threads 2->2, subints 1->1
|
||||||
|
|
||||||
|
'''
|
||||||
|
before = _snapshot()
|
||||||
|
try:
|
||||||
|
yield before
|
||||||
|
finally:
|
||||||
|
after = _snapshot()
|
||||||
|
msg: str = (
|
||||||
|
f'{label}: '
|
||||||
|
f'threads {before[0]}->{after[0]}, '
|
||||||
|
f'subints {before[1]}->{after[1]}'
|
||||||
|
)
|
||||||
|
if writer is None:
|
||||||
|
sys.stderr.write(msg + '\n')
|
||||||
|
sys.stderr.flush()
|
||||||
|
else:
|
||||||
|
writer(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_delta_fixture(
|
||||||
|
*,
|
||||||
|
autouse: bool = True,
|
||||||
|
writer: Callable[[str], None] | None = None,
|
||||||
|
|
||||||
|
) -> Callable:
|
||||||
|
'''
|
||||||
|
Factory returning a `pytest` fixture that wraps each test
|
||||||
|
in `track_resource_deltas(label=<node.name>)`.
|
||||||
|
|
||||||
|
Usage in a `conftest.py`::
|
||||||
|
|
||||||
|
# tests/conftest.py
|
||||||
|
from tractor.devx import resource_delta_fixture
|
||||||
|
|
||||||
|
track_resources = resource_delta_fixture()
|
||||||
|
|
||||||
|
or opt-in per-test::
|
||||||
|
|
||||||
|
track_resources = resource_delta_fixture(autouse=False)
|
||||||
|
|
||||||
|
def test_foo(track_resources):
|
||||||
|
...
|
||||||
|
|
||||||
|
Kept as a factory (not a bare fixture) so callers control
|
||||||
|
`autouse` / `writer` without having to subclass or patch.
|
||||||
|
|
||||||
|
'''
|
||||||
|
import pytest # deferred: only needed when caller opts in
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=autouse)
|
||||||
|
def _track_resources(request):
|
||||||
|
with track_resource_deltas(
|
||||||
|
label=request.node.name,
|
||||||
|
writer=writer,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
return _track_resources
|
||||||
|
|
@ -17,10 +17,20 @@
|
||||||
Linux specifics, for now we are only exposing EventFD
|
Linux specifics, for now we are only exposing EventFD
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import os
|
|
||||||
import errno
|
import errno
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cffi
|
||||||
|
except ImportError as ie:
|
||||||
|
if sys.version_info < (3, 14):
|
||||||
|
ie.add_note(
|
||||||
|
f'The `cffi` pkg has no 3.14 support yet.\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ie
|
||||||
|
|
||||||
import cffi
|
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
ffi = cffi.FFI()
|
ffi = cffi.FFI()
|
||||||
|
|
|
||||||
|
|
@ -870,7 +870,14 @@ class Actor:
|
||||||
|
|
||||||
accept_addrs: list[UnwrappedAddress]|None = None
|
accept_addrs: list[UnwrappedAddress]|None = None
|
||||||
|
|
||||||
if self._spawn_method == "trio":
|
if self._spawn_method in (
|
||||||
|
'trio',
|
||||||
|
'subint',
|
||||||
|
# `subint_forkserver` parent-side sends a
|
||||||
|
# `SpawnSpec` over IPC just like the other two
|
||||||
|
# — fork child-side runtime is trio-native.
|
||||||
|
'subint_forkserver',
|
||||||
|
):
|
||||||
|
|
||||||
# Receive post-spawn runtime state from our parent.
|
# Receive post-spawn runtime state from our parent.
|
||||||
spawnspec: msgtypes.SpawnSpec = await chan.recv()
|
spawnspec: msgtypes.SpawnSpec = await chan.recv()
|
||||||
|
|
@ -1209,6 +1216,23 @@ class Actor:
|
||||||
ipc_server.cancel()
|
ipc_server.cancel()
|
||||||
await ipc_server.wait_for_shutdown()
|
await ipc_server.wait_for_shutdown()
|
||||||
|
|
||||||
|
# Break the shield on the parent-channel
|
||||||
|
# `process_messages` loop (started with `shield=True`
|
||||||
|
# in `async_main` above). Required to avoid a
|
||||||
|
# deadlock during teardown of fork-spawned subactors:
|
||||||
|
# without this cancel, the loop parks waiting for
|
||||||
|
# EOF on the parent channel, but the parent is
|
||||||
|
# blocked on `os.waitpid()` for THIS actor's exit
|
||||||
|
# — mutual wait. For exec-spawn backends the EOF
|
||||||
|
# arrives naturally when the parent closes its
|
||||||
|
# handler-task socket during its own teardown, but
|
||||||
|
# in fork backends the shared-process-image makes
|
||||||
|
# that delivery racy / not guaranteed. Explicit
|
||||||
|
# cancel here gives us deterministic unwinding
|
||||||
|
# regardless of backend.
|
||||||
|
if self._parent_chan_cs is not None:
|
||||||
|
self._parent_chan_cs.cancel()
|
||||||
|
|
||||||
# cancel all rpc tasks permanently
|
# cancel all rpc tasks permanently
|
||||||
if self._service_tn:
|
if self._service_tn:
|
||||||
self._service_tn.cancel_scope.cancel()
|
self._service_tn.cancel_scope.cancel()
|
||||||
|
|
@ -1729,7 +1753,16 @@ async def async_main(
|
||||||
# start processing parent requests until our channel
|
# start processing parent requests until our channel
|
||||||
# server is 100% up and running.
|
# server is 100% up and running.
|
||||||
if actor._parent_chan:
|
if actor._parent_chan:
|
||||||
await root_tn.start(
|
# Capture the shielded `loop_cs` for the
|
||||||
|
# parent-channel `process_messages` task so
|
||||||
|
# `Actor.cancel()` has a handle to break the
|
||||||
|
# shield during teardown — without this, the
|
||||||
|
# shielded loop would park on the parent chan
|
||||||
|
# indefinitely waiting for EOF that only arrives
|
||||||
|
# after the PARENT tears down, which under
|
||||||
|
# fork-based backends (e.g. `subint_forkserver`)
|
||||||
|
# it waits on THIS actor's exit — deadlock.
|
||||||
|
actor._parent_chan_cs = await root_tn.start(
|
||||||
partial(
|
partial(
|
||||||
_rpc.process_messages,
|
_rpc.process_messages,
|
||||||
chan=actor._parent_chan,
|
chan=actor._parent_chan,
|
||||||
|
|
@ -1940,7 +1973,25 @@ async def async_main(
|
||||||
f' {pformat(ipc_server._peers)}'
|
f' {pformat(ipc_server._peers)}'
|
||||||
)
|
)
|
||||||
log.runtime(teardown_report)
|
log.runtime(teardown_report)
|
||||||
await ipc_server.wait_for_no_more_peers()
|
# NOTE: bound the peer-clear wait — otherwise if any
|
||||||
|
# peer-channel handler is stuck (e.g. never got its
|
||||||
|
# cancel propagated due to a runtime bug), this wait
|
||||||
|
# blocks forever and deadlocks the whole actor-tree
|
||||||
|
# teardown cascade. 3s is enough for any graceful
|
||||||
|
# cancel-ack round-trip; beyond that we're in bug
|
||||||
|
# territory and need to proceed with local teardown
|
||||||
|
# so the parent's `_ForkedProc.wait()` can unblock.
|
||||||
|
# See `ai/conc-anal/
|
||||||
|
# subint_forkserver_test_cancellation_leak_issue.md`
|
||||||
|
# for the full diagnosis.
|
||||||
|
with trio.move_on_after(3.0) as _peers_cs:
|
||||||
|
await ipc_server.wait_for_no_more_peers()
|
||||||
|
if _peers_cs.cancelled_caught:
|
||||||
|
teardown_report += (
|
||||||
|
f'-> TIMED OUT waiting for peers to clear '
|
||||||
|
f'({len(ipc_server._peers)} still connected)\n'
|
||||||
|
)
|
||||||
|
log.warning(teardown_report)
|
||||||
|
|
||||||
teardown_report += (
|
teardown_report += (
|
||||||
'-]> all peer channels are complete.\n'
|
'-]> all peer channels are complete.\n'
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,14 @@ class RuntimeVars(Struct):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_runtime_vars: dict[str, Any] = {
|
# The "fresh process" defaults — what `_runtime_vars` looks
|
||||||
|
# like in a just-booted Python process that hasn't yet entered
|
||||||
|
# `open_root_actor()` nor received a parent `SpawnSpec`. Kept
|
||||||
|
# as a module-level constant so `get_runtime_vars(clear_values=
|
||||||
|
# True)` can reset the live dict back to this baseline (see
|
||||||
|
# `tractor.spawn._subint_forkserver` for the one current caller
|
||||||
|
# that needs it).
|
||||||
|
_RUNTIME_VARS_DEFAULTS: dict[str, Any] = {
|
||||||
# root of actor-process tree info
|
# root of actor-process tree info
|
||||||
'_is_root': False, # bool
|
'_is_root': False, # bool
|
||||||
'_root_mailbox': (None, None), # tuple[str|None, str|None]
|
'_root_mailbox': (None, None), # tuple[str|None, str|None]
|
||||||
|
|
@ -138,10 +145,12 @@ _runtime_vars: dict[str, Any] = {
|
||||||
# infected-`asyncio`-mode: `trio` running as guest.
|
# infected-`asyncio`-mode: `trio` running as guest.
|
||||||
'_is_infected_aio': False,
|
'_is_infected_aio': False,
|
||||||
}
|
}
|
||||||
|
_runtime_vars: dict[str, Any] = dict(_RUNTIME_VARS_DEFAULTS)
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_vars(
|
def get_runtime_vars(
|
||||||
as_dict: bool = True,
|
as_dict: bool = True,
|
||||||
|
clear_values: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
'''
|
'''
|
||||||
Deliver a **copy** of the current `Actor`'s "runtime variables".
|
Deliver a **copy** of the current `Actor`'s "runtime variables".
|
||||||
|
|
@ -150,11 +159,62 @@ def get_runtime_vars(
|
||||||
form, but the `RuntimeVars` struct should be utilized as possible
|
form, but the `RuntimeVars` struct should be utilized as possible
|
||||||
for future calls.
|
for future calls.
|
||||||
|
|
||||||
'''
|
Pure read — **never mutates** the module-level `_runtime_vars`.
|
||||||
if as_dict:
|
|
||||||
return dict(_runtime_vars)
|
|
||||||
|
|
||||||
return RuntimeVars(**_runtime_vars)
|
If `clear_values=True`, return a copy of the fresh-process
|
||||||
|
defaults (`_RUNTIME_VARS_DEFAULTS`) instead of the live
|
||||||
|
dict. Useful in combination with `set_runtime_vars()` to
|
||||||
|
reset process-global state back to "cold" — the main caller
|
||||||
|
today is the `subint_forkserver` spawn backend's post-fork
|
||||||
|
child prelude:
|
||||||
|
|
||||||
|
set_runtime_vars(get_runtime_vars(clear_values=True))
|
||||||
|
|
||||||
|
`os.fork()` inherits the parent's full memory image, so the
|
||||||
|
child sees the parent's populated `_runtime_vars` (e.g.
|
||||||
|
`_is_root=True`) which would trip the `assert not
|
||||||
|
self.enable_modules` gate in `Actor._from_parent()` on the
|
||||||
|
subsequent parent→child `SpawnSpec` handshake if left alone.
|
||||||
|
|
||||||
|
'''
|
||||||
|
src: dict = (
|
||||||
|
_RUNTIME_VARS_DEFAULTS
|
||||||
|
if clear_values
|
||||||
|
else _runtime_vars
|
||||||
|
)
|
||||||
|
snapshot: dict = dict(src)
|
||||||
|
if as_dict:
|
||||||
|
return snapshot
|
||||||
|
return RuntimeVars(**snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def set_runtime_vars(
|
||||||
|
rtvars: dict | RuntimeVars,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Atomically replace the module-level `_runtime_vars` contents
|
||||||
|
with those of `rtvars` (via `.clear()` + `.update()` so
|
||||||
|
live references to the same dict object remain valid).
|
||||||
|
|
||||||
|
Accepts either the historical `dict` form or the `RuntimeVars`
|
||||||
|
`msgspec.Struct` form (the latter still mostly unused but
|
||||||
|
the blessed forward shape — see the struct's definition).
|
||||||
|
|
||||||
|
Paired with `get_runtime_vars()` as the explicit
|
||||||
|
write-half of the runtime-vars API — prefer this over
|
||||||
|
direct mutation of `_runtime_vars[...]` from new call sites.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if isinstance(rtvars, RuntimeVars):
|
||||||
|
# `msgspec.Struct` → dict via its declared field set;
|
||||||
|
# avoids pulling in `msgspec.structs.asdict` just for
|
||||||
|
# this one call path.
|
||||||
|
rtvars = {
|
||||||
|
field_name: getattr(rtvars, field_name)
|
||||||
|
for field_name in rtvars.__struct_fields__
|
||||||
|
}
|
||||||
|
_runtime_vars.clear()
|
||||||
|
_runtime_vars.update(rtvars)
|
||||||
|
|
||||||
|
|
||||||
def last_actor() -> Actor|None:
|
def last_actor() -> Actor|None:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ coroutine registered in `_spawn._methods`):
|
||||||
- `._mp`: the stdlib `multiprocessing` backend variants — driven by
|
- `._mp`: the stdlib `multiprocessing` backend variants — driven by
|
||||||
the `mp.context` bound to `_spawn._ctx`:
|
the `mp.context` bound to `_spawn._ctx`:
|
||||||
* `'mp_spawn'`,
|
* `'mp_spawn'`,
|
||||||
* `'mp_forkserver'`
|
* `'mp_forkserver'`
|
||||||
|
|
||||||
Entry-point helpers live in `._entry`/`._mp_fixup_main`/
|
Entry-point helpers live in `._entry`/`._mp_fixup_main`/
|
||||||
`._forkserver_override`.
|
`._forkserver_override`.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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,
|
||||||
|
|
@ -61,6 +62,23 @@ 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._subint_forkserver`.
|
||||||
|
'subint_forkserver',
|
||||||
]
|
]
|
||||||
_spawn_method: SpawnMethodKey = 'trio'
|
_spawn_method: SpawnMethodKey = 'trio'
|
||||||
|
|
||||||
|
|
@ -113,6 +131,26 @@ def try_set_start_method(
|
||||||
case 'trio':
|
case 'trio':
|
||||||
_ctx = None
|
_ctx = None
|
||||||
|
|
||||||
|
case 'subint' | 'subint_fork' | 'subint_forkserver':
|
||||||
|
# All subint-family backends need no `mp.context`;
|
||||||
|
# all three 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`);
|
||||||
|
# `subint_forkserver` is the working workaround.
|
||||||
|
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
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Spawn method `{key}` is invalid!\n'
|
f'Spawn method `{key}` is invalid!\n'
|
||||||
|
|
@ -437,6 +475,9 @@ 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 ._subint_forkserver import subint_forkserver_proc
|
||||||
|
|
||||||
|
|
||||||
# proc spawning backend target map
|
# proc spawning backend target map
|
||||||
|
|
@ -444,4 +485,15 @@ _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,
|
||||||
|
# WIP — fork-from-non-trio-worker-thread, works on py3.14+
|
||||||
|
# (validated via `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`).
|
||||||
|
# See `tractor.spawn._subint_forkserver`.
|
||||||
|
'subint_forkserver': subint_forkserver_proc,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
# 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.
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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'(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)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
# 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.'
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,891 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Forkserver-style `os.fork()` primitives for the `subint`-hosted
|
||||||
|
actor model.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
CPython refuses `os.fork()` from a non-main sub-interpreter:
|
||||||
|
`PyOS_AfterFork_Child()` →
|
||||||
|
`_PyInterpreterState_DeleteExceptMain()` gates on the calling
|
||||||
|
thread's tstate belonging to the main interpreter and aborts
|
||||||
|
the forked child otherwise. The full walkthrough (with source
|
||||||
|
refs) lives in
|
||||||
|
`ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`.
|
||||||
|
|
||||||
|
However `os.fork()` from a regular `threading.Thread` attached
|
||||||
|
to the *main* interpreter — i.e. a worker thread that has
|
||||||
|
never entered a subint — works cleanly. Empirically validated
|
||||||
|
across four scenarios by
|
||||||
|
`ai/conc-anal/subint_fork_from_main_thread_smoketest.py` on
|
||||||
|
py3.14.
|
||||||
|
|
||||||
|
This submodule lifts the validated primitives out of the
|
||||||
|
smoke-test and into tractor proper, so they can eventually be
|
||||||
|
wired into a real "subint forkserver" spawn backend — where:
|
||||||
|
|
||||||
|
- A dedicated main-interp worker thread owns all `os.fork()`
|
||||||
|
calls (never enters a subint).
|
||||||
|
- The tractor parent-actor's `trio.run()` lives in a
|
||||||
|
sub-interpreter on a different worker thread.
|
||||||
|
- When a spawn is requested, the trio-task signals the
|
||||||
|
forkserver thread; the forkserver forks; child re-enters
|
||||||
|
the same pattern (trio in a subint + forkserver on main).
|
||||||
|
|
||||||
|
This mirrors the stdlib `multiprocessing.forkserver` design
|
||||||
|
but keeps the forkserver in-process for faster spawn latency
|
||||||
|
and inherited parent state.
|
||||||
|
|
||||||
|
Status
|
||||||
|
------
|
||||||
|
**EXPERIMENTAL** — wired as the `'subint_forkserver'` entry
|
||||||
|
in `tractor.spawn._spawn._methods` and selectable via
|
||||||
|
`try_set_start_method('subint_forkserver')` / `--spawn-backend
|
||||||
|
=subint_forkserver`. Parent-side spawn, child-side runtime
|
||||||
|
bring-up and normal portal-RPC teardown are validated by the
|
||||||
|
backend-tier test in
|
||||||
|
`tests/spawn/test_subint_forkserver.py::test_subint_forkserver_spawn_basic`.
|
||||||
|
|
||||||
|
Still-open work (tracked on tractor #379):
|
||||||
|
|
||||||
|
- no cancellation / hard-kill stress coverage yet (counterpart
|
||||||
|
to `tests/test_subint_cancellation.py` for the plain
|
||||||
|
`subint` backend),
|
||||||
|
- `child_sigint='trio'` mode (flag scaffolded below; default
|
||||||
|
is `'ipc'`). Originally intended as a manual SIGINT →
|
||||||
|
trio-cancel bridge, but investigation showed trio's handler
|
||||||
|
IS already correctly installed in the fork-child subactor
|
||||||
|
— the orphan-SIGINT hang is actually a separate bug where
|
||||||
|
trio's event loop stays wedged in `epoll_wait` despite
|
||||||
|
delivery. See
|
||||||
|
`ai/conc-anal/subint_forkserver_orphan_sigint_hang_issue.md`
|
||||||
|
for the full trace + fix directions. Once that root cause
|
||||||
|
is fixed, this flag may end up a no-op / doc-only mode.
|
||||||
|
- child-side "subint-hosted root runtime" mode (the second
|
||||||
|
half of the envisioned arch — currently the forked child
|
||||||
|
runs plain `_trio_main` via `spawn_method='trio'`; the
|
||||||
|
subint-hosted variant is still the future step gated on
|
||||||
|
msgspec PEP 684 support),
|
||||||
|
- thread-hygiene audit of the two `threading.Thread`
|
||||||
|
primitives below, gated on the same msgspec unblock
|
||||||
|
(see TODO section further down).
|
||||||
|
|
||||||
|
TODO — cleanup gated on msgspec PEP 684 support
|
||||||
|
-----------------------------------------------
|
||||||
|
Both primitives below allocate a dedicated
|
||||||
|
`threading.Thread` rather than using
|
||||||
|
`trio.to_thread.run_sync()`. That's a cautious design
|
||||||
|
rooted in three distinct-but-entangled issues (GIL
|
||||||
|
starvation from legacy-config subints, tstate-recycling
|
||||||
|
destroy race on trio cache threads, fork-from-main-tstate
|
||||||
|
invariant). Some of those dissolve under PEP 684
|
||||||
|
isolated-mode subints; one requires empirical re-testing
|
||||||
|
to know.
|
||||||
|
|
||||||
|
Full analysis + audit plan for when we can revisit is in
|
||||||
|
`ai/conc-anal/subint_forkserver_thread_constraints_on_pep684_issue.md`.
|
||||||
|
Intent: file a follow-up GH issue linked to #379 once
|
||||||
|
[jcrist/msgspec#563](https://github.com/jcrist/msgspec/issues/563)
|
||||||
|
unblocks isolated-mode subints in tractor.
|
||||||
|
|
||||||
|
See also
|
||||||
|
--------
|
||||||
|
- `tractor.spawn._subint_fork` — the stub for the
|
||||||
|
fork-from-subint strategy that DIDN'T work (kept as
|
||||||
|
in-tree documentation of the attempt + CPython-level
|
||||||
|
block).
|
||||||
|
- `ai/conc-anal/subint_fork_blocked_by_cpython_post_fork_issue.md`
|
||||||
|
— the CPython source walkthrough.
|
||||||
|
- `ai/conc-anal/subint_fork_from_main_thread_smoketest.py`
|
||||||
|
— the standalone feasibility check (now delegates to
|
||||||
|
this module for the primitives it exercises).
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from functools import partial
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
Literal,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
import trio
|
||||||
|
from trio import TaskStatus
|
||||||
|
|
||||||
|
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,
|
||||||
|
soft_kill,
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
# Configurable child-side SIGINT handling for forkserver-spawned
|
||||||
|
# subactors. Threaded through `subint_forkserver_proc`'s
|
||||||
|
# `proc_kwargs` under the `'child_sigint'` key.
|
||||||
|
#
|
||||||
|
# - `'ipc'` (default, currently the only implemented mode):
|
||||||
|
# child has NO trio-level SIGINT handler — trio.run() is on
|
||||||
|
# the fork-inherited non-main thread, `signal.set_wakeup_fd()`
|
||||||
|
# is main-thread-only. Cancellation flows exclusively via
|
||||||
|
# the parent's `Portal.cancel_actor()` IPC path. Safe +
|
||||||
|
# deterministic for nursery-structured apps where the parent
|
||||||
|
# is always the cancel authority. Known gap: orphan
|
||||||
|
# (post-parent-SIGKILL) children don't respond to SIGINT
|
||||||
|
# — see `test_orphaned_subactor_sigint_cleanup_DRAFT`.
|
||||||
|
#
|
||||||
|
# - `'trio'` (**not yet implemented**): install a manual
|
||||||
|
# SIGINT → trio-cancel bridge in the child's fork prelude
|
||||||
|
# (pre-`trio.run()`) so external Ctrl-C reaches stuck
|
||||||
|
# grandchildren even with a dead parent. Adds signal-
|
||||||
|
# handling surface the `'ipc'` default cleanly avoids; only
|
||||||
|
# pay for it when externally-interruptible children actually
|
||||||
|
# matter (e.g. CLI tool grandchildren).
|
||||||
|
ChildSigintMode = Literal['ipc', 'trio']
|
||||||
|
_DEFAULT_CHILD_SIGINT: ChildSigintMode = 'ipc'
|
||||||
|
|
||||||
|
|
||||||
|
# Feature-gate: py3.14+ via the public `concurrent.interpreters`
|
||||||
|
# wrapper. Matches the gate in `tractor.spawn._subint` —
|
||||||
|
# see that module's docstring for why we require the public
|
||||||
|
# API's presence even though we reach into the private
|
||||||
|
# `_interpreters` C module for actual calls.
|
||||||
|
try:
|
||||||
|
from concurrent import interpreters as _public_interpreters # noqa: F401 # type: ignore
|
||||||
|
import _interpreters # type: ignore
|
||||||
|
_has_subints: bool = True
|
||||||
|
except ImportError:
|
||||||
|
_interpreters = None # type: ignore
|
||||||
|
_has_subints: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _close_inherited_fds(
|
||||||
|
keep: frozenset[int] = frozenset({0, 1, 2}),
|
||||||
|
) -> int:
|
||||||
|
'''
|
||||||
|
Close every open file descriptor in the current process
|
||||||
|
EXCEPT those in `keep` (default: stdio only).
|
||||||
|
|
||||||
|
Intended as the first thing a post-`os.fork()` child runs
|
||||||
|
after closing any communication pipes it knows about. This
|
||||||
|
is the fork-child FD hygiene discipline that
|
||||||
|
`subprocess.Popen(close_fds=True)` applies by default for
|
||||||
|
its exec-based children, but which we have to implement
|
||||||
|
ourselves because our `fork_from_worker_thread()` primitive
|
||||||
|
deliberately does NOT exec.
|
||||||
|
|
||||||
|
Why it matters
|
||||||
|
--------------
|
||||||
|
Without this, a forkserver-spawned subactor inherits the
|
||||||
|
parent actor's IPC listener sockets, trio-epoll fd, trio
|
||||||
|
wakeup-pipe, peer-channel sockets, etc. If that subactor
|
||||||
|
then itself forkserver-spawns a grandchild, the grandchild
|
||||||
|
inherits the FDs transitively from *both* its direct
|
||||||
|
parent AND the root actor — IPC message routing becomes
|
||||||
|
ambiguous and the cancel cascade deadlocks. See
|
||||||
|
`ai/conc-anal/subint_forkserver_test_cancellation_leak_issue.md`
|
||||||
|
for the full diagnosis + the empirical repro.
|
||||||
|
|
||||||
|
Fresh children will open their own IPC sockets via
|
||||||
|
`_actor_child_main()`, so they don't need any of the
|
||||||
|
parent's FDs.
|
||||||
|
|
||||||
|
Returns the count of fds that were successfully closed —
|
||||||
|
useful for sanity-check logging at callsites.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# Enumerate open fds via `/proc/self/fd` on Linux (the fast +
|
||||||
|
# precise path); fall back to `RLIMIT_NOFILE` range close on
|
||||||
|
# other platforms. Matches stdlib
|
||||||
|
# `subprocess._posixsubprocess.close_fds` strategy.
|
||||||
|
try:
|
||||||
|
fd_names: list[str] = os.listdir('/proc/self/fd')
|
||||||
|
candidates: list[int] = [
|
||||||
|
int(n) for n in fd_names if n.isdigit()
|
||||||
|
]
|
||||||
|
except (FileNotFoundError, PermissionError):
|
||||||
|
import resource
|
||||||
|
soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
candidates = list(range(3, soft))
|
||||||
|
|
||||||
|
closed: int = 0
|
||||||
|
for fd in candidates:
|
||||||
|
if fd in keep:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
closed += 1
|
||||||
|
except OSError:
|
||||||
|
# fd was already closed (race with listdir) or otherwise
|
||||||
|
# unclosable — either is fine.
|
||||||
|
log.exception(
|
||||||
|
f'Failed to close inherited fd in child ??\n'
|
||||||
|
f'{fd!r}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
return closed
|
||||||
|
|
||||||
|
|
||||||
|
def _format_child_exit(
|
||||||
|
status: int,
|
||||||
|
) -> str:
|
||||||
|
'''
|
||||||
|
Render `os.waitpid()`-returned status as a short human
|
||||||
|
string (`'rc=0'` / `'signal=SIGABRT'` / etc.) for log
|
||||||
|
output.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if os.WIFEXITED(status):
|
||||||
|
return f'rc={os.WEXITSTATUS(status)}'
|
||||||
|
elif os.WIFSIGNALED(status):
|
||||||
|
sig: int = os.WTERMSIG(status)
|
||||||
|
return f'signal={signal.Signals(sig).name}'
|
||||||
|
else:
|
||||||
|
return f'raw_status={status}'
|
||||||
|
|
||||||
|
|
||||||
|
def wait_child(
|
||||||
|
pid: int,
|
||||||
|
*,
|
||||||
|
expect_exit_ok: bool = True,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
'''
|
||||||
|
`os.waitpid()` + classify the child's exit as
|
||||||
|
expected-or-not.
|
||||||
|
|
||||||
|
`expect_exit_ok=True` → expect clean `rc=0`. `False` →
|
||||||
|
expect abnormal death (any signal or nonzero rc). Used
|
||||||
|
by the control-case smoke-test scenario where CPython
|
||||||
|
is meant to abort the child.
|
||||||
|
|
||||||
|
Returns `(ok, status_str)` — `ok` reflects whether the
|
||||||
|
observed outcome matches `expect_exit_ok`, `status_str`
|
||||||
|
is a short render of the actual status.
|
||||||
|
|
||||||
|
'''
|
||||||
|
_, status = os.waitpid(pid, 0)
|
||||||
|
exited_normally: bool = (
|
||||||
|
os.WIFEXITED(status)
|
||||||
|
and
|
||||||
|
os.WEXITSTATUS(status) == 0
|
||||||
|
)
|
||||||
|
ok: bool = (
|
||||||
|
exited_normally
|
||||||
|
if expect_exit_ok
|
||||||
|
else not exited_normally
|
||||||
|
)
|
||||||
|
return ok, _format_child_exit(status)
|
||||||
|
|
||||||
|
|
||||||
|
def fork_from_worker_thread(
|
||||||
|
child_target: Callable[[], int] | None = None,
|
||||||
|
*,
|
||||||
|
thread_name: str = 'subint-forkserver',
|
||||||
|
join_timeout: float = 10.0,
|
||||||
|
|
||||||
|
) -> int:
|
||||||
|
'''
|
||||||
|
`os.fork()` from a main-interp worker thread; return the
|
||||||
|
forked child's pid.
|
||||||
|
|
||||||
|
The calling context **must** be the main interpreter
|
||||||
|
(not a subinterpreter) — that's the whole point of this
|
||||||
|
primitive. A regular `threading.Thread(target=...)`
|
||||||
|
spawned from main-interp code satisfies this
|
||||||
|
automatically because Python attaches the thread's
|
||||||
|
tstate to the *calling* interpreter, and our main
|
||||||
|
thread's calling interp is always main.
|
||||||
|
|
||||||
|
If `child_target` is provided, it runs IN the forked
|
||||||
|
child process before `os._exit` is called. The callable
|
||||||
|
should return an int used as the child's exit rc. If
|
||||||
|
`child_target` is None, the child `_exit(0)`s immediately
|
||||||
|
(useful for the baseline sanity case).
|
||||||
|
|
||||||
|
On the PARENT side, this function drives the worker
|
||||||
|
thread to completion (`fork()` returns near-instantly;
|
||||||
|
the thread is expected to exit promptly) and then
|
||||||
|
returns the forked child's pid. Raises `RuntimeError`
|
||||||
|
if the worker thread fails to return within
|
||||||
|
`join_timeout` seconds — that'd be an unexpected CPython
|
||||||
|
pathology.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if not _has_subints:
|
||||||
|
raise RuntimeError(
|
||||||
|
'subint-forkserver primitives require Python '
|
||||||
|
'3.14+ (public `concurrent.interpreters` module '
|
||||||
|
'not present on this runtime).'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a pipe to shuttle the forked child's pid from the
|
||||||
|
# worker thread back to the caller.
|
||||||
|
rfd, wfd = os.pipe()
|
||||||
|
|
||||||
|
def _worker() -> None:
|
||||||
|
'''
|
||||||
|
Runs on the forkserver worker thread. Forks; child
|
||||||
|
runs `child_target` (if any) and exits; parent side
|
||||||
|
writes the child pid to the pipe so the main-thread
|
||||||
|
caller can retrieve it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
pid: int = os.fork()
|
||||||
|
if pid == 0:
|
||||||
|
# CHILD: close the pid-pipe ends (we don't use
|
||||||
|
# them here), then scrub ALL other inherited FDs
|
||||||
|
# so the child starts with a clean slate
|
||||||
|
# (stdio-only). Critical for multi-level spawn
|
||||||
|
# trees — see `_close_inherited_fds()` docstring.
|
||||||
|
os.close(rfd)
|
||||||
|
os.close(wfd)
|
||||||
|
_close_inherited_fds()
|
||||||
|
rc: int = 0
|
||||||
|
if child_target is not None:
|
||||||
|
try:
|
||||||
|
rc = child_target() or 0
|
||||||
|
except BaseException as err:
|
||||||
|
log.error(
|
||||||
|
f'subint-forkserver child_target '
|
||||||
|
f'raised:\n'
|
||||||
|
f'|_{type(err).__name__}: {err}'
|
||||||
|
)
|
||||||
|
rc = 2
|
||||||
|
os._exit(rc)
|
||||||
|
else:
|
||||||
|
# PARENT (still inside the worker thread):
|
||||||
|
# hand the child pid back to main via pipe.
|
||||||
|
os.write(wfd, pid.to_bytes(8, 'little'))
|
||||||
|
|
||||||
|
worker: threading.Thread = threading.Thread(
|
||||||
|
target=_worker,
|
||||||
|
name=thread_name,
|
||||||
|
daemon=False,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
worker.join(timeout=join_timeout)
|
||||||
|
if worker.is_alive():
|
||||||
|
# Pipe cleanup best-effort before bail.
|
||||||
|
try:
|
||||||
|
os.close(rfd)
|
||||||
|
except OSError:
|
||||||
|
log.exception(
|
||||||
|
f'Failed to close PID-pipe read-fd in parent ??\n'
|
||||||
|
f'{rfd!r}\n'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.close(wfd)
|
||||||
|
except OSError:
|
||||||
|
log.exception(
|
||||||
|
f'Failed to close PID-pipe write-fd in parent ??\n'
|
||||||
|
f'{wfd!r}\n'
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f'subint-forkserver worker thread '
|
||||||
|
f'{thread_name!r} did not return within '
|
||||||
|
f'{join_timeout}s — this is unexpected since '
|
||||||
|
f'`os.fork()` should return near-instantly on '
|
||||||
|
f'the parent side.'
|
||||||
|
)
|
||||||
|
|
||||||
|
pid_bytes: bytes = os.read(rfd, 8)
|
||||||
|
os.close(rfd)
|
||||||
|
os.close(wfd)
|
||||||
|
pid: int = int.from_bytes(pid_bytes, 'little')
|
||||||
|
log.runtime(
|
||||||
|
f'subint-forkserver forked child\n'
|
||||||
|
f'(>\n'
|
||||||
|
f' |_pid={pid}\n'
|
||||||
|
)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _ForkedProc:
|
||||||
|
'''
|
||||||
|
Thin `trio.Process`-compatible shim around a raw OS pid
|
||||||
|
returned by `fork_from_worker_thread()`, exposing just
|
||||||
|
enough surface for the `soft_kill()` / hard-reap pattern
|
||||||
|
borrowed from `trio_proc()`.
|
||||||
|
|
||||||
|
Unlike `trio.Process`, we have no direct handles on the
|
||||||
|
child's std-streams (fork-without-exec inherits the
|
||||||
|
parent's FDs, but we don't marshal them into this
|
||||||
|
wrapper) — `.stdin`/`.stdout`/`.stderr` are all `None`,
|
||||||
|
which matches what `soft_kill()` handles via its
|
||||||
|
`is not None` guards.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self, pid: int):
|
||||||
|
self.pid: int = pid
|
||||||
|
self._returncode: int | None = None
|
||||||
|
# `soft_kill`/`hard_kill` check these for pipe
|
||||||
|
# teardown — all None since we didn't wire up pipes
|
||||||
|
# on the fork-without-exec path.
|
||||||
|
self.stdin = None
|
||||||
|
self.stdout = None
|
||||||
|
self.stderr = None
|
||||||
|
# pidfd (Linux 5.3+, Python 3.9+) — a file descriptor
|
||||||
|
# referencing this child process which becomes readable
|
||||||
|
# once the child exits. Enables a fully trio-cancellable
|
||||||
|
# wait via `trio.lowlevel.wait_readable()` — same
|
||||||
|
# pattern `trio.Process.wait()` uses under the hood, and
|
||||||
|
# the same pattern `multiprocessing.Process.sentinel`
|
||||||
|
# uses for `tractor.spawn._spawn.proc_waiter()`. Without
|
||||||
|
# this, waiting via `trio.to_thread.run_sync(os.waitpid,
|
||||||
|
# ...)` blocks a cache thread on a sync syscall that is
|
||||||
|
# NOT trio-cancellable, which prevents outer cancel
|
||||||
|
# scopes from unwedging a stuck-child cancel cascade.
|
||||||
|
self._pidfd: int = os.pidfd_open(pid)
|
||||||
|
|
||||||
|
def poll(self) -> int | None:
|
||||||
|
'''
|
||||||
|
Non-blocking liveness probe. Returns `None` if the
|
||||||
|
child is still running, else its exit code (negative
|
||||||
|
for signal-death, matching `subprocess.Popen`
|
||||||
|
convention).
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self._returncode is not None:
|
||||||
|
return self._returncode
|
||||||
|
try:
|
||||||
|
waited_pid, status = os.waitpid(self.pid, os.WNOHANG)
|
||||||
|
except ChildProcessError:
|
||||||
|
# already reaped (or never existed) — treat as
|
||||||
|
# clean exit for polling purposes.
|
||||||
|
self._returncode = 0
|
||||||
|
return 0
|
||||||
|
if waited_pid == 0:
|
||||||
|
return None
|
||||||
|
self._returncode = self._parse_status(status)
|
||||||
|
return self._returncode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def returncode(self) -> int | None:
|
||||||
|
return self._returncode
|
||||||
|
|
||||||
|
async def wait(self) -> int:
|
||||||
|
'''
|
||||||
|
Async, fully-trio-cancellable wait for the child's
|
||||||
|
exit. Uses `trio.lowlevel.wait_readable()` on the
|
||||||
|
`pidfd` sentinel — same pattern as `trio.Process.wait`
|
||||||
|
and `tractor.spawn._spawn.proc_waiter` (mp backend).
|
||||||
|
|
||||||
|
Safe to call multiple times; subsequent calls return
|
||||||
|
the cached rc without re-issuing the syscall.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self._returncode is not None:
|
||||||
|
return self._returncode
|
||||||
|
# Park until the pidfd becomes readable — the OS
|
||||||
|
# signals this exactly once on child exit. Cancellable
|
||||||
|
# via any outer trio cancel scope (this was the key
|
||||||
|
# fix vs. the prior `to_thread.run_sync(os.waitpid,
|
||||||
|
# abandon_on_cancel=False)` which blocked a thread on
|
||||||
|
# a sync syscall and swallowed cancels).
|
||||||
|
await trio.lowlevel.wait_readable(self._pidfd)
|
||||||
|
# pidfd signaled → reap non-blocking to collect the
|
||||||
|
# exit status. `WNOHANG` here is correct: by the time
|
||||||
|
# the pidfd is readable, `waitpid()` won't block.
|
||||||
|
try:
|
||||||
|
_, status = os.waitpid(self.pid, os.WNOHANG)
|
||||||
|
except ChildProcessError:
|
||||||
|
# already reaped by something else
|
||||||
|
status = 0
|
||||||
|
self._returncode = self._parse_status(status)
|
||||||
|
# pidfd is one-shot; close it so we don't leak fds
|
||||||
|
# across many spawns.
|
||||||
|
try:
|
||||||
|
os.close(self._pidfd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._pidfd = -1
|
||||||
|
return self._returncode
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
'''
|
||||||
|
OS-level `SIGKILL` to the child. Swallows
|
||||||
|
`ProcessLookupError` (already dead).
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
os.kill(self.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
# belt-and-braces: close the pidfd if `wait()` wasn't
|
||||||
|
# called (e.g. unexpected teardown path).
|
||||||
|
fd: int = getattr(self, '_pidfd', -1)
|
||||||
|
if fd >= 0:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _parse_status(self, status: int) -> int:
|
||||||
|
if os.WIFEXITED(status):
|
||||||
|
return os.WEXITSTATUS(status)
|
||||||
|
elif os.WIFSIGNALED(status):
|
||||||
|
# negative rc by `subprocess.Popen` convention
|
||||||
|
return -os.WTERMSIG(status)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f'<_ForkedProc pid={self.pid} '
|
||||||
|
f'returncode={self._returncode}>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def subint_forkserver_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],
|
||||||
|
*,
|
||||||
|
infect_asyncio: bool = False,
|
||||||
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED,
|
||||||
|
proc_kwargs: dict[str, any] = {},
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Spawn a subactor via `os.fork()` from a non-trio worker
|
||||||
|
thread (see `fork_from_worker_thread()`), with the forked
|
||||||
|
child running `tractor._child._actor_child_main()` and
|
||||||
|
connecting back via tractor's normal IPC handshake.
|
||||||
|
|
||||||
|
Supervision model mirrors `trio_proc()` — we manage a
|
||||||
|
real OS subprocess, so `Portal.cancel_actor()` +
|
||||||
|
`soft_kill()` on graceful teardown and `os.kill(SIGKILL)`
|
||||||
|
on hard-reap both apply directly (no
|
||||||
|
`_interpreters.destroy()` voodoo needed since the child
|
||||||
|
is in its own process).
|
||||||
|
|
||||||
|
The only real difference from `trio_proc` is the spawn
|
||||||
|
mechanism: fork from a known-clean main-interp worker
|
||||||
|
thread instead of `trio.lowlevel.open_process()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if not _has_subints:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'The {"subint_forkserver"!r} spawn backend '
|
||||||
|
f'requires Python 3.14+.\n'
|
||||||
|
f'Current runtime: {sys.version}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backend-scoped config pulled from `proc_kwargs`. Using
|
||||||
|
# `proc_kwargs` (vs a first-class kwarg on this function)
|
||||||
|
# matches how other backends expose per-spawn tuning
|
||||||
|
# (`trio_proc` threads it to `trio.lowlevel.open_process`,
|
||||||
|
# etc.) and keeps `ActorNursery.start_actor(proc_kwargs=...)`
|
||||||
|
# as the single ergonomic entry point.
|
||||||
|
child_sigint: ChildSigintMode = proc_kwargs.get(
|
||||||
|
'child_sigint',
|
||||||
|
_DEFAULT_CHILD_SIGINT,
|
||||||
|
)
|
||||||
|
if child_sigint not in ('ipc', 'trio'):
|
||||||
|
raise ValueError(
|
||||||
|
f'Invalid `child_sigint={child_sigint!r}` for '
|
||||||
|
f'`subint_forkserver` backend.\n'
|
||||||
|
f'Expected one of: {ChildSigintMode}.'
|
||||||
|
)
|
||||||
|
if child_sigint == 'trio':
|
||||||
|
raise NotImplementedError(
|
||||||
|
"`child_sigint='trio'` mode — trio-native SIGINT "
|
||||||
|
"plumbing in the fork-child — is scaffolded but "
|
||||||
|
"not yet implemented. See the xfail'd "
|
||||||
|
"`test_orphaned_subactor_sigint_cleanup_DRAFT` "
|
||||||
|
"and the TODO in this module's docstring."
|
||||||
|
)
|
||||||
|
|
||||||
|
uid: tuple[str, str] = subactor.aid.uid
|
||||||
|
loglevel: str | None = subactor.loglevel
|
||||||
|
|
||||||
|
# Closure captured into the fork-child's memory image.
|
||||||
|
# In the child this is the first post-fork Python code to
|
||||||
|
# run, on what was the fork-worker thread in the parent.
|
||||||
|
# `child_sigint` is captured here so the impl lands inside
|
||||||
|
# this function once the `'trio'` mode is wired up —
|
||||||
|
# nothing above this comment needs to change.
|
||||||
|
def _child_target() -> int:
|
||||||
|
# Dispatch on the captured SIGINT-mode closure var.
|
||||||
|
# Today only `'ipc'` is reachable (the `'trio'` branch
|
||||||
|
# is fenced off at the backend-entry guard above); the
|
||||||
|
# match is in place so the future `'trio'` impl slots
|
||||||
|
# in as a plain case arm without restructuring.
|
||||||
|
match child_sigint:
|
||||||
|
case 'ipc':
|
||||||
|
pass # <- current behavior: no child-side
|
||||||
|
# SIGINT plumbing; rely on parent
|
||||||
|
# `Portal.cancel_actor()` IPC path.
|
||||||
|
case 'trio':
|
||||||
|
# Unreachable today (see entry-guard above);
|
||||||
|
# this stub exists so that lifting the guard
|
||||||
|
# is the only change required to enable
|
||||||
|
# `'trio'` mode once the SIGINT wakeup-fd
|
||||||
|
# bridge is implemented.
|
||||||
|
raise NotImplementedError(
|
||||||
|
"`child_sigint='trio'` fork-prelude "
|
||||||
|
"plumbing not yet wired."
|
||||||
|
)
|
||||||
|
# Lazy import so the parent doesn't pay for it on
|
||||||
|
# every spawn — it's module-level in `_child` but
|
||||||
|
# cheap enough to re-resolve here.
|
||||||
|
from tractor._child import _actor_child_main
|
||||||
|
# XXX, `os.fork()` inherits the parent's entire memory
|
||||||
|
# image, including `tractor.runtime._state._runtime_vars`
|
||||||
|
# (which in the parent encodes "this process IS the root
|
||||||
|
# actor"). A fresh `exec`-based child starts cold; we
|
||||||
|
# replicate that here by explicitly resetting runtime
|
||||||
|
# vars to their fresh-process defaults — otherwise
|
||||||
|
# `Actor.__init__` takes the `is_root_process() == True`
|
||||||
|
# branch, pre-populates `self.enable_modules`, and trips
|
||||||
|
# the `assert not self.enable_modules` gate at the top
|
||||||
|
# of `Actor._from_parent()` on the subsequent parent→
|
||||||
|
# child `SpawnSpec` handshake. (`_state._current_actor`
|
||||||
|
# is unconditionally overwritten by `_trio_main` → no
|
||||||
|
# reset needed for it.)
|
||||||
|
from tractor.runtime._state import (
|
||||||
|
get_runtime_vars,
|
||||||
|
set_runtime_vars,
|
||||||
|
)
|
||||||
|
set_runtime_vars(get_runtime_vars(clear_values=True))
|
||||||
|
_actor_child_main(
|
||||||
|
uid=uid,
|
||||||
|
loglevel=loglevel,
|
||||||
|
parent_addr=parent_addr,
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
|
# The child's runtime is trio-native (uses
|
||||||
|
# `_trio_main` + receives `SpawnSpec` over IPC),
|
||||||
|
# but label it with the actual parent-side spawn
|
||||||
|
# mechanism so `Actor.pformat()` / log lines
|
||||||
|
# reflect reality. Downstream runtime gates that
|
||||||
|
# key on `_spawn_method` group `subint_forkserver`
|
||||||
|
# alongside `trio`/`subint` where the SpawnSpec
|
||||||
|
# IPC handshake is concerned — see
|
||||||
|
# `runtime._runtime.Actor._from_parent()`.
|
||||||
|
spawn_method='subint_forkserver',
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cancelled_during_spawn: bool = False
|
||||||
|
proc: _ForkedProc | None = None
|
||||||
|
ipc_server: _server.Server = actor_nursery._actor.ipc_server
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
pid: int = await trio.to_thread.run_sync(
|
||||||
|
partial(
|
||||||
|
fork_from_worker_thread,
|
||||||
|
_child_target,
|
||||||
|
thread_name=(
|
||||||
|
f'subint-forkserver[{name}]'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
abandon_on_cancel=False,
|
||||||
|
)
|
||||||
|
proc = _ForkedProc(pid)
|
||||||
|
log.runtime(
|
||||||
|
f'Forked subactor via forkserver\n'
|
||||||
|
f'(>\n'
|
||||||
|
f' |_{proc}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
event, chan = await ipc_server.wait_for_peer(uid)
|
||||||
|
|
||||||
|
except trio.Cancelled:
|
||||||
|
cancelled_during_spawn = True
|
||||||
|
raise
|
||||||
|
|
||||||
|
assert proc is not None
|
||||||
|
|
||||||
|
portal = Portal(chan)
|
||||||
|
actor_nursery._children[uid] = (
|
||||||
|
subactor,
|
||||||
|
proc,
|
||||||
|
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 forkserver 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 nursery:
|
||||||
|
if portal in actor_nursery._cancel_after_result_on_exit:
|
||||||
|
nursery.start_soon(
|
||||||
|
cancel_on_completion,
|
||||||
|
portal,
|
||||||
|
subactor,
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# reuse `trio_proc`'s soft-kill dance — `proc`
|
||||||
|
# is our `_ForkedProc` shim which implements the
|
||||||
|
# same `.poll()` / `.wait()` / `.kill()` surface
|
||||||
|
# `soft_kill` expects.
|
||||||
|
await soft_kill(
|
||||||
|
proc,
|
||||||
|
_ForkedProc.wait,
|
||||||
|
portal,
|
||||||
|
)
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Hard reap: SIGKILL + waitpid. Cheap since we have
|
||||||
|
# the real OS pid, unlike `subint_proc` which has to
|
||||||
|
# fuss with `_interpreters.destroy()` races.
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
log.cancel(
|
||||||
|
f'Hard killing forkserver subactor\n'
|
||||||
|
f'>x)\n'
|
||||||
|
f' |_{proc}\n'
|
||||||
|
)
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
if not cancelled_during_spawn:
|
||||||
|
actor_nursery._children.pop(uid, None)
|
||||||
|
|
@ -246,7 +246,11 @@ async def trio_proc(
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
|
|
||||||
await debug.maybe_wait_for_debugger(
|
await debug.maybe_wait_for_debugger(
|
||||||
child_in_debug=get_runtime_vars().get(
|
# NOTE: use the child's `_runtime_vars`
|
||||||
|
# (the fn-arg dict shipped via `SpawnSpec`)
|
||||||
|
# — NOT `get_runtime_vars()` which returns
|
||||||
|
# the *parent's* live runtime state.
|
||||||
|
child_in_debug=_runtime_vars.get(
|
||||||
'_debug_mode', False
|
'_debug_mode', False
|
||||||
),
|
),
|
||||||
header_msg=(
|
header_msg=(
|
||||||
|
|
|
||||||
257
uv.lock
257
uv.lock
|
|
@ -1,6 +1,10 @@
|
||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12, <3.14"
|
requires-python = ">=3.13, <3.15"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.14'",
|
||||||
|
"python_full_version < '3.14'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-generator"
|
name = "async-generator"
|
||||||
|
|
@ -44,18 +48,6 @@ version = "1.0.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" },
|
||||||
|
|
@ -80,6 +72,30 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/2a/9f13ea01b03b1b4751a1cc2b6c1ef4b782e19433a59cf35b59cafb2a2696/blake3-1.0.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2c33dac2c6112bc23f961a7ca305c7e34702c8177040eb98d0389d13a347b9e1", size = 347016, upload-time = "2025-10-14T06:46:40.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/8e/8458c4285fbc5de76414f243e4e0fcab795d71a8b75324e14959aee699da/blake3-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c445eff665d21c3b3b44f864f849a2225b1164c08654beb23224a02f087b7ff1", size = 324496, upload-time = "2025-10-14T06:46:42.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/fa/b913eb9cc4af708c03e01e6b88a8bb3a74833ba4ae4b16b87e2829198e06/blake3-1.0.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47939f04b89c5c6ff1e51e883e5efab1ea1bf01a02f4d208d216dddd63d0dd8", size = 370654, upload-time = "2025-10-14T06:46:43.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/4f/245e0800c33b99c8f2b570d9a7199b51803694913ee4897f339648502933/blake3-1.0.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73e0b4fa25f6e3078526a592fb38fca85ef204fd02eced6731e1cdd9396552d4", size = 374693, upload-time = "2025-10-14T06:46:45.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/a6/8cb182c8e482071dbdfcc6ec0048271fd48bcb78782d346119ff54993700/blake3-1.0.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0543c57eb9d6dac9d4bced63e9f7f7b546886ac04cec8da3c3d9c8f30cbbb7", size = 447673, upload-time = "2025-10-14T06:46:46.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b7/1cbbb5574d2a9436d1b15e7eb5b9d82e178adcaca71a97b0fddaca4bfe3a/blake3-1.0.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed972ebd553c0c25363459e9fc71a38c045d8419e365b59acd8cd791eff13981", size = 507233, upload-time = "2025-10-14T06:46:48.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/45/b55825d90af353b3e26c653bab278da9d6563afcf66736677f9397e465be/blake3-1.0.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bafdec95dfffa3f6571e529644744e280337df15ddd9728f224ba70c5779b23", size = 393852, upload-time = "2025-10-14T06:46:49.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/73/9058a1a457dd20491d1b37de53d6876eff125e1520d9b2dd7d0acbc88de2/blake3-1.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d78f06f3fb838b34c330e2987090376145cbe5944d8608a0c4779c779618f7b", size = 386442, upload-time = "2025-10-14T06:46:51.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/6d/561d537ffc17985e276e08bf4513f1c106f1fdbef571e782604dc4e44070/blake3-1.0.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd03ff08d1b6e4fdda1cd03826f971ae8966ef6f683a8c68aa27fb21904b5aa9", size = 549929, upload-time = "2025-10-14T06:46:52.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/2f/dbe20d2c57f1a67c63be4ba310bcebc707b945c902a0bde075d2a8f5cd5c/blake3-1.0.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4e02a3c499e35bf51fc15b2738aca1a76410804c877bcd914752cac4f71f052a", size = 553750, upload-time = "2025-10-14T06:46:54.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/da/c6cb712663c869b2814870c2798e57289c4268c5ac5fb12d467fce244860/blake3-1.0.8-cp314-cp314-win32.whl", hash = "sha256:a585357d5d8774aad9ffc12435de457f9e35cde55e0dc8bc43ab590a6929e59f", size = 228404, upload-time = "2025-10-14T06:46:56.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/b6/c7dcd8bc3094bba1c4274e432f9e77a7df703532ca000eaa550bd066b870/blake3-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:9ab5998e2abd9754819753bc2f1cf3edf82d95402bff46aeef45ed392a5468bf", size = 215460, upload-time = "2025-10-14T06:46:58.15Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/3c/6c8afd856c353176836daa5cc33a7989e8f54569e9d53eb1c53fc8f80c34/blake3-1.0.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2df12f295f95a804338bd300e8fad4a6f54fd49bd4d9c5893855a230b5188a8", size = 347482, upload-time = "2025-10-14T06:47:00.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/35/92cd5501ce8e1f5cabdc0c3ac62d69fdb13ff0b60b62abbb2b6d0a53a790/blake3-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:63379be58438878eeb76ebe4f0efbeaabf42b79f2cff23b6126b7991588ced67", size = 324376, upload-time = "2025-10-14T06:47:01.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/33/503b37220a3e2e31917ef13722efd00055af51c5e88ae30974c733d7ece6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88d527c247f9609dc1d45a08fd243e39f0d5300d54c57e048de24d4fa9240ebb", size = 370220, upload-time = "2025-10-14T06:47:02.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/df/fe817843adf59516c04d44387bd643b422a3b0400ea95c6ede6a49920737/blake3-1.0.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506a47897a11ebe8f3cdeb52f1365d6a2f83959e98ccb0c830f8f73277d4d358", size = 373454, upload-time = "2025-10-14T06:47:03.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/4d/90a2a623575373dfc9b683f1bad1bf017feafa5a6d65d94fb09543050740/blake3-1.0.8-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5122a61b3b004bbbd979bdf83a3aaab432da3e2a842d7ddf1c273f2503b4884", size = 447102, upload-time = "2025-10-14T06:47:04.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/ff/4e8ce314f60115c4c657b1fdbe9225b991da4f5bcc5d1c1f1d151e2f39d6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0171e85d56dec1219abdae5f49a0ed12cb3f86a454c29160a64fd8a8166bba37", size = 506791, upload-time = "2025-10-14T06:47:06.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/88/2963a1f18aab52bdcf35379b2b48c34bbc462320c37e76960636b8602c36/blake3-1.0.8-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:003f61e8c41dd9931edddf1cc6a1bb680fb2ac0ad15493ef4a1df9adc59ce9df", size = 393717, upload-time = "2025-10-14T06:47:09.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/d1/a848ed8e8d4e236b9b16381768c9ae99d92890c24886bb4505aa9c3d2033/blake3-1.0.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c3151955efb09ba58cd3e1263521e15e9e3866a40d6bd3556d86fc968e8f95", size = 386150, upload-time = "2025-10-14T06:47:10.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/09/e3eb5d60f97c01de23d9f434e6e1fc117efb466eaa1f6ddbbbcb62580d6e/blake3-1.0.8-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:5eb25bca3cee2e0dd746a214784fb36be6a43640c01c55b6b4e26196e72d076c", size = 549120, upload-time = "2025-10-14T06:47:11.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/ad/3d9661c710febb8957dd685fdb3e5a861aa0ac918eda3031365ce45789e2/blake3-1.0.8-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ab4e1dea4fa857944944db78e8f20d99ee2e16b2dea5a14f514fb0607753ac83", size = 553264, upload-time = "2025-10-14T06:47:13.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/55/e332a5b49edf377d0690e95951cca21a00c568f6e37315f9749efee52617/blake3-1.0.8-cp314-cp314t-win32.whl", hash = "sha256:67f1bc11bf59464ef092488c707b13dd4e872db36e25c453dfb6e0c7498df9f1", size = 228116, upload-time = "2025-10-14T06:47:14.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -91,17 +107,6 @@ dependencies = [
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
|
||||||
|
|
@ -150,9 +155,9 @@ name = "greenback"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet", marker = "python_full_version < '3.14'" },
|
||||||
{ name = "outcome" },
|
{ name = "outcome", marker = "python_full_version < '3.14'" },
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio", marker = "python_full_version < '3.14'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597, upload-time = "2024-02-20T21:23:13.239Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|
@ -165,15 +170,6 @@ version = "3.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022, upload-time = "2024-09-20T18:21:04.506Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022, upload-time = "2024-09-20T18:21:04.506Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260, upload-time = "2024-09-20T17:08:07.301Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064, upload-time = "2024-09-20T17:36:47.628Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420, upload-time = "2024-09-20T17:39:21.258Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035, upload-time = "2024-09-20T17:44:26.501Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105, upload-time = "2024-09-20T17:08:42.048Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077, upload-time = "2024-09-20T17:08:33.707Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975, upload-time = "2024-09-20T17:44:15.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955, upload-time = "2024-09-20T17:09:25.539Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655, upload-time = "2024-09-20T17:21:22.427Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990, upload-time = "2024-09-20T17:08:26.312Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990, upload-time = "2024-09-20T17:08:26.312Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175, upload-time = "2024-09-20T17:36:48.983Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175, upload-time = "2024-09-20T17:36:48.983Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425, upload-time = "2024-09-20T17:39:22.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425, upload-time = "2024-09-20T17:39:22.705Z" },
|
||||||
|
|
@ -228,22 +224,6 @@ version = "5.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" },
|
||||||
|
|
@ -265,6 +245,43 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -281,14 +298,6 @@ version = "0.21.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" },
|
||||||
|
|
@ -297,6 +306,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -547,6 +572,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-timeout"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-baseconv"
|
name = "python-baseconv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -633,7 +670,6 @@ version = "0.1.0a6.dev0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "cffi" },
|
|
||||||
{ name = "colorlog" },
|
{ name = "colorlog" },
|
||||||
{ name = "msgspec" },
|
{ name = "msgspec" },
|
||||||
{ name = "multiaddr" },
|
{ name = "multiaddr" },
|
||||||
|
|
@ -646,21 +682,23 @@ dependencies = [
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "greenback" },
|
|
||||||
{ name = "pexpect" },
|
{ name = "pexpect" },
|
||||||
{ name = "prompt-toolkit" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "pyperclip" },
|
{ name = "pyperclip" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-timeout" },
|
||||||
{ name = "stackscope" },
|
{ name = "stackscope" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "xonsh" },
|
{ name = "xonsh" },
|
||||||
]
|
]
|
||||||
devx = [
|
devx = [
|
||||||
{ name = "greenback" },
|
|
||||||
{ name = "stackscope" },
|
{ name = "stackscope" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
|
eventfd = [
|
||||||
|
{ name = "cffi", marker = "python_full_version < '3.14'" },
|
||||||
|
]
|
||||||
lint = [
|
lint = [
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
@ -670,17 +708,23 @@ repl = [
|
||||||
{ name = "pyperclip" },
|
{ name = "pyperclip" },
|
||||||
{ name = "xonsh" },
|
{ name = "xonsh" },
|
||||||
]
|
]
|
||||||
|
subints = [
|
||||||
|
{ name = "msgspec", marker = "python_full_version >= '3.14'" },
|
||||||
|
]
|
||||||
|
sync-pause = [
|
||||||
|
{ name = "greenback", marker = "python_full_version < '3.14'" },
|
||||||
|
]
|
||||||
testing = [
|
testing = [
|
||||||
{ name = "pexpect" },
|
{ name = "pexpect" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-timeout" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bidict", specifier = ">=0.23.1" },
|
{ name = "bidict", specifier = ">=0.23.1" },
|
||||||
{ name = "cffi", specifier = ">=1.17.1" },
|
|
||||||
{ name = "colorlog", specifier = ">=6.8.2,<7" },
|
{ name = "colorlog", specifier = ">=6.8.2,<7" },
|
||||||
{ name = "msgspec", specifier = ">=0.21.0" },
|
{ name = "msgspec", specifier = ">=0.20.0" },
|
||||||
{ name = "multiaddr", specifier = ">=0.2.0" },
|
{ name = "multiaddr", specifier = ">=0.2.0" },
|
||||||
{ name = "pdbp", specifier = ">=1.8.2,<2" },
|
{ name = "pdbp", specifier = ">=1.8.2,<2" },
|
||||||
{ name = "platformdirs", specifier = ">=4.4.0" },
|
{ name = "platformdirs", specifier = ">=4.4.0" },
|
||||||
|
|
@ -691,31 +735,34 @@ requires-dist = [
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "greenback", specifier = ">=1.2.1,<2" },
|
|
||||||
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
||||||
{ 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 = ">=8.3.5" },
|
{ name = "pytest", specifier = ">=8.3.5" },
|
||||||
|
{ 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" },
|
||||||
{ name = "xonsh", specifier = ">=0.22.2" },
|
{ name = "xonsh", editable = "../xonsh" },
|
||||||
]
|
]
|
||||||
devx = [
|
devx = [
|
||||||
{ name = "greenback", specifier = ">=1.2.1,<2" },
|
|
||||||
{ 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" },
|
||||||
]
|
]
|
||||||
|
eventfd = [{ name = "cffi", marker = "python_full_version == '3.13.*'", specifier = ">=1.17.1" }]
|
||||||
lint = [{ name = "ruff", specifier = ">=0.9.6" }]
|
lint = [{ name = "ruff", specifier = ">=0.9.6" }]
|
||||||
repl = [
|
repl = [
|
||||||
{ 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 = "xonsh", specifier = ">=0.22.2" },
|
{ name = "xonsh", editable = "../xonsh" },
|
||||||
]
|
]
|
||||||
|
subints = [{ name = "msgspec", marker = "python_full_version >= '3.14'", specifier = ">=0.21.0" }]
|
||||||
|
sync-pause = [{ name = "greenback", marker = "python_full_version == '3.13.*'", specifier = ">=1.2.1,<2" }]
|
||||||
testing = [
|
testing = [
|
||||||
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
{ name = "pexpect", specifier = ">=4.9.0,<5" },
|
||||||
{ name = "pytest", specifier = ">=8.3.5" },
|
{ name = "pytest", specifier = ">=8.3.5" },
|
||||||
|
{ name = "pytest-timeout", specifier = ">=2.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -794,17 +841,6 @@ version = "1.17.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" },
|
||||||
|
|
@ -832,14 +868,61 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xonsh"
|
name = "xonsh"
|
||||||
version = "0.22.4"
|
source = { editable = "../xonsh" }
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/48/df/1fc9ed62b3d7c14612e1713e9eb7bd41d54f6ad1028a8fbb6b7cddebc345/xonsh-0.22.4.tar.gz", hash = "sha256:6be346563fec2db75778ba5d2caee155525e634e99d9cc8cc347626025c0b3fa", size = 826665, upload-time = "2026-02-17T07:53:39.424Z" }
|
[package.metadata]
|
||||||
wheels = [
|
requires-dist = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/00/7cbc0c1fb64365a0a317c54ce3a151c9644eea5a509d9cbaae61c9fd1426/xonsh-0.22.4-py311-none-any.whl", hash = "sha256:38b29b29fa85aa756462d9d9bbcaa1d85478c2108da3de6cc590a69a4bcd1a01", size = 654375, upload-time = "2026-02-17T07:53:37.702Z" },
|
{ name = "click", marker = "extra == 'full'" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/c2/3dd498dc28d8f89cdd52e39950c5e591499ae423f61694c0bb4d03ed1d82/xonsh-0.22.4-py312-none-any.whl", hash = "sha256:4e538fac9f4c3d866ddbdeca068f0c0515469c997ed58d3bfee963878c6df5a5", size = 654300, upload-time = "2026-02-17T07:53:35.813Z" },
|
{ name = "coverage", marker = "extra == 'test'", specifier = ">=5.3.1" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/7d/1f9c7147518e9f03f6ce081b5bfc4f1aceb6ec5caba849024d005e41d3be/xonsh-0.22.4-py313-none-any.whl", hash = "sha256:cc5fabf0ad0c56a2a11bed1e6a43c4ec6416a5b30f24f126b8e768547c3793e2", size = 654818, upload-time = "2026-02-17T07:53:33.477Z" },
|
{ name = "distro", marker = "sys_platform == 'linux' and extra == 'full'" },
|
||||||
|
{ name = "distro", marker = "extra == 'linux'" },
|
||||||
|
{ name = "furo", marker = "extra == 'doc'" },
|
||||||
|
{ name = "gnureadline", marker = "sys_platform == 'darwin' and extra == 'full'" },
|
||||||
|
{ name = "gnureadline", marker = "extra == 'mac'" },
|
||||||
|
{ name = "matplotlib", marker = "extra == 'doc'" },
|
||||||
|
{ name = "myst-parser", marker = "extra == 'doc'" },
|
||||||
|
{ name = "numpydoc", marker = "extra == 'doc'" },
|
||||||
|
{ name = "pre-commit", marker = "extra == 'dev'" },
|
||||||
|
{ name = "prompt-toolkit", marker = "extra == 'bestshell'", specifier = ">=3.0.29" },
|
||||||
|
{ name = "prompt-toolkit", marker = "extra == 'ptk'", specifier = ">=3.0.29" },
|
||||||
|
{ name = "prompt-toolkit", marker = "extra == 'test'", specifier = ">=3.0.29" },
|
||||||
|
{ name = "psutil", marker = "extra == 'doc'" },
|
||||||
|
{ name = "pygments", marker = "extra == 'bestshell'", specifier = ">=2.2" },
|
||||||
|
{ name = "pygments", marker = "extra == 'pygments'", specifier = ">=2.2" },
|
||||||
|
{ name = "pygments", marker = "extra == 'test'", specifier = ">=2.2" },
|
||||||
|
{ name = "pyperclip", marker = "extra == 'ptk'" },
|
||||||
|
{ name = "pyte", marker = "extra == 'test'", specifier = ">=0.8.0" },
|
||||||
|
{ name = "pytest", marker = "extra == 'test'", specifier = ">=7" },
|
||||||
|
{ name = "pytest-cov", marker = "extra == 'test'" },
|
||||||
|
{ name = "pytest-mock", marker = "extra == 'test'" },
|
||||||
|
{ name = "pytest-rerunfailures", marker = "extra == 'test'" },
|
||||||
|
{ name = "pytest-subprocess", marker = "extra == 'test'" },
|
||||||
|
{ name = "pytest-timeout", marker = "extra == 'test'" },
|
||||||
|
{ name = "pyzmq", marker = "extra == 'doc'" },
|
||||||
|
{ name = "re-ver", marker = "extra == 'dev'" },
|
||||||
|
{ name = "requests", marker = "extra == 'test'" },
|
||||||
|
{ name = "restructuredtext-lint", marker = "extra == 'test'" },
|
||||||
|
{ name = "runthis-sphinxext", marker = "extra == 'doc'" },
|
||||||
|
{ name = "setproctitle", marker = "sys_platform == 'win32' and extra == 'full'" },
|
||||||
|
{ name = "setproctitle", marker = "extra == 'proctitle'" },
|
||||||
|
{ name = "sphinx", marker = "extra == 'doc'", specifier = ">=3.1" },
|
||||||
|
{ name = "sphinx-autobuild", marker = "extra == 'doc'" },
|
||||||
|
{ name = "sphinx-prompt", marker = "extra == 'doc'" },
|
||||||
|
{ name = "sphinx-reredirects", marker = "extra == 'doc'" },
|
||||||
|
{ name = "sphinx-sitemap", marker = "extra == 'doc'" },
|
||||||
|
{ name = "tomli", marker = "extra == 'dev'" },
|
||||||
|
{ name = "tornado", marker = "extra == 'doc'" },
|
||||||
|
{ name = "ujson", marker = "extra == 'full'" },
|
||||||
|
{ name = "virtualenv", marker = "extra == 'test'", specifier = ">=20.16.2" },
|
||||||
|
{ name = "xonsh", extras = ["bestshell"], marker = "extra == 'doc'" },
|
||||||
|
{ name = "xonsh", extras = ["bestshell"], marker = "extra == 'test'" },
|
||||||
|
{ name = "xonsh", extras = ["doc", "test"], marker = "extra == 'dev'" },
|
||||||
|
{ name = "xonsh", extras = ["ptk", "pygments"], marker = "extra == 'full'" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["ptk", "pygments", "mac", "linux", "proctitle", "full", "bestshell", "test", "dev", "doc"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "xonsh", extras = ["dev"] }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue