Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/python-315t.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions docs/free-threading.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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).
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion scripts/build-wheels.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions scripts/check-free-threaded-wheel.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 4 additions & 4 deletions scripts/python-versions.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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[@]}"
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-supported-pythons.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading