Skip to content

Add safe context watcher API - #6227

Open
florentinl wants to merge 9 commits into
PyO3:mainfrom
florentinl:florentin.labelle/add-safe-wrappers-for-context-watching-api
Open

Add safe context watcher API#6227
florentinl wants to merge 9 commits into
PyO3:mainfrom
florentinl:florentin.labelle/add-safe-wrappers-for-context-watching-api

Conversation

@florentinl

@florentinl florentinl commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Adds PyContext and safe context-watcher bindings for GIL-enabled CPython 3.14:

  • typed context-switch events
  • RAII watcher registration
  • panic- and error-safe callback trampoline
use pyo3::{context::ContextEvent, prelude::*};

fn context_changed(_py: Python<'_>, event: ContextEvent<'_, '_>) -> PyResult<()> {
    if let ContextEvent::Switched(context) = event {
        println!("current context: {context:?}");
    }
    Ok(())
}

fn main() -> PyResult<()> {
    Python::initialize();

    Python::attach(|py| {
        let _watcher = pyo3::register_context_watcher!(py, context_changed)?;
        py.run(
            c"import contextvars\ncontextvars.Context().run(lambda: None)",
            None,
            None,
        )
    })
}

Tested with unit tests, doctests, and Clippy on Python 3.14.

Comment thread src/context.rs
/// storage. State can still be shared through safe static synchronization primitives.
///
/// Panics and returned [`PyErr`][crate::PyErr] values are reported as unraisable exceptions and
/// never unwind across the C boundary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mention this as well?

/// # Thread Safety
///
/// On GIL-enabled builds, callbacks are invoked synchronously. On free-threaded
/// builds (when this API is available), callbacks may be invoked concurrently.
/// Ensure your callback is thread-safe if needed.

@florentinl florentinl Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, It should definitely be mentioned if we enable the API on free threaded builds.

The problem is that PyContext_AddWatcher and PyContext_ClearWatcher while available on free threaded are not callable concurrently and require external synchronization, which is not something we can guarantee from an extension.

I was thinking of making this API only available on GIL-enabled builds for this reason.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can should requireSend + Sync + 'static on the provided closure.

@florentinl florentinl Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API in this PR accepts a function path, which is coerced to a function pointer. Function pointers carry no captured state and are already Send + Sync + 'static, so there is nothing additional to enforce.

CPython’s watcher API does not expose a user-data pointer where we could store a closure, which is why I did not try to implement a closure registration api.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bschoenmaeckers can I ask you to give it another look ? Thanks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that PyContext_AddWatcher and PyContext_ClearWatcher while available on free threaded are not callable concurrently and require external synchronization, which is not something we can guarantee from an extension.

Where do you conclude this? We should make a CPython issue for this if not already present.

Comment thread src/context.rs
@florentinl
florentinl force-pushed the florentin.labelle/add-safe-wrappers-for-context-watching-api branch from eb0dff3 to 66ad857 Compare July 28, 2026 07:47
@florentinl
florentinl marked this pull request as ready for review July 28, 2026 08:19
@florentinl
florentinl requested a review from chirizxc July 28, 2026 08:19
Comment thread src/context.rs
@florentinl
florentinl force-pushed the florentin.labelle/add-safe-wrappers-for-context-watching-api branch from 7259b06 to 69b141c Compare July 28, 2026 10:40
@florentinl
florentinl requested a review from chirizxc July 28, 2026 11:20
@chirizxc

Copy link
Copy Markdown
Contributor

LGTM, but im not a maintainer

@florentinl

Copy link
Copy Markdown
Contributor Author

Still, thank you for taking a look.

@florentinl
florentinl force-pushed the florentin.labelle/add-safe-wrappers-for-context-watching-api branch 2 times, most recently from b253b69 to 2041732 Compare August 13, 2026 13:27
gh-worker-dd-mergequeue-cf854d Bot pushed a commit to DataDog/dd-trace-py that referenced this pull request Aug 13, 2026
## Description

Add a generic CPython 3.14+ publisher for the internal `python.context.switch` event.

It registers the CPython `PyContext_AddWatcher` API from the native extension and dispatches the event after each context switch.

The C callback catches Rust panics at the FFI boundary and preserves any exception already pending when CPython invokes it.

This PR is stacked on #19604, which adds the public `DD_TRACE_OTEL_CTX_ENABLED` configuration and activation-listener setup. The setting defaults to `true`; setting it to `false` disables both the listener and this Python 3.14 watcher. It also complements #19336, which independently provides the asyncio, uvloop, AnyIO, and greenlet emitters for older Python versions.

## Testing

- `scripts/lint checks`
- `reno lint`
- CPython 3.14 native context watcher: 4 passed
- Linux CPython 3.14.5 OTel thread context: 6 passed

## Risks

Low

## Additional Notes

- The PR contains raw external C bindings to CPython. I am currently upstreaming them to PyO3; once that work is merged and released, we can rely on the PyO3 bindings and simplify this code:
  - PyO3/pyo3#6204
  - PyO3/pyo3#6227


Co-authored-by: florentin.labelle <florentin.labelle@datadoghq.com>
@florentinl
florentinl force-pushed the florentin.labelle/add-safe-wrappers-for-context-watching-api branch from 9826318 to d0a8de6 Compare August 20, 2026 12:56
Fix the newsfragment's overclaim that PyContext requires GIL-enabled
CPython 3.14+ (it's available on all supported versions; only the
watcher API needs 3.14+), clarify the SAFETY comments on
ContextWatcherGuard around single-interpreter attachment, and drop a
redundant doctest cfg attribute already covered by the module-level
gate in lib.rs.
@florentinl
florentinl force-pushed the florentin.labelle/add-safe-wrappers-for-context-watching-api branch from d0a8de6 to cf95e72 Compare August 20, 2026 13:32
@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 10.31%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 140 untouched benchmarks
⏩ 6 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_empty_class_init_py 27.1 µs 24.5 µs +10.31%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing florentinl:florentin.labelle/add-safe-wrappers-for-context-watching-api (cf95e72) with main (6b67723)

Open in CodSpeed

Footnotes

  1. 6 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@davidhewitt

Copy link
Copy Markdown
Member

I would quite like to avoid the macro for this API, it seems unfortunate. I understand why it's there, however. I'm thinking about what we can do as alternatives.

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened florentinl#1 which has a suggestion how I'd like this API to look.

Also placed a few other comments here.

Comment thread src/context.rs
/// storage. State can still be shared through safe static synchronization primitives.
///
/// Panics and returned [`PyErr`][crate::PyErr] values are reported as unraisable exceptions and
/// never unwind across the C boundary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that PyContext_AddWatcher and PyContext_ClearWatcher while available on free threaded are not callable concurrently and require external synchronization, which is not something we can guarantee from an extension.

Where do you conclude this? We should make a CPython issue for this if not already present.

Comment thread src/context.rs
Comment on lines +45 to +46
/// This guard is bound to the [`Python`] attachment used to create it. It therefore cannot be sent
/// to another thread, moved outside that attachment, or moved into [`Python::detach`].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is potentially awkward, we might want to consider the same strategy as PyBuffer here which automatically attaches on drop.

That said, a bound type is more efficient so maybe we want to support the ability to bind & unbind this and have two guard types? 🤔

I have a feeling that for most usecases the lifetime will be a problem.

Comment thread src/context.rs
///
/// - The thread must be attached to Python.
/// - `object` must follow the contract for the supplied `event`.
pub unsafe extern "C" fn context_watcher<Callback: ContextWatcherCallbackDef>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be desirable to reduce the amount of unsafe code in here by using PyO3's higher-level APIs where possible.

Comment thread src/context.rs
Comment on lines +228 to +232
// Although normal PyO3 APIs return errors as `PyResult`, `PyErr::restore` can be
// called directly. Do not allow an Ok return with an exception still set.
if crate::PyErr::occurred(py) {
return Err(crate::PyErr::fetch(py));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a general problem that would be worth addressing in all trampolines rather than special casing here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants