From 9cf3d588e7e25578e82ab39034bfce5b8c21fa1f Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 19:15:21 -0400 Subject: [PATCH 01/26] Ignore notes & snippets subdirs in `git` --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 3537652e4..4ff5ebbdd 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,13 @@ venv.bak/ # all files under .git/ +# require very explicit staging for anything we **really** +# want put/kept in repo. +notes_to_self/ +snippets/ + + +# ------- AI shiz ------- # any commit-msg gen tmp files .claude/skills/commit-msg/msgs/ .claude/git_commit_msg_LATEST.md From d3d6f646f9c48bad8e4e0ca1f09493c1750bc5e3 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 10 Apr 2026 20:46:59 -0400 Subject: [PATCH 02/26] Reorganize `.gitignore` by skill/purpose Group `.claude/` ignores per-skill instead of a flat list: `ai.skillz` symlinks, `/open-wkt`, `/code-review-changes`, `/pr-msg`, `/commit-msg`. Add missing symlink entries (`yt-url-lookup` -> `resolve-conflicts`, `inter-skill-review`). Drop stale `Claude worktrees` section (already covered by `.claude/wkts/`). (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .gitignore | 60 ++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 4ff5ebbdd..7c7a6fe59 100644 --- a/.gitignore +++ b/.gitignore @@ -111,48 +111,50 @@ venv.bak/ notes_to_self/ snippets/ - # ------- AI shiz ------- -# any commit-msg gen tmp files -.claude/skills/commit-msg/msgs/ -.claude/git_commit_msg_LATEST.md -.claude/*_commit_*.md -.claude/*_commit*.toml -.claude/*_commit*.txt -.claude/skills/commit-msg/msgs/* +# `ai.skillz` symlinks, +# (machine-local, deploy via deploy-skill.sh) +.claude/skills/py-codestyle +.claude/skills/close-wkt +.claude/skills/plan-io +.claude/skills/prompt-io +.claude/skills/resolve-conflicts +.claude/skills/inter-skill-review -.claude/skills/pr-msg/msgs/* -# XXX, for rn, so i can telescope this file. -!/.claude/skills/pr-msg/pr_msg_LATEST.md +# /open-wkt specifics +.claude/skills/open-wkt +.claude/wkts/ +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 -# per-skill session/conf (machine-local) -.claude/skills/*/conf.toml +# /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 -# ai.skillz symlinks (machine-local, deploy via deploy-skill.sh) -.claude/skills/py-codestyle -.claude/skills/code-review-changes -.claude/skills/close-wkt -.claude/skills/open-wkt -.claude/skills/plan-io -.claude/skills/prompt-io -.claude/skills/resolve-conflicts -.claude/skills/inter-skill-review -.claude/skills/yt-url-lookup +# /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 -# hybrid skills — symlinked SKILL.md + references -.claude/skills/commit-msg/SKILL.md -.claude/skills/pr-msg/SKILL.md -.claude/skills/pr-msg/references +# use prompt-io instead? +.claude/plans # nix develop --profile .nixdev .nixdev* # :Obsession . Session.vim + # `gish` local `.md`-files # TODO? better all around automation! # -[ ] it'd be handy to also commit and sync with wtv git service? @@ -166,7 +168,3 @@ gh/ # LLM conversations that should remain private docs/conversations/ - -# Claude worktrees -.claude/wkts/ -claude_wkts From ba86d482e3848ff194e45246b9ac00c69089ccee Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 17:33:46 -0400 Subject: [PATCH 03/26] Add `lastfailed` cache inspection to `/run-tests` skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Inspect last failures" section reads the pytest `lastfailed` cache JSON directly — instant, no collection overhead, and filters to `tests/`-prefixed entries to avoid stale junk paths. Also, - add `jq` tool permission for `.pytest_cache/` files (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .claude/skills/run-tests/SKILL.md | 44 ++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md index 1d2b284af..f185c9278 100644 --- a/.claude/skills/run-tests/SKILL.md +++ b/.claude/skills/run-tests/SKILL.md @@ -12,6 +12,7 @@ allowed-tools: - Bash(UV_PROJECT_ENVIRONMENT=py* uv sync *) - Bash(ls *) - Bash(cat *) + - Bash(jq * .pytest_cache/*) - Read - Grep - Glob @@ -217,7 +218,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 ``` -### 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 python -m pytest --lf -x --tb=short --no-header ``` From b1a0753a3fb0dad536fb33c103f14356e63b70fe Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 18:05:32 -0400 Subject: [PATCH 04/26] Expand `/run-tests` venv pre-flight to cover all cases Rework section 3 from a worktree-only check into a structured 3-step flow: detect active venv, interpret results (Case A: active, B: none, C: worktree), then run import + collection checks. Deats, - Case B prompts via `AskUserQuestion` when no venv is detected, offering `uv sync` or manual activate - add `uv run` fallback section for envs where venv activation isn't practical - new allowed-tools: `uv run python`, `uv run pytest`, `uv pip show`, `AskUserQuestion` (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .claude/skills/run-tests/SKILL.md | 109 ++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md index f185c9278..946e871e0 100644 --- a/.claude/skills/run-tests/SKILL.md +++ b/.claude/skills/run-tests/SKILL.md @@ -8,8 +8,11 @@ allowed-tools: - Bash(python -m pytest *) - Bash(python -c *) - 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 pip show *) + - Bash(git rev-parse *) - Bash(ls *) - Bash(cat *) - Bash(jq * .pytest_cache/*) @@ -17,6 +20,7 @@ allowed-tools: - Grep - Glob - Task + - AskUserQuestion --- Run the `tractor` test suite using `pytest`. Follow this @@ -91,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" ``` -## 3. Pre-flight checks (before running tests) +## 3. Pre-flight: venv detection (MANDATORY) + +**Always verify a `uv` venv is active before running +`python` or `pytest`.** This project uses +`UV_PROJECT_ENVIRONMENT=py` naming (e.g. +`py313`) — never `.venv`. + +### Step 1: detect active venv + +Run this check first: + +```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/` dir under the project +root or worktree): -### Worktree venv detection +Use bare `python` / `python -m pytest` for all +commands. This is the normal, fast path. -If running inside a git worktree (`git rev-parse ---git-common-dir` differs from `--git-dir`), verify -the Python being used is from the **worktree's own -venv**, not the main repo's. Check: +**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 uv sync`, +> or would you prefer to activate your shell venv +> first?" + +Options: +1. **"Create/sync venv"** — run + `UV_PROJECT_ENVIRONMENT=py uv sync` where + `` is detected from `python --version` + (e.g. `313` for 3.13). Then use + `py/bin/python` for all subsequent + commands in this session. +2. **"I'll activate it myself"** — stop and let the + user `source py/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 python -c "import tractor; print(tractor.__file__)" ``` -If the path points outside the worktree (e.g. to -the main repo), set up a local venv first: +If the path points outside the worktree, create a +worktree-local venv: ```sh UV_PROJECT_ENVIRONMENT=py uv sync ``` -where `` matches the active cpython minor -version (detect via `python --version`, e.g. -`py313` for 3.13, `py314` for 3.14). Then use -`py/bin/python` for all subsequent commands. +Then use `py/bin/python` for all commands. + +**Why this matters**: without the correct venv, +subprocesses spawned by tractor resolve modules +from the wrong editable install, causing spurious +`AttributeError` / `ModuleNotFoundError`. + +### Fallback: `uv run` + +If the user can't or won't activate a venv, all +`python` and `pytest` commands can be prefixed +with `UV_PROJECT_ENVIRONMENT=py 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' +``` -**Why this matters**: without a worktree-local venv, -subprocesses spawned by tractor resolve modules from -the main repo's editable install, causing spurious -`AttributeError` / `ModuleNotFoundError` for code -that only exists on the worktree's branch. +`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. -### Import + collection checks +### Step 3: import + collection checks -Always run these, especially after refactors or -module moves — they catch import errors instantly: +After venv is confirmed, always run these +(especially after refactors or module moves): ```sh # 1. package import smoke check From b524ee463389982cf0c69a926f25725af6c5004c Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 16 Apr 2026 17:47:16 -0400 Subject: [PATCH 05/26] Bump `xonsh` to latest pre `0.23` release --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fee5547a3..1a36cdd6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ testing = [ repl = [ "pyperclip>=1.9.0", "prompt-toolkit>=3.0.50", - "xonsh>=0.22.2", + "xonsh>=0.22.8", "psutil>=7.0.0", ] lint = [ diff --git a/uv.lock b/uv.lock index dd19b5ead..98a6ef0b9 100644 --- a/uv.lock +++ b/uv.lock @@ -699,7 +699,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" }, { name = "typing-extensions", specifier = ">=4.14.1" }, - { name = "xonsh", specifier = ">=0.22.2" }, + { name = "xonsh", specifier = ">=0.22.8" }, ] devx = [ { name = "greenback", specifier = ">=1.2.1,<2" }, @@ -711,7 +711,7 @@ repl = [ { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, - { name = "xonsh", specifier = ">=0.22.2" }, + { name = "xonsh", specifier = ">=0.22.8" }, ] testing = [ { name = "pexpect", specifier = ">=4.9.0,<5" }, @@ -832,13 +832,13 @@ wheels = [ [[package]] name = "xonsh" -version = "0.22.4" +version = "0.22.8" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/99/d7/269a167722c8cea95204add643664a7dcb445ef50f5810a924ca7fd3e3f2/xonsh-0.22.8.tar.gz", hash = "sha256:5eedf7822f1655eb4f29a7c3916b0dcc7b8fe548b4b83001afe79f3bd39be28c", size = 830111, upload-time = "2026-03-17T11:58:35.193Z" } wheels = [ - { 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" }, - { 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" }, - { 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" }, + { url = "https://files.pythonhosted.org/packages/f2/32/7c3b686da03121f5efd7a70e54dd2bd306baf7f9c052040f21e9840c41d9/xonsh-0.22.8-py311-none-any.whl", hash = "sha256:e6eda3606ef0eb35d6a82ef2323f7887d786de82250b8a4f5139773e8c14cc9a", size = 655394, upload-time = "2026-03-17T11:58:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f8/25be838bc171dadae10e7bba9a2d4dad47ad3c9828665398d5314494c33d/xonsh-0.22.8-py312-none-any.whl", hash = "sha256:15a175c9930ab23d810632ef2dbb82ba6d366f8d0d5cf8c24a1f3442be2e9497", size = 655395, upload-time = "2026-03-17T11:58:30.003Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c7/156cdc5e93acb80bbd345201eb82bba940914365bd2cd44508f1a87bc568/xonsh-0.22.8-py313-none-any.whl", hash = "sha256:b2e2545b532a4a96349d0151df2441323fea2ded0043fc8e96dd69f3a6309d00", size = 655853, upload-time = "2026-03-17T11:58:26.924Z" }, ] [[package]] From 64ddc42ad84e90fc47f909a3a4eb49f8cc125842 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 16 Apr 2026 21:01:36 -0400 Subject: [PATCH 06/26] Pin `xonsh` to GH `main` in editable mode --- pyproject.toml | 5 +++ uv.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a36cdd6a..4bc783235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,11 @@ lint = [ [tool.uv.sources] # XXX NOTE, only for @goodboy's hacking on `pprint(sort_dicts=False)` # 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] # XXX, in case we need to tmp patch again. diff --git a/uv.lock b/uv.lock index 98a6ef0b9..a120d720f 100644 --- a/uv.lock +++ b/uv.lock @@ -699,7 +699,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" }, { name = "typing-extensions", specifier = ">=4.14.1" }, - { name = "xonsh", specifier = ">=0.22.8" }, + { name = "xonsh", editable = "../xonsh" }, ] devx = [ { name = "greenback", specifier = ">=1.2.1,<2" }, @@ -711,7 +711,7 @@ repl = [ { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, - { name = "xonsh", specifier = ">=0.22.8" }, + { name = "xonsh", editable = "../xonsh" }, ] testing = [ { name = "pexpect", specifier = ">=4.9.0,<5" }, @@ -832,14 +832,85 @@ wheels = [ [[package]] name = "xonsh" -version = "0.22.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/d7/269a167722c8cea95204add643664a7dcb445ef50f5810a924ca7fd3e3f2/xonsh-0.22.8.tar.gz", hash = "sha256:5eedf7822f1655eb4f29a7c3916b0dcc7b8fe548b4b83001afe79f3bd39be28c", size = 830111, upload-time = "2026-03-17T11:58:35.193Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/32/7c3b686da03121f5efd7a70e54dd2bd306baf7f9c052040f21e9840c41d9/xonsh-0.22.8-py311-none-any.whl", hash = "sha256:e6eda3606ef0eb35d6a82ef2323f7887d786de82250b8a4f5139773e8c14cc9a", size = 655394, upload-time = "2026-03-17T11:58:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f8/25be838bc171dadae10e7bba9a2d4dad47ad3c9828665398d5314494c33d/xonsh-0.22.8-py312-none-any.whl", hash = "sha256:15a175c9930ab23d810632ef2dbb82ba6d366f8d0d5cf8c24a1f3442be2e9497", size = 655395, upload-time = "2026-03-17T11:58:30.003Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c7/156cdc5e93acb80bbd345201eb82bba940914365bd2cd44508f1a87bc568/xonsh-0.22.8-py313-none-any.whl", hash = "sha256:b2e2545b532a4a96349d0151df2441323fea2ded0043fc8e96dd69f3a6309d00", size = 655853, upload-time = "2026-03-17T11:58:26.924Z" }, -] +source = { editable = "../xonsh" } + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'dev'", specifier = ">=5.3.1" }, + { name = "coverage", marker = "extra == 'test'", specifier = ">=5.3.1" }, + { name = "distro", marker = "sys_platform == 'linux' and extra == 'full'" }, + { name = "distro", marker = "extra == 'linux'" }, + { name = "furo", marker = "extra == 'dev'" }, + { name = "furo", marker = "extra == 'doc'" }, + { name = "gnureadline", marker = "sys_platform == 'darwin' and extra == 'full'" }, + { name = "gnureadline", marker = "extra == 'mac'" }, + { name = "matplotlib", marker = "extra == 'dev'" }, + { name = "matplotlib", marker = "extra == 'doc'" }, + { name = "myst-parser", marker = "extra == 'dev'" }, + { name = "myst-parser", marker = "extra == 'doc'" }, + { name = "numpydoc", marker = "extra == 'dev'" }, + { 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 == 'dev'", specifier = ">=3.0.29" }, + { name = "prompt-toolkit", marker = "extra == 'doc'", specifier = ">=3.0.29" }, + { name = "prompt-toolkit", marker = "extra == 'full'", 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 == 'dev'" }, + { name = "psutil", marker = "extra == 'doc'" }, + { name = "pygments", marker = "extra == 'bestshell'", specifier = ">=2.2" }, + { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.2" }, + { name = "pygments", marker = "extra == 'doc'", specifier = ">=2.2" }, + { name = "pygments", marker = "extra == 'full'", specifier = ">=2.2" }, + { name = "pygments", marker = "extra == 'pygments'", specifier = ">=2.2" }, + { name = "pygments", marker = "extra == 'test'", specifier = ">=2.2" }, + { name = "pyperclip", marker = "extra == 'full'" }, + { name = "pyperclip", marker = "extra == 'ptk'" }, + { name = "pyte", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "pyte", marker = "extra == 'test'", specifier = ">=0.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "pytest-mock", marker = "extra == 'dev'" }, + { name = "pytest-mock", marker = "extra == 'test'" }, + { name = "pytest-rerunfailures", marker = "extra == 'dev'" }, + { name = "pytest-rerunfailures", marker = "extra == 'test'" }, + { name = "pytest-subprocess", marker = "extra == 'dev'" }, + { name = "pytest-subprocess", marker = "extra == 'test'" }, + { name = "pytest-timeout", marker = "extra == 'dev'" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "pyzmq", marker = "extra == 'dev'" }, + { name = "pyzmq", marker = "extra == 'doc'" }, + { name = "re-ver", marker = "extra == 'dev'" }, + { name = "requests", marker = "extra == 'dev'" }, + { name = "requests", marker = "extra == 'test'" }, + { name = "restructuredtext-lint", marker = "extra == 'dev'" }, + { name = "restructuredtext-lint", marker = "extra == 'test'" }, + { name = "runthis-sphinxext", marker = "extra == 'dev'" }, + { name = "runthis-sphinxext", marker = "extra == 'doc'" }, + { name = "setproctitle", marker = "sys_platform == 'win32' and extra == 'full'" }, + { name = "setproctitle", marker = "extra == 'proctitle'" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = ">=3.1" }, + { name = "sphinx", marker = "extra == 'doc'", specifier = ">=3.1" }, + { name = "sphinx-autobuild", marker = "extra == 'dev'" }, + { name = "sphinx-autobuild", marker = "extra == 'doc'" }, + { name = "sphinx-prompt", marker = "extra == 'dev'" }, + { name = "sphinx-prompt", marker = "extra == 'doc'" }, + { name = "sphinx-reredirects", marker = "extra == 'dev'" }, + { name = "sphinx-reredirects", marker = "extra == 'doc'" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tornado", marker = "extra == 'dev'" }, + { name = "tornado", marker = "extra == 'doc'" }, + { name = "ujson", marker = "extra == 'full'" }, + { name = "virtualenv", marker = "extra == 'dev'", specifier = ">=20.16.2" }, + { name = "virtualenv", marker = "extra == 'test'", specifier = ">=20.16.2" }, +] +provides-extras = ["bestshell", "dev", "doc", "full", "linux", "mac", "proctitle", "ptk", "pygments", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "xonsh", extras = ["dev"] }] [[package]] name = "zipp" From d318f1f8f4f6a056a8ca67d7f15e4e2a72507ea3 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 17 Apr 2026 12:44:37 -0400 Subject: [PATCH 07/26] Add `'subint'` spawn backend scaffold (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the scaffolding for a future sub-interpreter (PEP 734 `concurrent.interpreters`) actor spawn backend per issue #379. The spawn flow itself is not yet implemented; `subint_proc()` raises a placeholder `NotImplementedError` pointing at the tracking issue — this commit only wires up the registry, the py-version gate, and the harness. Deats, - bump `pyproject.toml` `requires-python` to `>=3.12, <3.15` and list the `3.14` classifier — the new stdlib `concurrent.interpreters` module only ships on 3.14 - extend `SpawnMethodKey = Literal[..., 'subint']` - `try_set_start_method('subint')` grows a new `match` arm that feature-detects the stdlib module and raises `RuntimeError` with a clear banner on py<3.14 - `_methods` registers the new `subint_proc()` via the same bottom-of-module late-import pattern used for `._trio` / `._mp` Also, - new `tractor/spawn/_subint.py` — top-level `try: from concurrent import interpreters` guards `_has_subints: bool`; `subint_proc()` signature mirrors `trio_proc`/`mp_proc` so the Phase B.2 impl can drop in without touching the registry - re-add `import sys` to `_spawn.py` (needed for the py-version msg in the gate-error) - `_testing.pytest.pytest_configure` wraps `try_set_start_method()` in a `pytest.UsageError` handler so `--spawn-backend=subint` on py<3.14 prints a clean banner instead of a traceback (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- 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(-) create mode 100644 tractor/spawn/_subint.py diff --git a/pyproject.toml b/pyproject.toml index 4bc783235..ef8428457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "tractor" version = "0.1.0a6dev0" description = 'structured concurrent `trio`-"actors"' authors = [{ name = "Tyler Goodlet", email = "goodboy_foss@protonmail.com" }] -requires-python = ">=3.12, <3.14" +requires-python = ">=3.12, <3.15" readme = "docs/README.rst" license = "AGPL-3.0-or-later" keywords = [ @@ -31,6 +31,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: System :: Distributed Computing", ] dependencies = [ diff --git a/tractor/_testing/pytest.py b/tractor/_testing/pytest.py index 1d803c9e4..c33406ff4 100644 --- a/tractor/_testing/pytest.py +++ b/tractor/_testing/pytest.py @@ -227,7 +227,13 @@ def pytest_addoption( def pytest_configure(config): backend = config.option.spawn_backend 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, # https://docs.pytest.org/en/stable/how-to/writing_plugins.html#registering-custom-markers diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index f9cc0a51f..09368f73e 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -22,6 +22,7 @@ from __future__ import annotations import multiprocessing as mp import platform +import sys from typing import ( Any, Awaitable, @@ -61,6 +62,7 @@ 'trio', # supported on all platforms 'mp_spawn', 'mp_forkserver', # posix only + 'subint', # py3.14+ via `concurrent.interpreters` (PEP 734) ] _spawn_method: SpawnMethodKey = 'trio' @@ -113,6 +115,17 @@ def try_set_start_method( case 'trio': _ctx = None + case 'subint': + # subints need no `mp.context`; feature-gate 3.14+ + from ._subint import _has_subints + if not _has_subints: + raise RuntimeError( + f'Spawn method {key!r} requires Python 3.14+ ' + f'(stdlib `concurrent.interpreters`, PEP 734).\n' + f'Current runtime: {sys.version}' + ) + _ctx = None + case _: raise ValueError( f'Spawn method `{key}` is invalid!\n' @@ -437,6 +450,7 @@ async def new_proc( # `hard_kill`/`proc_waiter` from this module. from ._trio import trio_proc from ._mp import mp_proc +from ._subint import subint_proc # proc spawning backend target map @@ -444,4 +458,5 @@ async def new_proc( 'trio': trio_proc, 'mp_spawn': mp_proc, 'mp_forkserver': mp_proc, + 'subint': subint_proc, } diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py new file mode 100644 index 000000000..097934963 --- /dev/null +++ b/tractor/spawn/_subint.py @@ -0,0 +1,100 @@ +# 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 . + +''' +Sub-interpreter (`subint`) actor spawning backend. + +Spawns each sub-actor as a CPython PEP 734 sub-interpreter +(`concurrent.interpreters.Interpreter`) — same-process state +isolation with faster start-up than an OS subproc, while +preserving tractor's IPC-based actor boundaries. + +Availability +------------ +Requires Python 3.14+ for the stdlib `concurrent.interpreters` +module. On older runtimes the module still imports (so the +registry stays introspectable) but `subint_proc()` raises. + +Status +------ +SCAFFOLDING STUB — `subint_proc()` is **not yet implemented**. +The real impl lands in Phase B.2 (see issue #379). + +''' +from __future__ import annotations +import sys +from typing import ( + Any, + TYPE_CHECKING, +) + +import trio +from trio import TaskStatus + + +try: + from concurrent import interpreters as _interpreters # type: ignore + _has_subints: bool = True +except ImportError: + _interpreters = None # type: ignore + _has_subints: bool = False + + +if TYPE_CHECKING: + from tractor.discovery._addr import UnwrappedAddress + from tractor.runtime._portal import Portal + from tractor.runtime._runtime import Actor + from tractor.runtime._supervise import ActorNursery + + +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 in a dedicated OS thread, + reusing tractor's existing UDS/TCP IPC handshake + for parent<->child channel setup. + + NOT YET IMPLEMENTED — placeholder stub pending the + Phase B.2 impl. + + ''' + if not _has_subints: + raise RuntimeError( + f'The {"subint"!r} spawn backend requires Python 3.14+ ' + f'(stdlib `concurrent.interpreters`, PEP 734).\n' + f'Current runtime: {sys.version}' + ) + + raise NotImplementedError( + 'The `subint` spawn backend scaffolding is in place but ' + 'the spawn-flow itself is not yet implemented.\n' + 'Tracking: https://github.com/goodboy/tractor/issues/379' + ) From d2ea8aa2deb681faf7986759ff822579ab9ee22f Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 17 Apr 2026 13:26:19 -0400 Subject: [PATCH 08/26] Handle py3.14+ incompats as test skips Since we're devving subints we require the 3.14+ stdlib API and a couple compiled libs don't support it yet, namely: - `cffi`, which we're only using for the `.ipc._linux` eventfd stuff (now factored into `hotbaud` anyway). - `greenback`, which requires `greenlet` which doesn't seem to be wheeled yet * on nixos the sdist build was failing due to lack of `g++` which i don't care to figure out rn since we don't need `.devx` stuff immediately for this subints prototype. * [ ] we still need to adjust any dependent suites to skip. Adjust `test_ringbuf` to skip on import failure. Also project wide, - pin us to py 3.13+ in prep for last-2-minor-version policy. - drop `msgspec>=0.20.0`, the first release with py3.14 support. --- pyproject.toml | 6 +- tests/test_ringbuf.py | 3 + tractor/ipc/_linux.py | 14 +++- uv.lock | 148 ++++++++++++++++++++++-------------------- 4 files changed, 97 insertions(+), 74 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef8428457..dd7df3b0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "tractor" version = "0.1.0a6dev0" description = 'structured concurrent `trio`-"actors"' authors = [{ name = "Tyler Goodlet", email = "goodboy_foss@protonmail.com" }] -requires-python = ">=3.12, <3.15" +requires-python = ">=3.13, <3.15" readme = "docs/README.rst" license = "AGPL-3.0-or-later" keywords = [ @@ -47,7 +47,7 @@ dependencies = [ # built-in multi-actor `pdb` REPL "pdbp>=1.8.2,<2", # windows only (from `pdbp`) # typed IPC msging - "msgspec>=0.21.0", + "msgspec>=0.20.0", "cffi>=1.17.1", "bidict>=0.23.1", "multiaddr>=0.2.0", @@ -64,7 +64,7 @@ dev = [ ] devx = [ # `tractor.devx` tooling - "greenback>=1.2.1,<2", + "greenback>=1.2.1,<2", # TODO? 3.14 greenlet on nix? "stackscope>=0.2.2,<0.3", # ^ requires this? "typing-extensions>=4.14.1", diff --git a/tests/test_ringbuf.py b/tests/test_ringbuf.py index 0d3b420b3..a89e2bb3b 100644 --- a/tests/test_ringbuf.py +++ b/tests/test_ringbuf.py @@ -17,6 +17,9 @@ # in case you don't want to melt your cores, uncomment dis! pytestmark = pytest.mark.skip +# XXX `cffi` dun build on py3.14 yet.. +cffi = pytest.importorskip("cffi") + @tractor.context async def child_read_shm( diff --git a/tractor/ipc/_linux.py b/tractor/ipc/_linux.py index 88d80d1c1..cd7de870f 100644 --- a/tractor/ipc/_linux.py +++ b/tractor/ipc/_linux.py @@ -17,10 +17,20 @@ Linux specifics, for now we are only exposing EventFD ''' -import os 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 ffi = cffi.FFI() diff --git a/uv.lock b/uv.lock index a120d720f..260ee0906 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12, <3.14" +requires-python = ">=3.13, <3.15" [[package]] name = "async-generator" @@ -44,18 +44,6 @@ version = "1.0.8" 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" } 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/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" }, @@ -80,6 +68,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/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/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]] @@ -91,17 +103,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" } 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/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" }, @@ -165,15 +166,6 @@ version = "3.1.1" 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" } 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/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" }, @@ -228,22 +220,6 @@ version = "5.2.1" 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" } 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/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" }, @@ -265,6 +241,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/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/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]] @@ -281,14 +294,6 @@ version = "0.21.1" 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" } 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/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" }, @@ -297,6 +302,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/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/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]] @@ -680,7 +701,7 @@ requires-dist = [ { name = "bidict", specifier = ">=0.23.1" }, { name = "cffi", specifier = ">=1.17.1" }, { 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 = "pdbp", specifier = ">=1.8.2,<2" }, { name = "platformdirs", specifier = ">=4.4.0" }, @@ -794,17 +815,6 @@ version = "1.17.2" 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" } 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/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" }, From b8f243e98d38688131d56397a87e3e56106bf4a9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 17 Apr 2026 18:01:08 -0400 Subject: [PATCH 09/26] Impl min-viable `subint` spawn backend (B.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the B.1 scaffold stub w/ a working spawn flow driving PEP 734 sub-interpreters on dedicated OS threads. Deats, - use private `_interpreters` C mod (not the public `concurrent.interpreters` API) to get `'legacy'` subint config — avoids PEP 684 C-ext compat issues w/ `msgspec` and other deps missing the `Py_mod_multiple_interpreters` slot - bootstrap subint via code-string calling new `_actor_child_main()` from `_child.py` (shared entry for both CLI and subint backends) - drive subint lifetime on an OS thread using `trio.to_thread.run_sync(_interpreters.exec, ..)` - full supervision lifecycle mirrors `trio_proc`: `ipc_server.wait_for_peer()` → send `SpawnSpec` → yield `Portal` via `task_status.started()` - graceful shutdown awaits the subint's inner `trio.run()` completing; cancel path sends `portal.cancel_actor()` then waits for thread join before `_interpreters.destroy()` Also, - extract `_actor_child_main()` from `_child.py` `__main__` block as callable entry shape bc the subint needs it for code-string bootstrap - add `"subint"` to the `_runtime.py` spawn-method check so child accepts `SpawnSpec` over IPC Prompt-IO: ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260417T124437Z_5cd6df5_prompt_io.md | 97 ++++++++ .../20260417T124437Z_5cd6df5_prompt_io.raw.md | 168 ++++++++++++++ tractor/_child.py | 52 ++++- tractor/runtime/_runtime.py | 2 +- tractor/spawn/_subint.py | 211 ++++++++++++++++-- 5 files changed, 502 insertions(+), 28 deletions(-) create mode 100644 ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.md create mode 100644 ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.raw.md diff --git a/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.md b/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.md new file mode 100644 index 000000000..42cdb7803 --- /dev/null +++ b/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.md @@ -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). diff --git a/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.raw.md b/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.raw.md new file mode 100644 index 000000000..c59785593 --- /dev/null +++ b/ai/prompt-io/claude/20260417T124437Z_5cd6df5_prompt_io.raw.md @@ -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. diff --git a/tractor/_child.py b/tractor/_child.py index c61cdec3f..727a5054a 100644 --- a/tractor/_child.py +++ b/tractor/_child.py @@ -15,16 +15,23 @@ # along with this program. If not, see . """ -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 - from ast import literal_eval +from typing import TYPE_CHECKING from .runtime._runtime import Actor from .spawn._entry import _trio_main +if TYPE_CHECKING: + from .discovery._addr import UnwrappedAddress + from .spawn._spawn import SpawnMethodKey + def parse_uid(arg): name, uuid = literal_eval(arg) # ensure 2 elements @@ -39,6 +46,36 @@ def parse_ipaddr(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__": __tracebackhide__: bool = True @@ -49,15 +86,10 @@ def parse_ipaddr(arg): parser.add_argument("--asyncio", action='store_true') args = parser.parse_args() - subactor = Actor( - name=args.uid[0], - uuid=args.uid[1], + _actor_child_main( + uid=args.uid, loglevel=args.loglevel, - spawn_method="trio" - ) - - _trio_main( - subactor, parent_addr=args.parent_addr, infect_asyncio=args.asyncio, + spawn_method='trio', ) diff --git a/tractor/runtime/_runtime.py b/tractor/runtime/_runtime.py index bee9e20d4..0c25b9262 100644 --- a/tractor/runtime/_runtime.py +++ b/tractor/runtime/_runtime.py @@ -870,7 +870,7 @@ async def _from_parent( accept_addrs: list[UnwrappedAddress]|None = None - if self._spawn_method == "trio": + if self._spawn_method in ("trio", "subint"): # Receive post-spawn runtime state from our parent. spawnspec: msgtypes.SpawnSpec = await chan.recv() diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py index 097934963..169657b20 100644 --- a/tractor/spawn/_subint.py +++ b/tractor/spawn/_subint.py @@ -18,9 +18,10 @@ Sub-interpreter (`subint`) actor spawning backend. Spawns each sub-actor as a CPython PEP 734 sub-interpreter -(`concurrent.interpreters.Interpreter`) — same-process state -isolation with faster start-up than an OS subproc, while -preserving tractor's IPC-based actor boundaries. +(`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 ------------ @@ -28,14 +29,10 @@ module. On older runtimes the module still imports (so the registry stays introspectable) but `subint_proc()` raises. -Status ------- -SCAFFOLDING STUB — `subint_proc()` is **not yet implemented**. -The real impl lands in Phase B.2 (see issue #379). - ''' from __future__ import annotations import sys +from functools import partial from typing import ( Any, TYPE_CHECKING, @@ -45,21 +42,50 @@ from trio import TaskStatus +# NOTE: we reach into the *private* `_interpreters` C module +# rather than using the nice `concurrent.interpreters` public +# API because the latter only exposes the `'isolated'` subint +# config (PEP 684, per-interp GIL). Under that config, any C +# extension lacking the `Py_mod_multiple_interpreters` slot +# refuses to import — which includes `msgspec` (used all over +# tractor's IPC layer) as of 0.19.x. Dropping to the `'legacy'` +# config keeps the main GIL + lets existing C extensions load +# normally while preserving the state-isolation we actually +# need for the actor model (separate `sys.modules`, `__main__`, +# globals). Once msgspec (and similar deps) opt-in to PEP 684 +# we can migrate to the public `interpreters.create()` API and +# pick up per-interp-GIL parallelism for free. try: - from concurrent import interpreters as _interpreters # type: ignore + 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.runtime._portal import Portal + from tractor.ipc import ( + _server, + Channel, + ) from tractor.runtime._runtime import Actor from tractor.runtime._supervise import ActorNursery +log = get_logger('tractor') + + async def subint_proc( name: str, actor_nursery: ActorNursery, @@ -78,12 +104,21 @@ async def subint_proc( ) -> None: ''' Create a new sub-actor hosted inside a PEP 734 - sub-interpreter running in a dedicated OS thread, + sub-interpreter running on a dedicated OS thread, reusing tractor's existing UDS/TCP IPC handshake for parent<->child channel setup. - NOT YET IMPLEMENTED — placeholder stub pending the - Phase B.2 impl. + 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: @@ -93,8 +128,150 @@ async def subint_proc( f'Current runtime: {sys.version}' ) - raise NotImplementedError( - 'The `subint` spawn backend scaffolding is in place but ' - 'the spawn-flow itself is not yet implemented.\n' - 'Tracking: https://github.com/goodboy/tractor/issues/379' + 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 + + async def _drive_subint() -> None: + ''' + Block a worker OS-thread on `_interpreters.exec()` for + the lifetime of the sub-actor. When the subint's inner + `trio.run()` exits, `exec()` returns and the thread + naturally joins. + + ''' + try: + await trio.to_thread.run_sync( + _interpreters.exec, + interp_id, + bootstrap, + abandon_on_cancel=False, + ) + finally: + subint_exited.set() + + try: + try: + async with trio.open_nursery() as thread_n: + thread_n.start_soon(_drive_subint) + + 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 `interp.close()` + # won't race with a running interpreter. + try: + await subint_exited.wait() + except trio.Cancelled: + with trio.CancelScope(shield=True): + 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 + pass + await subint_exited.wait() + raise + finally: + lifecycle_n.cancel_scope.cancel() + + finally: + # The driver thread has exited (either natural subint + # completion or post-cancel teardown) so the subint is + # no longer running — safe to destroy. + 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}' + ) + + finally: + if not cancelled_during_spawn: + actor_nursery._children.pop(uid, None) From 03bf2b931ecea9528e366beefa813b2a3f196781 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 17 Apr 2026 19:16:16 -0400 Subject: [PATCH 10/26] Avoid skip `.ipc._ringbuf` import when no `cffi` --- tests/test_ringbuf.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_ringbuf.py b/tests/test_ringbuf.py index a89e2bb3b..1fc0b8c13 100644 --- a/tests/test_ringbuf.py +++ b/tests/test_ringbuf.py @@ -4,6 +4,10 @@ import pytest import tractor + +# XXX `cffi` dun build on py3.14 yet.. +cffi = pytest.importorskip("cffi") + from tractor.ipc._ringbuf import ( open_ringbuf, RBToken, @@ -14,12 +18,9 @@ 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 -# XXX `cffi` dun build on py3.14 yet.. -cffi = pytest.importorskip("cffi") - @tractor.context async def child_read_shm( From 8a8d01e07606086eaddc2ae3a95247567fb3b499 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 17 Apr 2026 20:10:46 -0400 Subject: [PATCH 11/26] Doc the `_interpreters` private-API choice in `_subint` Expand the comment block above the `_interpreters` import explaining *why* we use the private C mod over `concurrent.interpreters`: the public API only exposes PEP 734's `'isolated'` config which breaks `msgspec` (missing PEP 684 slot). Add reference links to PEP 734, PEP 684, cpython sources, and the msgspec upstream tracker (jcrist/msgspec#563). Also, - update error msgs in both `_spawn.py` and `_subint.py` to say "3.13+" (matching the actual `_interpreters` availability) instead of "3.14+". - tweak the mod docstring to reflect py3.13+ availability via the private C module. Review: PR #444 (copilot-pull-request-reviewer) https://github.com/goodboy/tractor/pull/444 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_spawn.py | 11 +++++-- tractor/spawn/_subint.py | 64 +++++++++++++++++++++++++++++----------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index 09368f73e..b99fb40c0 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -116,12 +116,17 @@ def try_set_start_method( _ctx = None case 'subint': - # subints need no `mp.context`; feature-gate 3.14+ + # subints need no `mp.context`; feature-gate on the + # private `_interpreters` C module (available py3.13+ + # via cpython's internal stdlib — predates the PEP 734 + # public wrapper which only lands in py3.14). from ._subint import _has_subints if not _has_subints: raise RuntimeError( - f'Spawn method {key!r} requires Python 3.14+ ' - f'(stdlib `concurrent.interpreters`, PEP 734).\n' + f'Spawn method {key!r} requires Python 3.13+ ' + f'(private stdlib `_interpreters` C module; ' + f'the public `concurrent.interpreters` wrapper ' + f'lands in py3.14).\n' f'Current runtime: {sys.version}' ) _ctx = None diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py index 169657b20..a4eb9a70e 100644 --- a/tractor/spawn/_subint.py +++ b/tractor/spawn/_subint.py @@ -25,9 +25,13 @@ Availability ------------ -Requires Python 3.14+ for the stdlib `concurrent.interpreters` -module. On older runtimes the module still imports (so the -registry stays introspectable) but `subint_proc()` raises. +Runs on py3.13+ via the *private* stdlib `_interpreters` C +module (which predates the py3.14 public +`concurrent.interpreters` stdlib wrapper). See the comment +above the `_interpreters` import below for the trade-offs +driving the private-API choice. On older runtimes the +module still imports (so the registry stays +introspectable) but `subint_proc()` raises. ''' from __future__ import annotations @@ -43,18 +47,43 @@ # NOTE: we reach into the *private* `_interpreters` C module -# rather than using the nice `concurrent.interpreters` public -# API because the latter only exposes the `'isolated'` subint -# config (PEP 684, per-interp GIL). Under that config, any C -# extension lacking the `Py_mod_multiple_interpreters` slot -# refuses to import — which includes `msgspec` (used all over -# tractor's IPC layer) as of 0.19.x. Dropping to the `'legacy'` -# config keeps the main GIL + lets existing C extensions load -# normally while preserving the state-isolation we actually -# need for the actor model (separate `sys.modules`, `__main__`, -# globals). Once msgspec (and similar deps) opt-in to PEP 684 -# we can migrate to the public `interpreters.create()` API and -# pick up per-interp-GIL parallelism for free. +# 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). Side win: +# the private `_interpreters` module has shipped since py3.13 +# (it predates the PEP 734 stdlib landing), so the `subint` +# backend can run on py3.13+ despite `concurrent.interpreters` +# itself being 3.14+. +# +# 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: import _interpreters # type: ignore _has_subints: bool = True @@ -123,8 +152,9 @@ async def subint_proc( ''' if not _has_subints: raise RuntimeError( - f'The {"subint"!r} spawn backend requires Python 3.14+ ' - f'(stdlib `concurrent.interpreters`, PEP 734).\n' + f'The {"subint"!r} spawn backend requires Python 3.13+ ' + f'(private stdlib `_interpreters` C module; the public ' + f'`concurrent.interpreters` wrapper lands in py3.14).\n' f'Current runtime: {sys.version}' ) From 31cbd11a5b9f309102eac9f7a79157ea6cf0d3bd Mon Sep 17 00:00:00 2001 From: goodboy Date: Sat, 18 Apr 2026 00:21:49 -0400 Subject: [PATCH 12/26] Fix subint destroy race via dedicated OS thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `trio.to_thread.run_sync(_interpreters.exec, ...)` runs `exec()` on a cached worker thread — and when that thread is returned to the cache after the subint's `trio.run()` exits, CPython still keeps the subint's tstate attached to the (now idle) worker. Result: the teardown `_interpreters.destroy(interp_id)` in the `finally` block can block the parent's trio loop indefinitely, waiting for a tstate release that only happens when the worker either picks up a new job or exits. Manifested as intermittent mid-suite hangs under `--spawn-backend=subint` — caught by a `faulthandler.dump_traceback_later()` showing the main thread stuck in `_interpreters.destroy()` at `_subint.py:293` with only an idle trio-cache worker as the other live thread. Deats, - drive the subint on a plain `threading.Thread` (not `trio.to_thread`) so the OS thread truly exits after `_interpreters.exec()` returns, releasing tstate and unblocking destroy - signal `subint_exited.set()` back to the parent trio loop from the driver thread via `trio.from_thread.run_sync(..., trio_token=...)` — capture the token at `subint_proc` entry - swallow `trio.RunFinishedError` in that signal path for the case where parent trio has already exited (proc teardown) - in the teardown `finally`, off-load the sync `driver_thread.join()` to `trio.to_thread.run_sync` (cache thread w/ no subint tstate → safe) so we actually wait for the driver to exit before `_interpreters.destroy()` (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_subint.py | 194 ++++++++++++++++++++++----------------- 1 file changed, 110 insertions(+), 84 deletions(-) diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py index a4eb9a70e..4b4afa5fc 100644 --- a/tractor/spawn/_subint.py +++ b/tractor/spawn/_subint.py @@ -36,7 +36,7 @@ ''' from __future__ import annotations import sys -from functools import partial +import threading from typing import ( Any, TYPE_CHECKING, @@ -188,107 +188,133 @@ async def subint_proc( subint_exited = trio.Event() ipc_server: _server.Server = actor_nursery._actor.ipc_server - async def _drive_subint() -> None: + # 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: ''' - Block a worker OS-thread on `_interpreters.exec()` for - the lifetime of the sub-actor. When the subint's inner - `trio.run()` exits, `exec()` returns and the thread - naturally joins. + 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: - await trio.to_thread.run_sync( - _interpreters.exec, - interp_id, - bootstrap, - abandon_on_cancel=False, - ) + _interpreters.exec(interp_id, bootstrap) finally: - subint_exited.set() + 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 + + driver_thread = threading.Thread( + target=_subint_target, + name=f'subint-driver[{interp_id}]', + daemon=False, + ) try: try: - async with trio.open_nursery() as thread_n: - thread_n.start_soon(_drive_subint) - - 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, - ) + 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) + 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 + curr_actor: Actor = current_actor() + curr_actor._actoruid2nursery[uid] = actor_nursery - task_status.started(portal) + task_status.started(portal) - with trio.CancelScope(shield=True): - await actor_nursery._join_procs.wait() + 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, + ) - 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: + with trio.CancelScope(shield=True): + log.cancel( + f'Soft-killing subint sub-actor\n' + f'c)=> {chan.aid.reprol()}\n' + f' |_interp_id={interp_id}\n' ) - - # 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 `interp.close()` - # won't race with a running interpreter. - try: + try: + await portal.cancel_actor() + except ( + trio.BrokenResourceError, + trio.ClosedResourceError, + ): + # channel already down — subint will + # exit on its own timeline + pass await subint_exited.wait() - except trio.Cancelled: - with trio.CancelScope(shield=True): - 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 - pass - await subint_exited.wait() - raise - finally: - lifecycle_n.cancel_scope.cancel() + raise + finally: + lifecycle_n.cancel_scope.cancel() finally: - # The driver thread has exited (either natural subint - # completion or post-cancel teardown) so the subint is - # no longer running — safe to destroy. + # 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). with trio.CancelScope(shield=True): + if driver_thread.is_alive(): + await trio.to_thread.run_sync(driver_thread.join) try: _interpreters.destroy(interp_id) log.runtime( From c041518bdb4aa25fb182ace4bb8cb8c7d49379ab Mon Sep 17 00:00:00 2001 From: goodboy Date: Sat, 18 Apr 2026 16:04:57 -0400 Subject: [PATCH 13/26] Add prompt-IO log for subint destroy-race fix Log the `claude-opus-4-7` session that produced the `_subint.py` dedicated-thread fix (`26fb8206`). Substantive bc the patch was entirely AI-generated; raw log also preserves the CPython-internals research informing Phase B.3 hard-kill work. Prompt-IO: ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.md (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260418T042526Z_26fb820_prompt_io.md | 117 ++++++++++ .../20260418T042526Z_26fb820_prompt_io.raw.md | 220 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.md create mode 100644 ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.raw.md diff --git a/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.md b/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.md new file mode 100644 index 000000000..0cf0e41d0 --- /dev/null +++ b/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.md @@ -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. diff --git a/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.raw.md b/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.raw.md new file mode 100644 index 000000000..6b5da2930 --- /dev/null +++ b/ai/prompt-io/claude/20260418T042526Z_26fb820_prompt_io.raw.md @@ -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`. From 99541feec755d961138995ed54f62fadfa06b0f7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sat, 18 Apr 2026 16:30:04 -0400 Subject: [PATCH 14/26] Bound subint teardown shields with hard-kill timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unbounded `trio.CancelScope(shield=True)` at the soft-kill and thread-join sites can wedge the parent trio loop indefinitely when a stuck subint ignores portal-cancel (e.g. bc the IPC channel is already broken). Deats, - add `_HARD_KILL_TIMEOUT` (3s) module-level const - wrap both shield sites with `trio.move_on_after()` so we abandon a stuck subint after the deadline - flip driver thread to `daemon=True` so proc-exit also isn't blocked by a wedged subint - pass `abandon_on_cancel=True` to `trio.to_thread.run_sync(driver_thread.join)` — load-bearing for `move_on_after` to actually fire - log warnings when either timeout triggers - improve `InterpreterError` log msg to explain the abandoned-thread scenario (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_subint.py | 94 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py index 4b4afa5fc..e740bbb98 100644 --- a/tractor/spawn/_subint.py +++ b/tractor/spawn/_subint.py @@ -115,6 +115,17 @@ 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, @@ -220,10 +231,15 @@ def _subint_target() -> None: # 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=False, + daemon=True, ) try: @@ -283,7 +299,23 @@ def _subint_target() -> None: try: await subint_exited.wait() except trio.Cancelled: - with trio.CancelScope(shield=True): + # 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' @@ -296,9 +328,20 @@ def _subint_target() -> None: trio.ClosedResourceError, ): # channel already down — subint will - # exit on its own timeline + # 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() @@ -312,9 +355,44 @@ def _subint_target() -> None: # teardown. Off-load the blocking `.join()` to a # cache thread (which carries no subint tstate of # its own, so no cache conflict). - with trio.CancelScope(shield=True): + # + # 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(): - await trio.to_thread.run_sync(driver_thread.join) + # 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( @@ -325,7 +403,11 @@ def _subint_target() -> None: except _interpreters.InterpreterError as e: log.warning( f'Could not destroy sub-interpreter ' - f'{interp_id}: {e}' + 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: From 09466a1e9dc548040df0bddb5aed96402c1d0a19 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sat, 18 Apr 2026 18:28:15 -0400 Subject: [PATCH 15/26] Add `._debug_hangs` to `.devx` for hang triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottle up the diagnostic primitives that actually cracked the silent mid-suite hangs in the `subint` spawn-backend bringup (issue there" session has them on the shelf instead of reinventing from scratch. Deats, - `dump_on_hang(seconds, *, path)` — context manager wrapping `faulthandler.dump_traceback_later()`. Critical gotcha baked in: dumps go to a *file*, not `sys.stderr`, bc pytest's stderr capture silently eats the output and you can spend an hour convinced you're looking at the wrong thing - `track_resource_deltas(label, *, writer)` — context manager logging per-block `(threading.active_count(), len(_interpreters.list_all()))` deltas; quickly rules out leak-accumulation theories when a suite progressively worsens (if counts don't grow, it's not a leak, look for a race on shared cleanup instead) - `resource_delta_fixture(*, autouse, writer)` — factory returning a `pytest` fixture wrapping `track_resource_deltas` per-test; opt in by importing into a `conftest.py`. Kept as a factory (not a bare fixture) so callers own `autouse` / `writer` wiring Also, - export the three names from `tractor.devx` - dep-free on py<3.13 (swallows `ImportError` for `_interpreters`) - link back to the provenance in the module docstring (issue #379 / commit `26fb820`) (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/devx/__init__.py | 5 + tractor/devx/_debug_hangs.py | 227 +++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tractor/devx/_debug_hangs.py diff --git a/tractor/devx/__init__.py b/tractor/devx/__init__.py index 80c6744f9..6b681d985 100644 --- a/tractor/devx/__init__.py +++ b/tractor/devx/__init__.py @@ -41,6 +41,11 @@ pformat_caller_frame as pformat_caller_frame, 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? diff --git a/tractor/devx/_debug_hangs.py b/tractor/devx/_debug_hangs.py new file mode 100644 index 000000000..1ac66f942 --- /dev/null +++ b/tractor/devx/_debug_hangs.py @@ -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 . + +''' +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=)`. + + 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 From 34d9d482e407f61362e7d5b186a5d7ae95bd426a Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 14:01:49 -0400 Subject: [PATCH 16/26] Raise `subint` floor to py3.14 and split dep-groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private `_interpreters` C module ships since 3.13, but that vintage wedges under our `threading.Thread` + multi-trio usage pattern —> `_interpreters.exec()` silently never makes progress. 3.14 fixes it. So gate on the presence of the public `concurrent.interpreters` wrapper (3.14+ only) even tho we still call into the private module at runtime. Deats, - `try_set_start_method('subint')` error msg + `_subint` module docstring/comments rewritten to document the 3.14 floor and why 3.13 can't work. - `_subint._has_subints` gate now imports `concurrent.interpreters` (not `_interpreters`) as the version sentinel. Also, reshuffle `pyproject.toml` deps into per-python-version `[tool.uv.dependency-groups]`: - `subints` group: `msgspec>=0.21.0`, py>=3.14 - `eventfd` group: `cffi>=1.17.1`, py>=3.13,<3.14 - `sync_pause` group: `greenback`, py>=3.13,<3.14 (was in `devx`; moved out bc no 3.14 yet) Bump top-level `msgspec>=0.20.0` too. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- pyproject.toml | 27 +++++++++++++++-- tractor/spawn/_spawn.py | 19 +++++++----- tractor/spawn/_subint.py | 62 ++++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dd7df3b0c..ebf044f87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,12 @@ dependencies = [ "tricycle>=0.4.1,<0.5", "wrapt>=1.16.0,<2", "colorlog>=6.8.2,<7", + # built-in multi-actor `pdb` REPL "pdbp>=1.8.2,<2", # windows only (from `pdbp`) + # typed IPC msging "msgspec>=0.20.0", - "cffi>=1.17.1", "bidict>=0.23.1", "multiaddr>=0.2.0", "platformdirs>=4.4.0", @@ -64,10 +65,13 @@ dev = [ ] devx = [ # `tractor.devx` tooling - "greenback>=1.2.1,<2", # TODO? 3.14 greenlet on nix? "stackscope>=0.2.2,<0.3", # ^ requires this? "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 = [ # test suite @@ -85,6 +89,14 @@ repl = [ lint = [ "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 # `requirements-docs.txt`.. # docs = [ @@ -93,6 +105,17 @@ lint = [ # ] # ------ 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] # XXX NOTE, only for @goodboy's hacking on `pprint(sort_dicts=False)` diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index b99fb40c0..ec4cc3e48 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -117,16 +117,21 @@ def try_set_start_method( case 'subint': # subints need no `mp.context`; feature-gate on the - # private `_interpreters` C module (available py3.13+ - # via cpython's internal stdlib — predates the PEP 734 - # public wrapper which only lands in py3.14). + # py3.14 public `concurrent.interpreters` wrapper + # (PEP 734). We actually drive the private + # `_interpreters` C module in legacy mode — see + # `tractor.spawn._subint` for why — but py3.13's + # vintage of that private module hangs under our + # multi-trio usage, so we refuse it via the public- + # module presence check. from ._subint import _has_subints if not _has_subints: raise RuntimeError( - f'Spawn method {key!r} requires Python 3.13+ ' - f'(private stdlib `_interpreters` C module; ' - f'the public `concurrent.interpreters` wrapper ' - f'lands in py3.14).\n' + 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 diff --git a/tractor/spawn/_subint.py b/tractor/spawn/_subint.py index e740bbb98..a521ad212 100644 --- a/tractor/spawn/_subint.py +++ b/tractor/spawn/_subint.py @@ -25,13 +25,17 @@ Availability ------------ -Runs on py3.13+ via the *private* stdlib `_interpreters` C -module (which predates the py3.14 public -`concurrent.interpreters` stdlib wrapper). See the comment -above the `_interpreters` import below for the trade-offs -driving the private-API choice. On older runtimes the -module still imports (so the registry stays -introspectable) but `subint_proc()` raises. +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 @@ -47,23 +51,31 @@ # NOTE: we reach into the *private* `_interpreters` C module -# 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 +# 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. +# 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). Side win: -# the private `_interpreters` module has shipped since py3.13 -# (it predates the PEP 734 stdlib landing), so the `subint` -# backend can run on py3.13+ despite `concurrent.interpreters` -# itself being 3.14+. +# (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 @@ -85,6 +97,11 @@ # - 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: @@ -163,9 +180,10 @@ async def subint_proc( ''' if not _has_subints: raise RuntimeError( - f'The {"subint"!r} spawn backend requires Python 3.13+ ' - f'(private stdlib `_interpreters` C module; the public ' - f'`concurrent.interpreters` wrapper lands in py3.14).\n' + 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}' ) From 2ed5e6a6e87069352a300e1ce9cf660f0600fd49 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 14:06:54 -0400 Subject: [PATCH 17/26] Add `subint` cancellation + hard-kill test audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in the escape-hatch machinery added to `tractor.spawn._subint` during the Phase B.2/B.3 bringup (issue #379) so future stdlib regressions or our own refactors don't silently re-introduce the mid-suite hangs. Deats, - `test_subint_happy_teardown`: baseline — spawn a subactor, one portal RPC, clean teardown. If this breaks, something's wrong unrelated to the hard-kill shields. - `test_subint_non_checkpointing_child`: cancel a subactor stuck in a non-checkpointing Python loop (`threading.Event.wait()` releases the GIL but never inserts a trio checkpoint). Validates the bounded-shield + daemon-driver-thread combo abandons the thread after `_HARD_KILL_TIMEOUT`. Every test is wrapped in `trio.fail_after()` for a deterministic per-test wall-clock ceiling (an unbounded audit would defeat itself) and arms `tractor.devx.dump_on_hang()` so a hang captures a stack dump — pytest's stderr capture swallows `faulthandler` output by default. Gated via `pytest.importorskip('concurrent.interpreters')` and a module-level skip when `--spawn-backend` isn't `'subint'`. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_subint_cancellation.py | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/test_subint_cancellation.py diff --git a/tests/test_subint_cancellation.py b/tests/test_subint_cancellation.py new file mode 100644 index 000000000..67523b8c8 --- /dev/null +++ b/tests/test_subint_cancellation.py @@ -0,0 +1,197 @@ +''' +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)) + + +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 + + ''' + 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, + ), + ) From 4a3254583b1168382314fdad1b268c2498bbf729 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 15:28:00 -0400 Subject: [PATCH 18/26] Doc `subint` backend hang classes + arm `dump_on_hang` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify and write up the two distinct hang modes hit during Phase B subint bringup (issue #379) so future triage doesn't re-derive them from scratch. Deats, two new `ai/conc-anal/` docs, - `subint_sigint_starvation_issue.md`: abandoned legacy-subint thread + shared GIL → main trio loop starves → signal-wakeup-fd pipe fills → `SIGINT` silently dropped (`strace` shows `write() = EAGAIN` on the wakeup-fd). Un- Ctrl-C-able. Structurally a CPython limit; blocked on `msgspec` PEP 684 (jcrist/msgspec#563) - `subint_cancel_delivery_hang_issue.md`: parent-side trio task parks on an orphaned IPC channel after subint teardown — no clean EOF delivered to the waiting receive. Ctrl-C-able (main loop iterates fine); OUR bug to fix. Candidate fix: explicit parent-side channel abort in `subint_proc`'s hard-kill teardown Cross-link the docs from their test reproducers, - `test_stale_entry_is_deleted` (→ starvation class): wrap `trio.run(main)` in `dump_on_hang(seconds=20)` so a future regression captures a stack dump. Kept un- skipped so the dump file is inspectable - `test_subint_non_checkpointing_child` (→ delivery class): extend docstring with a "KNOWN ISSUE" block pointing at the analysis (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../subint_cancel_delivery_hang_issue.md | 161 ++++++++++++++ .../subint_sigint_starvation_issue.md | 205 ++++++++++++++++++ tests/discovery/test_registrar.py | 52 ++++- tests/test_subint_cancellation.py | 26 +++ 4 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 ai/conc-anal/subint_cancel_delivery_hang_issue.md create mode 100644 ai/conc-anal/subint_sigint_starvation_issue.md diff --git a/ai/conc-anal/subint_cancel_delivery_hang_issue.md b/ai/conc-anal/subint_cancel_delivery_hang_issue.md new file mode 100644 index 000000000..4c3112ede --- /dev/null +++ b/ai/conc-anal/subint_cancel_delivery_hang_issue.md @@ -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. diff --git a/ai/conc-anal/subint_sigint_starvation_issue.md b/ai/conc-anal/subint_sigint_starvation_issue.md new file mode 100644 index 000000000..438b7b8ab --- /dev/null +++ b/ai/conc-anal/subint_sigint_starvation_issue.md @@ -0,0 +1,205 @@ +# `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`): + +- PEP 684 (per-interpreter GIL): + +- `msgspec` PEP 684 tracker: + +- CPython `_interpretersmodule.c` source: + +- `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). diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index 60b2b10c4..6f34b1177 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -14,6 +14,7 @@ import pytest import subprocess import tractor +from tractor.devx import dump_on_hang from tractor.trionics import collapse_eg from tractor._testing import tractor_test from tractor.discovery._addr import wrap_address @@ -562,4 +563,53 @@ async def main(): await ptl.cancel_actor() 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 ` 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) diff --git a/tests/test_subint_cancellation.py b/tests/test_subint_cancellation.py index 67523b8c8..04b6cc9eb 100644 --- a/tests/test_subint_cancellation.py +++ b/tests/test_subint_cancellation.py @@ -182,6 +182,32 @@ def test_subint_non_checkpointing_child( - ~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( From a65fded4c6b545c456736d1a4128c43804e87bf7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 16:04:19 -0400 Subject: [PATCH 19/26] Add prompt-io log for `subint` hang-class docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log the `claude-opus-4-7` collab that produced `e92e3cd2` ("Doc `subint` backend hang classes + arm `dump_on_hang`"). Substantive bc the two new `ai/conc-anal/` docs were jointly authored — user framed the two-class split + set candidate-fix ordering for the class-2 (Ctrl-C-able) hang; claude drafted the prose and the test-side cross-linking comments. `.raw.md` is in diff-ref mode — per-file pointers via `git diff e92e3cd2~1..e92e3cd2 -- ` rather than re-embedding content that already lives in `git log -p`. Prompt-IO: ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.md (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260420T192739Z_5e8cd8b2_prompt_io.md | 111 ++++++++++ ...20260420T192739Z_5e8cd8b2_prompt_io.raw.md | 198 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.md create mode 100644 ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.raw.md diff --git a/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.md b/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.md new file mode 100644 index 000000000..32478dd75 --- /dev/null +++ b/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.md @@ -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=)`; 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__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. diff --git a/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.raw.md b/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.raw.md new file mode 100644 index 000000000..9c70fa2ce --- /dev/null +++ b/ai/prompt-io/claude/20260420T192739Z_5e8cd8b2_prompt_io.raw.md @@ -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=)`. +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. From 189f4e3ffc1aad8203b91a59522f923ab3ec3fe4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 20:45:56 -0400 Subject: [PATCH 20/26] Wall-cap `subint` audit tests via `pytest-timeout` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a hard process-level wall-clock bound on the two known-hanging subint-backend tests so an unattended suite run can't wedge indefinitely in either of the hang classes doc'd in `ai/conc-anal/`. Deats, - New `testing` dep: `pytest-timeout>=2.3`. - `test_stale_entry_is_deleted`: `@pytest.mark.timeout(3, method='thread')`. The `method='thread'` choice is deliberate — `method='signal'` routes via `SIGALRM` which is starved by the same GIL-hostage path that drops `SIGINT` (see `subint_sigint_starvation_issue.md`), so it'd never actually fire in the starvation case. - `test_subint_non_checkpointing_child`: same decorator, same reasoning (defense-in-depth over the inner `trio.fail_after(15)`). At timeout, `pytest-timeout` hard-kills the pytest process itself — that's the intended behavior here; the alternative is the suite never returning. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- pyproject.toml | 5 +++++ tests/discovery/test_registrar.py | 16 ++++++++++++++++ tests/test_subint_cancellation.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ebf044f87..bd46a6340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,11 @@ testing = [ # https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules "pytest>=8.3.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 = [ "pyperclip>=1.9.0", diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index 6f34b1177..bd0156080 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -517,6 +517,22 @@ async def kill_transport( # @pytest.mark.parametrize('use_signal', [False, True]) +# +# 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', +) def test_stale_entry_is_deleted( debug_mode: bool, daemon: subprocess.Popen, diff --git a/tests/test_subint_cancellation.py b/tests/test_subint_cancellation.py index 04b6cc9eb..18cbf78b8 100644 --- a/tests/test_subint_cancellation.py +++ b/tests/test_subint_cancellation.py @@ -161,6 +161,20 @@ def test_subint_happy_teardown( trio.run(partial(_happy_path, reg_addr, deadline)) +# 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: From a6cbac954d60429b9f39809adddd6152bda7f6a9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 20 Apr 2026 20:57:26 -0400 Subject: [PATCH 21/26] Bump lock-file for `pytest-timeout` + 3.13 gated wheel-deps --- uv.lock | 82 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/uv.lock b/uv.lock index 260ee0906..086ae8ef7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13, <3.15" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "async-generator" @@ -151,9 +155,9 @@ name = "greenback" version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet" }, - { name = "outcome" }, - { name = "sniffio" }, + { name = "greenlet", marker = "python_full_version < '3.14'" }, + { name = "outcome", marker = "python_full_version < '3.14'" }, + { 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" } wheels = [ @@ -568,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" }, ] +[[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]] name = "python-baseconv" version = "1.2.2" @@ -654,7 +670,6 @@ version = "0.1.0a6.dev0" source = { editable = "." } dependencies = [ { name = "bidict" }, - { name = "cffi" }, { name = "colorlog" }, { name = "msgspec" }, { name = "multiaddr" }, @@ -667,21 +682,23 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "greenback" }, { name = "pexpect" }, { name = "prompt-toolkit" }, { name = "psutil" }, { name = "pyperclip" }, { name = "pytest" }, + { name = "pytest-timeout" }, { name = "stackscope" }, { name = "typing-extensions" }, { name = "xonsh" }, ] devx = [ - { name = "greenback" }, { name = "stackscope" }, { name = "typing-extensions" }, ] +eventfd = [ + { name = "cffi", marker = "python_full_version < '3.14'" }, +] lint = [ { name = "ruff" }, ] @@ -691,15 +708,21 @@ repl = [ { name = "pyperclip" }, { name = "xonsh" }, ] +subints = [ + { name = "msgspec", marker = "python_full_version >= '3.14'" }, +] +sync-pause = [ + { name = "greenback", marker = "python_full_version < '3.14'" }, +] testing = [ { name = "pexpect" }, { name = "pytest" }, + { name = "pytest-timeout" }, ] [package.metadata] requires-dist = [ { name = "bidict", specifier = ">=0.23.1" }, - { name = "cffi", specifier = ">=1.17.1" }, { name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "msgspec", specifier = ">=0.20.0" }, { name = "multiaddr", specifier = ">=0.2.0" }, @@ -712,21 +735,21 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "greenback", specifier = ">=1.2.1,<2" }, { name = "pexpect", specifier = ">=4.9.0,<5" }, { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" }, { name = "typing-extensions", specifier = ">=4.14.1" }, { name = "xonsh", editable = "../xonsh" }, ] devx = [ - { name = "greenback", specifier = ">=1.2.1,<2" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" }, { 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" }] repl = [ { name = "prompt-toolkit", specifier = ">=3.0.50" }, @@ -734,9 +757,12 @@ repl = [ { name = "pyperclip", specifier = ">=1.9.0" }, { 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 = [ { name = "pexpect", specifier = ">=4.9.0,<5" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-timeout", specifier = ">=2.3" }, ] [[package]] @@ -846,78 +872,54 @@ source = { editable = "../xonsh" } [package.metadata] requires-dist = [ - { name = "coverage", marker = "extra == 'dev'", specifier = ">=5.3.1" }, + { name = "click", marker = "extra == 'full'" }, { name = "coverage", marker = "extra == 'test'", specifier = ">=5.3.1" }, { name = "distro", marker = "sys_platform == 'linux' and extra == 'full'" }, { name = "distro", marker = "extra == 'linux'" }, - { name = "furo", marker = "extra == 'dev'" }, { name = "furo", marker = "extra == 'doc'" }, { name = "gnureadline", marker = "sys_platform == 'darwin' and extra == 'full'" }, { name = "gnureadline", marker = "extra == 'mac'" }, - { name = "matplotlib", marker = "extra == 'dev'" }, { name = "matplotlib", marker = "extra == 'doc'" }, - { name = "myst-parser", marker = "extra == 'dev'" }, { name = "myst-parser", marker = "extra == 'doc'" }, - { name = "numpydoc", marker = "extra == 'dev'" }, { 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 == 'dev'", specifier = ">=3.0.29" }, - { name = "prompt-toolkit", marker = "extra == 'doc'", specifier = ">=3.0.29" }, - { name = "prompt-toolkit", marker = "extra == 'full'", 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 == 'dev'" }, { name = "psutil", marker = "extra == 'doc'" }, { name = "pygments", marker = "extra == 'bestshell'", specifier = ">=2.2" }, - { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.2" }, - { name = "pygments", marker = "extra == 'doc'", specifier = ">=2.2" }, - { name = "pygments", marker = "extra == 'full'", specifier = ">=2.2" }, { name = "pygments", marker = "extra == 'pygments'", specifier = ">=2.2" }, { name = "pygments", marker = "extra == 'test'", specifier = ">=2.2" }, - { name = "pyperclip", marker = "extra == 'full'" }, { name = "pyperclip", marker = "extra == 'ptk'" }, - { name = "pyte", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "pyte", marker = "extra == 'test'", specifier = ">=0.8.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'test'" }, - { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'test'" }, - { name = "pytest-rerunfailures", marker = "extra == 'dev'" }, { name = "pytest-rerunfailures", marker = "extra == 'test'" }, - { name = "pytest-subprocess", marker = "extra == 'dev'" }, { name = "pytest-subprocess", marker = "extra == 'test'" }, - { name = "pytest-timeout", marker = "extra == 'dev'" }, { name = "pytest-timeout", marker = "extra == 'test'" }, - { name = "pyzmq", marker = "extra == 'dev'" }, { name = "pyzmq", marker = "extra == 'doc'" }, { name = "re-ver", marker = "extra == 'dev'" }, - { name = "requests", marker = "extra == 'dev'" }, { name = "requests", marker = "extra == 'test'" }, - { name = "restructuredtext-lint", marker = "extra == 'dev'" }, { name = "restructuredtext-lint", marker = "extra == 'test'" }, - { name = "runthis-sphinxext", marker = "extra == 'dev'" }, { name = "runthis-sphinxext", marker = "extra == 'doc'" }, { name = "setproctitle", marker = "sys_platform == 'win32' and extra == 'full'" }, { name = "setproctitle", marker = "extra == 'proctitle'" }, - { name = "sphinx", marker = "extra == 'dev'", specifier = ">=3.1" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=3.1" }, - { name = "sphinx-autobuild", marker = "extra == 'dev'" }, { name = "sphinx-autobuild", marker = "extra == 'doc'" }, - { name = "sphinx-prompt", marker = "extra == 'dev'" }, { name = "sphinx-prompt", marker = "extra == 'doc'" }, - { name = "sphinx-reredirects", marker = "extra == 'dev'" }, { name = "sphinx-reredirects", marker = "extra == 'doc'" }, + { name = "sphinx-sitemap", marker = "extra == 'doc'" }, { name = "tomli", marker = "extra == 'dev'" }, - { name = "tornado", marker = "extra == 'dev'" }, { name = "tornado", marker = "extra == 'doc'" }, { name = "ujson", marker = "extra == 'full'" }, - { name = "virtualenv", marker = "extra == 'dev'", specifier = ">=20.16.2" }, { 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 = ["bestshell", "dev", "doc", "full", "linux", "mac", "proctitle", "ptk", "pygments", "test"] +provides-extras = ["ptk", "pygments", "mac", "linux", "proctitle", "full", "bestshell", "test", "dev", "doc"] [package.metadata.requires-dev] dev = [{ name = "xonsh", extras = ["dev"] }] From 59987745359e238d78c8de68bc9c502236b4e395 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 21 Apr 2026 13:33:34 -0400 Subject: [PATCH 22/26] Add global 200s `pytest-timeout` --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index bd46a6340..3d62c8b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,6 +198,7 @@ all_bullets = true [tool.pytest.ini_options] minversion = '6.0' +timeout = 200 # per-test hard limit # https://docs.pytest.org/en/stable/reference/reference.html#configuration-options testpaths = [ 'tests' From 985ea76de593d5707f5fcdd7f3095dfca6a77176 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 21 Apr 2026 13:36:41 -0400 Subject: [PATCH 23/26] Skip `test_stale_entry_is_deleted` hanger with `subint`s --- tests/discovery/test_registrar.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index bd0156080..02748370a 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -132,6 +132,10 @@ async def say_hello_use_wait( return result +@pytest.mark.timeout( + 3, + method='thread', +) @tractor_test @pytest.mark.parametrize( 'func', @@ -545,6 +549,12 @@ def test_stale_entry_is_deleted( stale entry and not delivering a bad portal. ''' + if start_method == 'subint': + pytest.skip( + 'XXX SUBINT HANGING TEST XXX\n' + 'See oustanding issue(s)\n' + ) + async def main(): name: str = 'transport_fails_actor' From f3cea714bcca0fc49b8170dab8014ca11008a968 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 21 Apr 2026 17:42:37 -0400 Subject: [PATCH 24/26] Expand `subint` sigint-starvation hang catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two more tests to the catalog in `conc-anal/subint_sigint_starvation_issue.md` — same signal-wakeup-fd-saturation fingerprint (abandoned legacy-subint driver threads → shared-GIL starvation → `write() = EAGAIN` on the wakeup pipe → silent SIGINT drop), different load patterns. Deats, - `test_cancel_while_childs_child_in_sync_sleep[subint-False]`: nested actor-tree + sync-sleeping grandchild. Under `trio`/`mp_*` the "zombie reaper" is a subproc `SIGKILL`; no equivalent exists under subint, so the grandchild persists in its abandoned driver thread. Often only manifests under full-suite runs (earlier tests seed the abandoned-thread pool). - `test_multierror_fast_nursery[subint-25-0.5]`: 25 concurrent subactors all go through teardown on the multierror. Bounded hard-kills run in parallel — so the total budget is ~3s, not 3s × 25. Leaves 25 abandoned driver threads simultaneously alive, an extreme pressure multiplier. `strace` shows several successful `write(16, "\2", 1) = 1` (GIL round-robin IS giving main brief slices) before finally saturating with `EAGAIN`. Also include a `pstree -snapt ` capture showing 16+ live `{subint-driver[}` threads at the moment of hang — the direct GIL-contender population. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../subint_sigint_starvation_issue.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/ai/conc-anal/subint_sigint_starvation_issue.md b/ai/conc-anal/subint_sigint_starvation_issue.md index 438b7b8ab..60b266c68 100644 --- a/ai/conc-anal/subint_sigint_starvation_issue.md +++ b/ai/conc-anal/subint_sigint_starvation_issue.md @@ -203,3 +203,148 @@ work" cause. 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[` — +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.) From 3b26b59dad8c6b365890746ed5acc0811bec94c6 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 21 Apr 2026 21:24:51 -0400 Subject: [PATCH 25/26] Add `skipon_spawn_backend` pytest marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reusable `@pytest.mark.skipon_spawn_backend( '' [, ...], reason='...')` marker for backend-specific known-hang / -borked cases — avoids scattering `@pytest.mark.skipif(lambda ...)` branches across tests that misbehave under a particular `--spawn-backend`. Deats, - `pytest_configure()` registers the marker via `addinivalue_line('markers', ...)`. - New `pytest_collection_modifyitems()` hook walks each collected item with `item.iter_markers( name='skipon_spawn_backend')`, checks whether the active `--spawn-backend` appears in `mark.args`, and if so injects a concrete `pytest.mark.skip( reason=...)`. `iter_markers()` makes the decorator work at function, class, or module (`pytestmark = [...]`) scope transparently. - First matching mark wins; default reason is `f'Borked on --spawn-backend={backend!r}'` if the caller doesn't supply one. Also, tighten type annotations on nearby `pytest` integration points — `pytest_configure`, `debug_mode`, `spawn_backend`, `tpt_protos`, `tpt_proto` — now taking typed `pytest.Config` / `pytest.FixtureRequest` params. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_testing/pytest.py | 61 +++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/tractor/_testing/pytest.py b/tractor/_testing/pytest.py index c33406ff4..ef3cc9a73 100644 --- a/tractor/_testing/pytest.py +++ b/tractor/_testing/pytest.py @@ -224,8 +224,10 @@ def pytest_addoption( ) -def pytest_configure(config): - backend = config.option.spawn_backend +def pytest_configure( + config: pytest.Config, +): + backend: str = config.option.spawn_backend from tractor.spawn._spawn import try_set_start_method try: try_set_start_method(backend) @@ -241,10 +243,52 @@ def pytest_configure(config): 'markers', '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(''[, + ...], 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') -def debug_mode(request) -> bool: +def debug_mode( + request: pytest.FixtureRequest, +) -> bool: ''' Flag state for whether `--tpdb` (for `tractor`-py-debugger) was passed to the test run. @@ -258,12 +302,16 @@ def debug_mode(request) -> bool: @pytest.fixture(scope='session') -def spawn_backend(request) -> str: +def spawn_backend( + request: pytest.FixtureRequest, +) -> str: return request.config.option.spawn_backend @pytest.fixture(scope='session') -def tpt_protos(request) -> list[str]: +def tpt_protos( + request: pytest.FixtureRequest, +) -> list[str]: # allow quoting on CLI proto_keys: list[str] = [ @@ -291,7 +339,7 @@ def tpt_protos(request) -> list[str]: autouse=True, ) def tpt_proto( - request, + request: pytest.FixtureRequest, tpt_protos: list[str], ) -> str: proto_key: str = tpt_protos[0] @@ -343,7 +391,6 @@ def pytest_generate_tests( metafunc: pytest.Metafunc, ): spawn_backend: str = metafunc.config.option.spawn_backend - if not spawn_backend: # XXX some weird windows bug with `pytest`? spawn_backend = 'trio' From 4b2a0886c3ac4b8fdeb1db3db820df2396f3c198 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 21 Apr 2026 21:33:15 -0400 Subject: [PATCH 26/26] Mark `subint`-hanging tests with `skipon_spawn_backend` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the `@pytest.mark.skipon_spawn_backend('subint', reason=...)` marker (a617b521) across the suites reproducing the `subint` GIL-contention / starvation hang classes doc'd in `ai/conc-anal/subint_*_issue.md`. Deats, - Module-level `pytestmark` on full-file-hanging suites: - `tests/test_cancellation.py` - `tests/test_inter_peer_cancellation.py` - `tests/test_pubsub.py` - `tests/test_shm.py` - Per-test decorator where only one test in the file hangs: - `tests/discovery/test_registrar.py ::test_stale_entry_is_deleted` — replaces the inline `if start_method == 'subint': pytest.skip` branch with a declarative skip. - `tests/test_subint_cancellation.py ::test_subint_non_checkpointing_child`. - A few per-test decorators are left commented-in- place as breadcrumbs for later finer-grained unskips. Also, some nearby tidying in the affected files: - Annotate loose fixture / test params (`pytest.FixtureRequest`, `str`, `tuple`, `bool`) in `tests/conftest.py`, `tests/devx/conftest.py`, and `tests/test_cancellation.py`. - Normalize `"""..."""` → `'''...'''` docstrings per repo convention on a few touched tests. - Add `timeout=6` / `timeout=10` to `@tractor_test(...)` on `test_cancel_infinite_streamer` and `test_some_cancels_all`. - Drop redundant `spawn_backend` param from `test_cancel_via_SIGINT`; use `start_method` in the `'mp' in ...` check instead. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/conftest.py | 6 +- tests/devx/conftest.py | 5 +- tests/discovery/test_registrar.py | 18 +++-- tests/test_cancellation.py | 107 +++++++++++++++++++++----- tests/test_inter_peer_cancellation.py | 9 +++ tests/test_pubsub.py | 8 ++ tests/test_shm.py | 8 ++ tests/test_subint_cancellation.py | 8 ++ 8 files changed, 136 insertions(+), 33 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c7b205313..90498ba05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,7 +139,9 @@ def pytest_addoption( @pytest.fixture(scope='session', autouse=True) -def loglevel(request) -> str: +def loglevel( + request: pytest.FixtureRequest, +) -> str: import tractor orig = tractor.log._default_loglevel level = tractor.log._default_loglevel = request.config.option.loglevel @@ -156,7 +158,7 @@ def loglevel(request) -> str: @pytest.fixture(scope='function') def test_log( - request, + request: pytest.FixtureRequest, loglevel: str, ) -> tractor.log.StackLevelAdapter: ''' diff --git a/tests/devx/conftest.py b/tests/devx/conftest.py index eb56d74c5..747c859d7 100644 --- a/tests/devx/conftest.py +++ b/tests/devx/conftest.py @@ -146,13 +146,12 @@ def _spawn( ids='ctl-c={}'.format, ) def ctlc( - request, + request: pytest.FixtureRequest, ci_env: bool, ) -> bool: - use_ctlc = request.param - + use_ctlc: bool = request.param node = request.node markers = node.own_markers for mark in markers: diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index 02748370a..a004ddac7 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -520,8 +520,6 @@ async def kill_transport( -# @pytest.mark.parametrize('use_signal', [False, True]) -# # 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 @@ -537,6 +535,16 @@ async def kill_transport( 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]) +# def test_stale_entry_is_deleted( debug_mode: bool, daemon: subprocess.Popen, @@ -549,12 +557,6 @@ def test_stale_entry_is_deleted( stale entry and not delivering a bad portal. ''' - if start_method == 'subint': - pytest.skip( - 'XXX SUBINT HANGING TEST XXX\n' - 'See oustanding issue(s)\n' - ) - async def main(): name: str = 'transport_fails_actor' diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index f1091372f..645ee068e 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -21,6 +21,16 @@ _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): await trio.sleep(delay) assert 0 @@ -110,8 +120,17 @@ async def main(): 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( reg_addr: tuple[str, int], + start_method: str, ): ''' Verify we raise a ``BaseExceptionGroup`` out of a nursery where @@ -141,15 +160,28 @@ async def main(): trio.run(main) -@pytest.mark.parametrize('delay', (0, 0.5)) @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): - """Verify we raise a ``BaseExceptionGroup`` out of a nursery where +@pytest.mark.parametrize( + '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 to test failure during an ongoing spawning. - """ + + ''' async def main(): async with tractor.open_nursery( registry_addrs=[reg_addr], @@ -189,8 +221,15 @@ async def do_nothing(): pass -@pytest.mark.parametrize('mechanism', ['nursery_cancel', KeyboardInterrupt]) -def test_cancel_single_subactor(reg_addr, mechanism): +@pytest.mark.parametrize( + 'mechanism', [ + 'nursery_cancel', + KeyboardInterrupt, +]) +def test_cancel_single_subactor( + reg_addr: tuple, + mechanism: str|KeyboardInterrupt, +): ''' Ensure a ``ActorNursery.start_actor()`` spawned subactor cancels when the nursery is cancelled. @@ -232,9 +271,12 @@ async def stream_forever(): await trio.sleep(0.01) -@tractor_test -async def test_cancel_infinite_streamer(start_method): - +@tractor_test( + timeout=6, +) +async def test_cancel_infinite_streamer( + start_method: str +): # stream for at most 1 seconds with ( trio.fail_after(4), @@ -257,6 +299,14 @@ async def test_cancel_infinite_streamer(start_method): 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( 'num_actors_and_errs', [ @@ -286,7 +336,9 @@ async def test_cancel_infinite_streamer(start_method): 'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail', ], ) -@tractor_test +@tractor_test( + timeout=10, +) async def test_some_cancels_all( num_actors_and_errs: tuple, start_method: str, @@ -370,7 +422,10 @@ async def test_some_cancels_all( 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 async with tractor.open_nursery() as nursery: for i in range(breadth): @@ -396,7 +451,10 @@ async def spawn_and_error(breadth, depth) -> None: @tractor_test -async def test_nested_multierrors(loglevel, start_method): +async def test_nested_multierrors( + loglevel: str, + start_method: str, +): ''' Test that failed actor sets are wrapped in `BaseExceptionGroup`s. This test goes only 2 nurseries deep but we should eventually have tests @@ -483,20 +541,21 @@ async def test_nested_multierrors(loglevel, start_method): @no_windows def test_cancel_via_SIGINT( - loglevel, - start_method, - spawn_backend, + loglevel: str, + 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 - """ + + ''' pid: int = os.getpid() async def main(): with trio.fail_after(2): async with tractor.open_nursery() as tn: await tn.start_actor('sucka') - if 'mp' in spawn_backend: + if 'mp' in start_method: time.sleep(0.1) os.kill(pid, signal.SIGINT) await trio.sleep_forever() @@ -580,6 +639,14 @@ async def spawn_sub_with_sync_blocking_task(): 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( 'man_cancel_outer', [ @@ -694,7 +761,7 @@ async def main(): 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 diff --git a/tests/test_inter_peer_cancellation.py b/tests/test_inter_peer_cancellation.py index b79c0393a..fc5d741d2 100644 --- a/tests/test_inter_peer_cancellation.py +++ b/tests/test_inter_peer_cancellation.py @@ -26,6 +26,15 @@ 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: # - [x] WE cancelled the peer and thus should not see any raised # `ContextCancelled` as it should be reaped silently? diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 6d416f89c..1bf8563a6 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -7,6 +7,14 @@ from tractor.experimental import msgpub 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(): diff --git a/tests/test_shm.py b/tests/test_shm.py index 00a36f8aa..3409f3384 100644 --- a/tests/test_shm.py +++ b/tests/test_shm.py @@ -14,6 +14,14 @@ 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 async def child_attach_shml_alot( diff --git a/tests/test_subint_cancellation.py b/tests/test_subint_cancellation.py index 18cbf78b8..0f0757384 100644 --- a/tests/test_subint_cancellation.py +++ b/tests/test_subint_cancellation.py @@ -161,6 +161,14 @@ def test_subint_happy_teardown( 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