diff --git a/.github/workflows/python-315t.yml b/.github/workflows/python-315t.yml new file mode 100644 index 0000000..186bb97 --- /dev/null +++ b/.github/workflows/python-315t.yml @@ -0,0 +1,52 @@ +name: Python 3.15t wheels + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-315t-${{ github.ref }} + cancel-in-progress: true + +jobs: + wheel-test: + name: cp315t / ${{ matrix.os_name }} x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os_name: linux + runner: ubuntu-latest + rust_target: x86_64-unknown-linux-gnu + - os_name: macos + runner: macos-15-intel + rust_target: x86_64-apple-darwin + - os_name: windows + runner: windows-latest + rust_target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + - uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-09-25 + - uses: Swatinem/rust-cache@v2 + - name: Build cp315t release wheel + shell: bash + env: + RSLOOP_PYTHON_VERSIONS: "3.15t" + run: scripts/build-wheels.sh --out dist --target "${{ matrix.rust_target }}" -- --locked + - name: Check installed wheel and focused free-threading tests + run: uv run --no-project --python 3.15t python scripts/check-free-threaded-wheel.py dist + - name: Upload cp315t wheel + uses: actions/upload-artifact@v4 + with: + name: cp315t-${{ matrix.os_name }}-x64 + path: dist/*-cp315-cp315t-*.whl + if-no-files-found: error diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0a6599..9c8e650 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: python_versions: - description: "Space-separated supported Python versions to test (3.10–3.15, 3.14t)" + description: "Space-separated supported Python versions to test (3.10–3.15, 3.14t, 3.15t)" required: false type: string build_mode: @@ -256,6 +256,7 @@ jobs: "3.14", "3.14t", "3.15", + "3.15t", } unsupported_versions = sorted(set(sys.argv[1:]) - supported_versions) if unsupported_versions: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c46c27a..b46ee32 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -88,6 +88,11 @@ jobs: scripts/build-wheels.sh --out dist --target "${{ matrix.rust_target }}" fi + - name: Check installed cp315t wheel + # Windows ARM64 retains its explicit GIL-only interpreter list. + if: matrix.os_name != 'windows-arm64' + run: uv run --no-project --python 3.15t python scripts/check-free-threaded-wheel.py dist + - name: Upload wheel artifacts uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 9cea087..9b13d62 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The package exposes: Repository metadata supports CPython 3.10 through 3.15. The native runtime requires Linux 6.1+, macOS 13+, or Windows 10+ so its hot paths can rely on modern completion, timer, and scheduler primitives. -Free-threaded CPython (`3.14t`) is supported: the extension declares +Free-threaded CPython (`3.14t`, `3.15t`) is supported: the extension declares `gil_used = false`, so importing it no longer re-enables the GIL. See [Free-Threaded CPython](./docs/free-threading.md) for what that does and does not buy you. diff --git a/docs/development.md b/docs/development.md index 975a25a..9c174d3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -80,7 +80,7 @@ scripts/build-wheels.sh [`scripts/build-wheels.sh`](https://github.com/RustedBytes/rsloop/blob/master/scripts/build-wheels.sh) currently defaults to -CPython `3.10 3.11 3.12 3.13 3.14 3.14t 3.15`, and uses +CPython `3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t`, and uses `uv python install` / `uv python find` to locate interpreters. ### Vibeio allocation budgets @@ -150,7 +150,8 @@ from the default `dev` environment because some frameworks have native dependencies that do not support every Python interpreter. The default matrix covers CPython 3.10 through 3.15 plus the free-threaded -CPython 3.14 build (`3.14t`). Until Python 3.15 is final, uv resolves `3.15` to +CPython 3.14 and 3.15 builds (`3.14t`, `3.15t`). Until Python 3.15 is final, +uv resolves `3.15` and `3.15t` to the latest available prerelease standalone build. ## Run Rust lints diff --git a/docs/free-threading.md b/docs/free-threading.md index 7909561..bcf38f2 100644 --- a/docs/free-threading.md +++ b/docs/free-threading.md @@ -1,6 +1,6 @@ # Free-Threaded CPython -`rsloop` builds and runs on free-threaded CPython 3.14 (`3.14t`). The extension +`rsloop` builds and runs on free-threaded CPython 3.14 and 3.15 (`3.14t`, `3.15t`). The extension declares `#[pymodule(gil_used = false)]`, which keeps CPython from silently switching the GIL back on for the whole process at import time: @@ -36,8 +36,20 @@ The pieces that make this safe include: standard-library stream-reader paths, `call_soon_threadsafe()` fan-in from eight threads, and a check that importing `rsloop` leaves the GIL off. -Wheels are built for `3.14t` alongside the GIL builds, and the test matrix runs -it as its own entry. +Wheels are built for `3.14t` and `3.15t` alongside the GIL builds, and the test +matrix runs each as its own entry. Windows ARM64 release jobs retain their +explicit GIL-only interpreter list. + +The manual `Python 3.15t wheels` workflow builds release wheels on Linux, macOS +Intel, and Windows x64. It installs each wheel in a clean 3.15t environment, +checks import and `build_info()`, runs a callback/TCP smoke check with the GIL +disabled, and runs the focused free-threading and callback tests. Release wheel +jobs also check their installed 3.15t wheel before upload. To run this locally: + +```bash +RSLOOP_PYTHON_VERSIONS=3.15t scripts/build-wheels.sh --out dist/315t -- --locked +uv run --no-project --python 3.15t python scripts/check-free-threaded-wheel.py dist/315t +``` Downstream PyO3 extensions should also follow the guidance in [Free-threaded interpreters](rust-extensions.md#free-threaded-interpreters). diff --git a/docs/getting-started.md b/docs/getting-started.md index d1b94ec..9306f57 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ It returns the package version, debug or release profile, target OS and architecture, whether the build interpreter was free-threaded, selected I/O reactor, and TLS backend. The `free_threaded` diagnostic reports whether this build targets a free-threaded -interpreter. rsloop supports free-threaded CPython 3.14 (`3.14t`) and declares +interpreter. rsloop supports free-threaded CPython 3.14 and 3.15 (`3.14t`, `3.15t`) and declares `gil_used = false`, so importing it does not re-enable the GIL. The exact values depend on the installed wheel or local build. diff --git a/scripts/build-wheels.sh b/scripts/build-wheels.sh index 99a345f..814e31a 100755 --- a/scripts/build-wheels.sh +++ b/scripts/build-wheels.sh @@ -22,7 +22,7 @@ Options: Environment: RSLOOP_PYTHON_VERSIONS Space-separated version list to override the defaults - (default: 3.10 3.11 3.12 3.13 3.14 3.14t 3.15) + (default: 3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t) RSLOOP_RUST_TARGET Rust compilation target to pass to maturin MACOSX_DEPLOYMENT_TARGET macOS wheel floor (defaults to 13.0 for Apple targets) diff --git a/scripts/check-free-threaded-wheel.py b/scripts/check-free-threaded-wheel.py new file mode 100644 index 0000000..ba22aae --- /dev/null +++ b/scripts/check-free-threaded-wheel.py @@ -0,0 +1,122 @@ +"""Install and check a cp315t wheel in a fresh, isolated uv environment.""" + +from __future__ import annotations + +import argparse +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SMOKE = r""" +import asyncio +import json +from pathlib import Path +import sys +import sysconfig + +assert sys.version_info[:2] == (3, 15), sys.version +assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1 +assert not sys._is_gil_enabled(), "GIL enabled before import" +import rsloop +import rsloop._loop as native +assert Path(rsloop.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) +assert Path(native.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) +info = rsloop.build_info() +assert info["free_threaded"] is True, info +assert not sys._is_gil_enabled(), "import re-enabled the GIL" +print(json.dumps({"python": sys.version, "package": rsloop.__file__, + "extension": native.__file__, "build_info": info}), flush=True) + +async def echo(reader, writer): + try: + writer.write(await reader.readexactly(4)) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + +async def exercise(): + loop = asyncio.get_running_loop() + seen = [] + loop.call_soon(seen.append, "soon") + loop.call_soon_threadsafe(seen.append, "threadsafe") + await asyncio.sleep(0) + assert seen == ["soon", "threadsafe"], seen + server = await asyncio.start_server(echo, "127.0.0.1", 0) + try: + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + try: + writer.write(b"ping") + await writer.drain() + assert await reader.readexactly(4) == b"ping" + finally: + writer.close() + await writer.wait_closed() + finally: + server.close() + await server.wait_closed() + +loop = rsloop.new_event_loop() +asyncio.set_event_loop(loop) +try: + loop.run_until_complete(asyncio.wait_for(exercise(), 15)) +finally: + asyncio.set_event_loop(None) + loop.close() +assert not sys._is_gil_enabled(), "smoke re-enabled the GIL" +print("PASS: installed-wheel import, build_info, callbacks and TCP", flush=True) +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel_dir", type=Path) + args = parser.parse_args() + wheels = sorted(args.wheel_dir.resolve().glob("*-cp315-cp315t-*.whl")) + if len(wheels) != 1: + raise SystemExit(f"Expected one cp315t wheel, found: {wheels}") + print(f"Checking wheel: {wheels[0].name}", flush=True) + with tempfile.TemporaryDirectory(prefix="rsloop-cp315t-") as work: + venv = Path(work) / "venv" + subprocess.run( + ["uv", "venv", "--no-project", "--python", "3.15t", str(venv)], check=True + ) + python = venv / ( + "Scripts/python.exe" if (venv / "Scripts").exists() else "bin/python" + ) + subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + str(python), + str(wheels[0]), + "pytest>=8.4", + ], + check=True, + ) + subprocess.run( + [str(python), "-I", "-c", SMOKE], cwd=work, check=True, timeout=60 + ) + subprocess.run( + [ + str(python), + "-I", + "-u", + str(ROOT / "scripts/run_python_tests.py"), + "-q", + "tests/test_free_threading.py", + "tests/test_fast_callbacks.py", + "tests/test_run.py::TestRun::test_build_info_describes_native_extension", + ], + cwd=ROOT, + check=True, + timeout=300, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/python-versions.sh b/scripts/python-versions.sh index 98c9be4..e121020 100755 --- a/scripts/python-versions.sh +++ b/scripts/python-versions.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# 3.14t is the free-threaded build. uv accepts the "t" suffix directly, and -# the interpreter it resolves to is named python3.14t on disk. -RSLOOP_DEFAULT_PYTHON_VERSIONS=(3.10 3.11 3.12 3.13 3.14 3.14t 3.15) +# The "t" suffix selects free-threaded CPython (3.14t and 3.15t). +# uv accepts it directly and resolves a python3.Xt interpreter. +RSLOOP_DEFAULT_PYTHON_VERSIONS=(3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t) rsloop_target_python_request() { local version="$1" @@ -57,7 +57,7 @@ rsloop_default_versions_for_target() { case "$target" in aarch64-pc-windows-msvc) # Windows ARM64 Python distributions are only available for newer CPython releases. - printf '%s\n' 3.11 3.12 3.13 3.14 3.14t 3.15 + printf '%s\n' 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t ;; *) printf '%s\n' "${RSLOOP_DEFAULT_PYTHON_VERSIONS[@]}" diff --git a/scripts/test-supported-pythons.sh b/scripts/test-supported-pythons.sh index ce7cb42..c0acbc0 100755 --- a/scripts/test-supported-pythons.sh +++ b/scripts/test-supported-pythons.sh @@ -23,7 +23,7 @@ Options: Environment: RSLOOP_PYTHON_VERSIONS Space-separated version list to override the defaults - (default: 3.10 3.11 3.12 3.13 3.14 3.14t 3.15) + (default: 3.10 3.11 3.12 3.13 3.14 3.14t 3.15 3.15t) RSLOOP_TEST_TIMEOUT_SECONDS Hard limit for one Python test suite (default: 300) RSLOOP_TEST_TRACEBACK_INTERVAL_SECONDS