From 222a684fd19bcc349b47d3f76e6c4641be9782e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 11:40:26 +0000 Subject: [PATCH 1/3] Test and ship free-threaded Python support deliberately PyO3 0.28 made gil_used = false the default, so this module has declared that it runs without the GIL since the 0.7.0 upgrade, and 0.10.0 already published cp314t and cp315t wheels for Linux because the manylinux images carry the free-threaded interpreters. Nothing had ever run the tests there. - CI gains a python3.14t leg that runs the whole suite with PYTHON_GIL=0, and pins uv to the matrix interpreter (UV_PYTHON) so it cannot fall back to a GIL build of the same version. The macOS and Windows x64 wheel jobs install 3.14t and build the free-threaded wheel explicitly, since those runners have no such interpreter for --find-interpreter to discover. - The gil_used = false declaration is now explicit, and compile-time assertions pin cel-rust's Program, Context, Value and Env as Send + Sync so an upstream change cannot silently reintroduce a data race into a free-threaded wheel. - Program and OptionalValue are frozen: they have no mutating methods, and sharing them between threads now involves no borrow tracking. - pyo3_log::init() panicked if the module was initialised twice in one process; try_init() tolerates the second logger installation. - The Context environment cache is locked with lock_py_attached, so a thread waiting for it cannot stall a free-threaded interpreter's stop-the-world pause while the holder runs Python API under the lock. - tests/test_free_threading.py checks, in a fresh interpreter without PYTHON_GIL, that importing cel does not make CPython re-enable the GIL, and pins the concurrency contract for Context: concurrent evaluation is safe and consistent; concurrent mutation raises "Already borrowed" rather than racing. - Documented the threading contract; maturin floor raised to 1.14, the first release that discovers free-threaded interpreters. Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW --- .github/workflows/ci.yml | 57 ++++++++++++++++- CHANGELOG.md | 40 ++++++++++++ docs/reference/python-api.md | 9 +++ pyproject.toml | 3 +- src/context.rs | 19 +++++- src/lib.rs | 34 ++++++++-- tests/test_free_threading.py | 120 +++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 tests/test_free_threading.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b0dc00..1c1369a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + # "3.14t" is the free-threaded build. The module declares that it runs + # without the GIL and free-threaded wheels are published, so the suite + # has to pass there too. + python-version: ["3.11", "3.12", "3.13", "3.14", "3.14t"] + env: + # Pin uv to the interpreter this job installs. The runner also carries + # system Pythons, and for the free-threaded entry uv would otherwise + # prefer a GIL build of the same version. + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v7 @@ -48,8 +56,19 @@ jobs: - name: Run Rust tests run: cargo test --verbose - + - name: Run Python tests + if: ${{ !endsWith(matrix.python-version, 't') }} + run: uv run pytest --verbose --tb=short + + - name: Run Python tests (free-threaded, GIL disabled) + if: ${{ endsWith(matrix.python-version, 't') }} + # PYTHON_GIL=0 keeps the GIL off even if some dependency has not declared + # free-threading support, so the suite exercises the free-threaded path + # rather than a silently re-enabled GIL. tests/test_free_threading.py + # separately checks that importing this module alone does not re-enable it. + env: + PYTHON_GIL: "0" run: uv run pytest --verbose --tb=short lint: @@ -156,8 +175,10 @@ jobs: platform: - runner: windows-latest target: x64 + free-threaded: true - runner: windows-latest target: x86 + free-threaded: false steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 @@ -170,6 +191,25 @@ jobs: target: ${{ matrix.platform.target }} args: --release --out dist --find-interpreter sccache: 'true' + # The manylinux images used by the Linux jobs carry python3.14t, so + # --find-interpreter builds the free-threaded wheel there on its own. This + # runner does not, so install the interpreter and build that wheel + # explicitly. Installed after the first build so --find-interpreter above + # does not also pick it up. + - name: Set up free-threaded Python + if: ${{ matrix.platform.free-threaded }} + id: free-threaded + uses: actions/setup-python@v7 + with: + python-version: '3.14t' + architecture: ${{ matrix.platform.target }} + - name: Build free-threaded wheel + if: ${{ matrix.platform.free-threaded }} + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist -i ${{ steps.free-threaded.outputs.python-path }} + sccache: 'true' - name: Upload wheels uses: actions/upload-artifact@v7 with: @@ -197,6 +237,19 @@ jobs: target: ${{ matrix.platform.target }} args: --release --out dist --find-interpreter sccache: 'true' + # See the Windows job: this runner has no python3.14t, so the free-threaded + # wheel needs the interpreter installed and an explicit build. + - name: Set up free-threaded Python + id: free-threaded + uses: actions/setup-python@v7 + with: + python-version: '3.14t' + - name: Build free-threaded wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist -i ${{ steps.free-threaded.outputs.python-path }} + sccache: 'true' - name: Upload wheels uses: actions/upload-artifact@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f62b4d8..40466f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Free-threaded CPython is now supported deliberately rather than by accident.** + PyO3 0.28 made `gil_used = false` the default, so since 0.7.0 this module has + declared that it runs without the GIL, and 0.10.0 already published `cp314t` and + `cp315t` wheels for Linux because the manylinux images carry the free-threaded + interpreters. Nothing had ever tested that. CI now runs the whole test suite on + `python3.14t` with `PYTHON_GIL=0`; the declaration is explicit in the source; + free-threaded wheels are also built for macOS and Windows (x64); a test in a fresh + interpreter checks that importing `cel` does not make CPython re-enable the GIL; + and compile-time assertions pin the cel-rust `Program`, `Context`, `Value` and + `Env` types as `Send + Sync`, so an upstream change cannot silently reintroduce a + data race into a free-threaded wheel + ([#45](https://github.com/hardbyte/python-common-expression-language/issues/45)). + +### Changed + +- `Program` and `OptionalValue` are frozen classes, which is what they already + were in practice: they have no mutating methods, and now no attribute can be + assigned on them either. Sharing them between threads involves no borrow + tracking. +- Concurrent use of one `Context` is documented and tested: evaluating from many + threads is safe and every evaluation sees a consistent snapshot; mutating from + several threads at the same time raises `RuntimeError: Already borrowed` on a + free-threaded interpreter rather than corrupting state. Build a context before + sharing it, or guard mutation with a lock. + +### Fixed + +- Initialising the extension module a second time in one process (a + sub-interpreter, for instance) no longer panics while installing the logger. +- The lock guarding a `Context`'s cached environment is taken with PyO3's + `lock_py_attached`, so a thread waiting for it cannot stall a free-threaded + interpreter's stop-the-world pause. + +### Updated + +- Building from the sdist requires maturin 1.14 or later, the first release that + discovers free-threaded interpreters. + ## [0.10.0] - 2026-09-15 Fixes `import cel` in a clean install, which has been broken since 0.6.0 for diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index cf00408..d4ff43f 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -213,6 +213,15 @@ reuses that work for every subsequent `evaluate()` or `Program.execute()` call until the context is modified. A dict passed as the context is converted afresh on every call, because it can change between calls without notice. +**Threads.** A `Program` is immutable and safe to share between threads. A +`Context` is safe to evaluate against from many threads at once: each evaluation +runs against a consistent snapshot, and a change made from another thread applies +from the next evaluation. Mutating one `Context` from several threads at the same +time is not supported; on a free-threaded interpreter (`python3.14t`) it raises +`RuntimeError: Already borrowed` rather than corrupting state. Build a context +before sharing it, or guard mutation with your own lock. Free-threaded wheels +(`cp314t`) are published and the module declares that it does not need the GIL. + ```python from cel import evaluate, Context diff --git a/pyproject.toml b/pyproject.toml index 66200db..7e2d28e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,8 @@ Homepage = "https://github.com/hardbyte/python-common-expression-language" Repository = "https://github.com/hardbyte/python-common-expression-language" [build-system] -requires = ["maturin>=1.8,<2.0"] +# 1.14 is the first maturin that discovers free-threaded (3.14t+) interpreters. +requires = ["maturin>=1.14,<2.0"] build-backend = "maturin" diff --git a/src/context.rs b/src/context.rs index 387f94f..4000123 100644 --- a/src/context.rs +++ b/src/context.rs @@ -2,6 +2,7 @@ use ::cel::objects::TryIntoValue; use ::cel::{Context as CelContext, Value}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; +use pyo3::sync::MutexExt; use pyo3::types::PyDict; use pyo3::IntoPyObjectExt; use std::collections::HashMap; @@ -38,8 +39,13 @@ use std::sync::{Arc, Mutex, PoisonError}; /// ``add_function()`` or ``update()``. /// /// Thread Safety: -/// Context objects are not thread-safe. Create separate Context instances -/// for concurrent use or implement your own synchronization. +/// Evaluating against one Context from several threads at once is safe: +/// each evaluation uses a consistent snapshot of the variables and +/// functions, and a change made from another thread applies from the next +/// evaluation. Mutating one Context from several threads at the same time +/// is not supported; on a free-threaded interpreter it raises +/// ``RuntimeError: Already borrowed`` rather than corrupting state. Build +/// the context before sharing it, or guard mutation with your own lock. /// /// Performance Tips: /// - Reuse Context objects for multiple evaluations when possible: the @@ -86,7 +92,14 @@ impl Context { /// even if a Python callback mutates this `Context` mid-evaluation; the /// mutation simply takes effect from the next evaluation. pub(crate) fn cel_context(&self, py: Python<'_>) -> Arc> { - let mut cached = self.cel.lock().unwrap_or_else(PoisonError::into_inner); + // `lock_py_attached` rather than `lock`: Python API runs under this lock + // (`clone_ref` in `build_cel_context`), and on a free-threaded + // interpreter a thread that blocks on a plain mutex while attached can + // stall a stop-the-world pause that the lock holder is waiting for. + let mut cached = self + .cel + .lock_py_attached(py) + .unwrap_or_else(PoisonError::into_inner); if let Some(existing) = cached.as_ref() { return Arc::clone(existing); } diff --git a/src/lib.rs b/src/lib.rs index 84897b1..d078f97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,21 @@ pub(crate) fn new_environment() -> CelContext<'static> { CelContext::with_env(stdlib_env()) } +/// Compile-time proof that the cel-rust types this module shares between threads +/// are `Send + Sync`. Free-threaded Python loads this module without a GIL (see the +/// `gil_used` declaration on the module), so a `Context` cached in an `Arc` and a +/// `Program` held by several threads must be sound to share. cel-rust guarantees +/// this today (`Val`, `Function` and `VariableResolver` all require it); if an +/// upstream release drops the bound, this fails to build instead of letting a data +/// race into a `cp314t` wheel. +const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::>(); + assert_send_sync::(); + assert_send_sync::(); +}; + /// A compiled CEL program that can be executed multiple times with different contexts. /// /// This is useful when you need to evaluate the same expression many times with different @@ -60,7 +75,9 @@ pub(crate) fn new_environment() -> CelContext<'static> { /// result1 = program.execute({"price": 10, "quantity": 20}) # True /// result2 = program.execute({"price": 5, "quantity": 10}) # False /// ``` -#[pyclass(name = "Program")] +// `frozen`: a compiled program never changes after construction, so Python may share +// it between threads without PyO3's per-access borrow tracking. +#[pyclass(name = "Program", frozen)] struct PyProgram { program: Program, source: String, @@ -152,7 +169,7 @@ impl PyProgram { } /// A CEL optional value wrapper for Python. -#[pyclass(name = "OptionalValue")] +#[pyclass(name = "OptionalValue", frozen)] struct PyOptionalValue { value: Option, } @@ -1039,9 +1056,18 @@ fn execute_compiled_program( }) } -#[pymodule] +// `gil_used = false` has been PyO3's default since 0.28; spelling it out records the +// decision. Every pyclass here is `Send + Sync` (PyO3 asserts that at compile time), +// the shared standard-library `Env` is immutable behind a `LazyLock`, and the +// `Context` environment cache sits behind a `Mutex`, so a free-threaded interpreter +// may load this module without re-enabling the GIL. CI runs the test suite on +// `python3.14t` with `PYTHON_GIL=0` to keep that true. +#[pymodule(gil_used = false)] fn cel(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { - pyo3_log::init(); + // The module can be initialised more than once in one process (a + // sub-interpreter, for instance). `init()` panics when a logger is already + // installed; a second installation failing is harmless, so tolerate it. + let _ = pyo3_log::try_init(); m.add_function(wrap_pyfunction!(evaluate, m)?)?; m.add_function(wrap_pyfunction!(compile, m)?)?; diff --git a/tests/test_free_threading.py b/tests/test_free_threading.py new file mode 100644 index 0000000..34cc1cf --- /dev/null +++ b/tests/test_free_threading.py @@ -0,0 +1,120 @@ +"""Free-threaded CPython (``python3.14t``) support. + +The extension declares that it does not need the GIL (PyO3's ``gil_used = false``) +and free-threaded wheels are published, so these tests pin the two things that +declaration commits us to: importing the module must not make CPython re-enable +the GIL, and sharing objects between threads must never corrupt state. Most of +the file also runs on a regular build, where it documents the same contract. +""" + +import os +import subprocess +import sys +import sysconfig +from concurrent.futures import ThreadPoolExecutor + +import cel +import pytest + +FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +requires_free_threaded = pytest.mark.skipif( + not FREE_THREADED_BUILD, reason="only meaningful on a free-threaded CPython build" +) + + +@requires_free_threaded +def test_suite_runs_with_the_gil_disabled(): + """Sanity check for CI: the free-threaded leg really is exercising the GIL-free path. + + CI sets ``PYTHON_GIL=0`` for that leg so a dependency lacking the free-threading + declaration cannot quietly turn the GIL back on for the whole run. + """ + assert not sys._is_gil_enabled() + + +@requires_free_threaded +def test_importing_cel_does_not_reenable_the_gil(): + """Without ``PYTHON_GIL`` forcing the matter, importing ``cel`` alone must leave the GIL off. + + A module that has not declared free-threading support makes CPython re-enable + the GIL at import and emit a RuntimeWarning naming the module. Run in a fresh + interpreter so nothing else imported by the test session can mask the result, + and turn warnings into errors so the RuntimeWarning is loud. + """ + env = {k: v for k, v in os.environ.items() if k != "PYTHON_GIL"} + script = ( + "import sys, warnings\n" + "warnings.simplefilter('error')\n" + "import cel\n" + "assert cel.evaluate('1 + 1') == 2\n" + "assert not sys._is_gil_enabled(), 'importing cel re-enabled the GIL'\n" + ) + subprocess.run([sys.executable, "-c", script], env=env, check=True, timeout=60) + + +def test_program_is_immutable(): + """``Program`` is a frozen class, so sharing one between threads needs no locking.""" + program = cel.compile("x + 1") + with pytest.raises(AttributeError): + program.source = "y" # type: ignore[misc] + with pytest.raises(AttributeError): + program.anything = 1 # type: ignore[attr-defined] + + +def test_optional_value_is_immutable(): + opt = cel.OptionalValue.of(1) + with pytest.raises(AttributeError): + opt.anything = 1 # type: ignore[attr-defined] + + +def test_concurrent_mutation_raises_rather_than_races(): + """Mutating one ``Context`` from several threads is unsupported but never unsafe. + + Readers see a consistent snapshot on every evaluation. A writer that collides + with another borrow of the same ``Context`` gets PyO3's ``RuntimeError: Already + borrowed`` (only reachable on a free-threaded build); it never corrupts the + context. Whatever interleaving happens, every value observed must be one that + some writer actually stored. + """ + ctx = cel.Context({"v": 0}) + program = cel.compile("v") + rounds = 500 + + def writer(_): + for n in range(rounds): + try: + ctx.add_variable("v", n) + except RuntimeError as exc: + assert "borrowed" in str(exc).lower(), exc + + def reader(_): + for _ in range(rounds): + try: + result = program.execute(ctx) + except RuntimeError as exc: + assert "borrowed" in str(exc).lower(), exc + continue + assert isinstance(result, int) and 0 <= result < rounds, result + + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(writer if i % 2 else reader, i) for i in range(8)] + for future in futures: + future.result() + + assert 0 <= ctx.variables["v"] < rounds + + +def test_shared_program_and_context_across_threads_agree(): + """Many threads evaluating one frozen ``Program`` against one ``Context`` all get the same answer.""" + ctx = cel.Context({"items": list(range(200))}) + ctx.add_function("bump", lambda n: n + 1) + program = cel.compile("bump(items.filter(i, i % 2 == 0).size())") + + def work(_): + return [program.execute(ctx) for _ in range(200)] + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(work, range(8))) + + assert all(result == [101] * 200 for result in results) From f67fe2005999d16f2963ecd2e0c1ceee21a3e55a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 11:42:54 +0000 Subject: [PATCH 2/3] Wait for a concurrent Context mutation instead of misreporting the type Found by the new free-threaded CI leg. prepare_environment extracted a PyRef and, when the extract failed, fell through to the dict check and finally to "evaluation_context must be a Context object or a dict". On a free-threaded interpreter the extract fails whenever another thread is inside a mutator holding the exclusive borrow, so a reader racing an add_variable got a ValueError claiming its Context was not a Context. Check the type first, then take the borrow with a short yield-retry (a mutator holds it only for one call), and if it still cannot be taken raise a RuntimeError that names the concurrent modification. Verified on python3.14t with PYTHON_GIL=0: 566 passed across three runs. Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW --- CHANGELOG.md | 15 +++++++++++---- docs/reference/python-api.md | 3 ++- src/context.rs | 5 +++-- src/lib.rs | 28 +++++++++++++++++++++++++++- tests/test_free_threading.py | 10 ++++++---- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40466f8..92968c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,13 +29,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 assigned on them either. Sharing them between threads involves no borrow tracking. - Concurrent use of one `Context` is documented and tested: evaluating from many - threads is safe and every evaluation sees a consistent snapshot; mutating from - several threads at the same time raises `RuntimeError: Already borrowed` on a - free-threaded interpreter rather than corrupting state. Build a context before - sharing it, or guard mutation with a lock. + threads is safe and every evaluation sees a consistent snapshot; an evaluation + that starts while another thread is inside a mutator briefly waits for it; and + mutating from several threads at the same time raises `RuntimeError: Already + borrowed` on a free-threaded interpreter rather than corrupting state. Build a + context before sharing it, or guard mutation with a lock. ### Fixed +- On a free-threaded interpreter, evaluating against a `Context` at the exact + moment another thread was mutating it raised a misleading + `ValueError: evaluation_context must be a Context object or a dict`, because the + failed borrow fell through to the type check. Found by the new free-threaded CI + leg. It now waits briefly for the mutation and, if the context is still held, + raises a `RuntimeError` that says so. - Initialising the extension module a second time in one process (a sub-interpreter, for instance) no longer panics while installing the logger. - The lock guarding a `Context`'s cached environment is taken with PyO3's diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index d4ff43f..0e70f2c 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -216,7 +216,8 @@ on every call, because it can change between calls without notice. **Threads.** A `Program` is immutable and safe to share between threads. A `Context` is safe to evaluate against from many threads at once: each evaluation runs against a consistent snapshot, and a change made from another thread applies -from the next evaluation. Mutating one `Context` from several threads at the same +from the next evaluation (an evaluation that starts while another thread is +mid-mutation briefly waits for it). Mutating one `Context` from several threads at the same time is not supported; on a free-threaded interpreter (`python3.14t`) it raises `RuntimeError: Already borrowed` rather than corrupting state. Build a context before sharing it, or guard mutation with your own lock. Free-threaded wheels diff --git a/src/context.rs b/src/context.rs index 4000123..e5103f9 100644 --- a/src/context.rs +++ b/src/context.rs @@ -42,8 +42,9 @@ use std::sync::{Arc, Mutex, PoisonError}; /// Evaluating against one Context from several threads at once is safe: /// each evaluation uses a consistent snapshot of the variables and /// functions, and a change made from another thread applies from the next -/// evaluation. Mutating one Context from several threads at the same time -/// is not supported; on a free-threaded interpreter it raises +/// evaluation (an evaluation that starts mid-mutation briefly waits for +/// it). Mutating one Context from several threads at the same time is not +/// supported; on a free-threaded interpreter it raises /// ``RuntimeError: Already borrowed`` rather than corrupting state. Build /// the context before sharing it, or guard mutation with your own lock. /// diff --git a/src/lib.rs b/src/lib.rs index d078f97..b0c82c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -565,6 +565,31 @@ struct Environment { resolver: Option, } +/// Takes the shared borrow of a `Context` that an evaluation needs. +/// +/// On a free-threaded interpreter another thread may be inside a mutator +/// (`add_variable`, `update`, ...) at this moment, holding the exclusive borrow. +/// A mutator holds it only for the duration of one call, so yield a few times +/// before giving up, which lets readers ride out a concurrent update. If it +/// still cannot be borrowed, report that rather than the misleading "must be a +/// Context or a dict" that a failed `extract` would otherwise fall through to. +fn borrow_context<'py>( + bound: &Bound<'py, context::Context>, +) -> PyResult> { + const ATTEMPTS: usize = 64; + for _ in 0..ATTEMPTS { + match bound.try_borrow() { + Ok(context) => return Ok(context), + Err(_) => std::thread::yield_now(), + } + } + Err(PyRuntimeError::new_err( + "Context is being modified by another thread (already mutably borrowed). \ + Finish building a Context before sharing it between threads, or guard \ + mutation with a lock.", + )) +} + /// Turns the `evaluation_context` argument of `evaluate()`/`Program.execute()` /// into an [`Environment`], so the two entry points behave identically. fn prepare_environment(evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResult { @@ -576,10 +601,11 @@ fn prepare_environment(evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResul }; let py = evaluation_context.py(); - if let Ok(py_context) = evaluation_context.extract::>() { + if let Ok(bound_context) = evaluation_context.cast::() { // The borrow of the Python object ends when `py_context` drops at the end // of this block, before any Python callback can run, so a callback that // mutates the Context mid-evaluation does not hit a "borrowed" error. + let py_context = borrow_context(bound_context)?; let resolver = py_context .resolver .as_ref() diff --git a/tests/test_free_threading.py b/tests/test_free_threading.py index 34cc1cf..e7339d2 100644 --- a/tests/test_free_threading.py +++ b/tests/test_free_threading.py @@ -71,10 +71,12 @@ def test_optional_value_is_immutable(): def test_concurrent_mutation_raises_rather_than_races(): """Mutating one ``Context`` from several threads is unsupported but never unsafe. - Readers see a consistent snapshot on every evaluation. A writer that collides - with another borrow of the same ``Context`` gets PyO3's ``RuntimeError: Already - borrowed`` (only reachable on a free-threaded build); it never corrupts the - context. Whatever interleaving happens, every value observed must be one that + Readers see a consistent snapshot on every evaluation and briefly wait out a + writer that is mid-mutation. A writer that collides with another borrow of the + same ``Context`` gets PyO3's ``RuntimeError: Already borrowed`` (only reachable + on a free-threaded build); a reader that cannot get the borrow at all gets a + ``RuntimeError`` naming the concurrent modification. Neither ever corrupts the + context: whatever interleaving happens, every value observed must be one that some writer actually stored. """ ctx = cel.Context({"v": 0}) From 23a3aaaad41ed7001ea0a7d64671068674b1b88f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 20:09:45 +0000 Subject: [PATCH 3/3] Tighten the free-threading CHANGELOG entries to what a user needs Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW --- CHANGELOG.md | 50 ++++++++++++++++---------------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92968c4..f1464c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,50 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Free-threaded CPython is now supported deliberately rather than by accident.** - PyO3 0.28 made `gil_used = false` the default, so since 0.7.0 this module has - declared that it runs without the GIL, and 0.10.0 already published `cp314t` and - `cp315t` wheels for Linux because the manylinux images carry the free-threaded - interpreters. Nothing had ever tested that. CI now runs the whole test suite on - `python3.14t` with `PYTHON_GIL=0`; the declaration is explicit in the source; - free-threaded wheels are also built for macOS and Windows (x64); a test in a fresh - interpreter checks that importing `cel` does not make CPython re-enable the GIL; - and compile-time assertions pin the cel-rust `Program`, `Context`, `Value` and - `Env` types as `Send + Sync`, so an upstream change cannot silently reintroduce a - data race into a free-threaded wheel +- **Free-threaded Python.** The test suite runs on the free-threaded build of + Python 3.14 (`python3.14t`), and free-threaded wheels ship for Linux, macOS and + Windows x64. Importing `cel` leaves the GIL disabled ([#45](https://github.com/hardbyte/python-common-expression-language/issues/45)). ### Changed -- `Program` and `OptionalValue` are frozen classes, which is what they already - were in practice: they have no mutating methods, and now no attribute can be - assigned on them either. Sharing them between threads involves no borrow - tracking. -- Concurrent use of one `Context` is documented and tested: evaluating from many - threads is safe and every evaluation sees a consistent snapshot; an evaluation - that starts while another thread is inside a mutator briefly waits for it; and - mutating from several threads at the same time raises `RuntimeError: Already - borrowed` on a free-threaded interpreter rather than corrupting state. Build a - context before sharing it, or guard mutation with a lock. +- `Program` and `OptionalValue` are immutable; attributes can no longer be + assigned on them. +- One `Context` may be evaluated against from many threads at once, and + evaluations see a consistent snapshot. An evaluation that starts during + `add_variable`, `add_function` or `update` waits for it. Mutating a `Context` + from two threads at once raises `RuntimeError` on a free-threaded interpreter; + build a context before sharing it, or guard mutation with a lock. ### Fixed -- On a free-threaded interpreter, evaluating against a `Context` at the exact - moment another thread was mutating it raised a misleading - `ValueError: evaluation_context must be a Context object or a dict`, because the - failed borrow fell through to the type check. Found by the new free-threaded CI - leg. It now waits briefly for the mutation and, if the context is still held, - raises a `RuntimeError` that says so. -- Initialising the extension module a second time in one process (a - sub-interpreter, for instance) no longer panics while installing the logger. -- The lock guarding a `Context`'s cached environment is taken with PyO3's - `lock_py_attached`, so a thread waiting for it cannot stall a free-threaded - interpreter's stop-the-world pause. +- On a free-threaded interpreter, evaluating against a `Context` while another + thread was mutating it raised `ValueError: evaluation_context must be a Context + object or a dict`. It now waits for the mutation to finish. +- Loading the extension module a second time in one process (from a + sub-interpreter, for instance) no longer panics. ### Updated -- Building from the sdist requires maturin 1.14 or later, the first release that - discovers free-threaded interpreters. +- Building from the sdist requires maturin 1.14 or later. ## [0.10.0] - 2026-09-15