Writing C extensions with the bocpy public C ABI
This skill is for writing a downstream C extension whose custom
types must travel through bocpy's runtime โ placed inside a
Cown, scheduled with @when, or shipped via send/receive
across worker sub-interpreters.
Read
thinking-in-bocfirst. The C ABI does not change the BOC mental model. Your C type is still wrapped in aCown, behaviors are still scheduled with@when, and ordering still comes from the cown graph โ not from anything you do in C. The C ABI only buys you a safe, zero-copy way to cross the interpreter boundary; the choreography between behaviors is unchanged.
When you need this skill
You need a C extension that uses the bocpy public C ABI only when all three of the following are true:
- Your type wraps a native C resource (large buffer, matrix, GPU handle, file descriptor, FFI pointer, etc.) that you do not want to copy or pickle every time it crosses interpreter boundaries.
- You want instances of that type to live inside a
Cownand be acquired by@whenbehaviors on worker sub-interpreters. - You want isolation guarantees: at most one interpreter may read or write the resource at a time, and any stale wrapper left behind in the previous owner cannot observe the resource any more.
If your type is pure-Python, pickleable, or you are happy with a copy
on each interpreter crossing, you do not need this skill โ
Cown(my_obj) already works. Read the BOC primer in the project
copilot instructions and stop here.
The canonical worked example shipped by bocpy lives in
templates/c_abi_consumer/ of the bocpy source tree โ copy it as the
starting point for a new extension. The Matrix type in
src/bocpy/_math.c is the in-tree reference implementation.
The proto-Region mental model
A bocpy-aware C type implements proto-Region ownership: a single
atomic owner field on the impl identifies the interpreter that may
read or write the payload. The producer-side XIData callback CASes
that field from bocpy_interpid() to BOCPY_NO_OWNER; the
consumer-side callback CASes it back from BOCPY_NO_OWNER to
bocpy_interpid(). Any data accessor that reads or writes the
payload first asserts bocpy_interpid() == atomic_load(&impl->owner)
and raises RuntimeError otherwise.
This is not the full Lungfish region model โ there are no nested regions, no freeze, no merge, no borrow tracking โ but it is enough to turn "a shareable pointer" into "a resource owned by exactly one interpreter at a time", which is what BOC needs.
What the proto-Region contract gives you in practice:
- A stale wrapper held in a producer interpreter after the handoff
cannot read or write the impl โ every accessor raises
RuntimeErroruntil ownership returns. This catches races that pointer-only sharing would miss. - The CAS in the producer callback fails if the calling interpreter is not the current owner. A behavior that tries to send a cown's value somewhere it should not go is rejected at the boundary, not silently corrupted.
Required scaffolding
setup.py
Use bocpy.get_include() for the header search path and
bocpy.get_sources() for the MSVC out-of-line atomics shim (no-op
elsewhere). Copy this verbatim from templates/c_abi_consumer/setup.py
and change the module name:
from setuptools import Extension, setup
import bocpy
setup(
ext_modules=[
Extension(
"_your_extension",
sources=["src/_your_extension.c"] + bocpy.get_sources(),
include_dirs=[bocpy.get_include()],
),
],
)pyproject.toml
Declare bocpy in both [build-system].requires (so an isolated PEP 517
build can satisfy import bocpy in setup.py) and
[project].dependencies (so installing your wheel installs bocpy at
runtime).
Always install with pip install --no-build-isolation so the build
resolves headers against the bocpy install actually being tested,
not whatever PyPI happens to publish.
Header include
#include <bocpy/bocpy.h>
/* Compile-time guard: bocpy.h must not leak Py_BUILD_CORE. */
#ifdef Py_BUILD_CORE
#error "Py_BUILD_CORE leaked from bocpy.h"
#endifRules:
<bocpy/bocpy.h>includes<Python.h>internally. It is order-insensitive with respect to<Python.h>itself.- It must appear before any system header (
<stdio.h>,<string.h>, ...) in the same translation unit, the same way<Python.h>must โ CPython forbids system headers beforePython.h. - C only.
<bocpy/bocpy.h>is not supported from C++ in this release. C++ consumers must wrap the ABI in a thin C translation unit.
Public ABI surface โ what you may use
Everything below is exposed by <bocpy/bocpy.h>. Treat anything else
under the bocpy package directory as private.
| Symbol | Purpose |
|---|---|
BOCPY_ABI |
Integer macro. Gate code on BOCPY_ABI >= N if you need a minimum revision. |
BOCPY_NO_OWNER |
Sentinel -2 meaning "no interpreter owns this impl right now". Use as initial / in-flight value of the owner field. |
bocpy_interpid() |
static inline int_least64_t. Returns the running interpreter's ID, pre-typed for the atomic CAS parameter list. Must be called with the GIL held / attached. |
atomic_int_least64_t |
64-bit atomic integer type. Sequentially consistent on every supported target. |
atomic_load(p), atomic_store(p, v), atomic_fetch_add(p, v), atomic_compare_exchange_strong(p, &exp, des) |
The four atomic ops you will need. SC on every supported MSVC target (x86, x64, ARM64); plain <stdatomic.h> elsewhere. |
thread_local |
Macro for thread-local storage. Use to cache the per-interpreter LOCAL_STATE so callbacks don't walk PyModule_GetState every call. |
XIDATA_T |
Opaque struct holding a serialised cross-interpreter handoff. |
XIDATA_NEW(), XIDATA_GETXIDATA(value, xidata), XIDATA_FREE(xidata), XIDATA_SET_FREE(xidata, fn) |
Lifecycle ops. You normally only call XIDATA_INIT from your producer callback; the rest is called by bocpy. |
XIDATA_INIT(xidata, interp, data, obj, new_object) |
Initialise an XIDATA_T. interp must be the interpreter that currently owns data. Buffer must be freshly allocated. |
XIDATA_NEWOBJECT |
Type alias for the consumer-side reconstruction callback. |
XIDATA_REGISTERCLASS(type, cb) |
Register a Python type as cross-interpreter shareable with producer callback cb. Per-interpreter โ call from the exec slot. |
XIDATA_GETDATA_FUNC(name) |
Macro that declares a producer callback with a portable (tstate, obj, xidata) signature. Hides the legacy-CPython signature change. |
Internal headers and surfaces (boc_compat.h, boc_cown.h,
boc_sched.h, boc_tags.h, boc_terminator.h, boc_noticeboard.h,
the typed atomics, the BOC mutex/condvar types, boc_yield, boc_now_*,
boc_sleep_ns, etc.) are not public. Do not depend on them.
The lifecycle: a Counter-sized walkthrough
The full annotated source is templates/c_abi_consumer/src/_bocpy_probe.c.
The skeleton below shows the five places you must get right.
1. The impl struct
A heap-allocated C struct that lives outside any single Python object's lifetime. Carries its own atomic refcount and its atomic owner field.
typedef struct {
atomic_int_least64_t refcount; /* number of wrappers holding this impl */
atomic_int_least64_t owner; /* interpreter ID, or BOCPY_NO_OWNER in flight */
/* ... your payload here ... */
} your_impl;
static your_impl *your_impl_new(void) {
your_impl *impl = PyMem_RawMalloc(sizeof(*impl));
if (impl == NULL) return NULL;
atomic_store(&impl->refcount, 1);
atomic_store(&impl->owner, bocpy_interpid()); /* born owned */
return impl;
}
static void your_impl_incref(your_impl *impl) {
atomic_fetch_add(&impl->refcount, 1);
}
static void your_impl_decref(your_impl *impl) {
if (atomic_fetch_add(&impl->refcount, -1) == 1) {
/* last holder, free the payload */
PyMem_RawFree(impl);
}
}
static bool your_impl_check_acquired(your_impl *impl, bool set_error) {
if (bocpy_interpid() != atomic_load(&impl->owner)) {
if (set_error)
PyErr_SetString(PyExc_RuntimeError,
"the current interpreter does not own this type");
return false;
}
return true;
}Refcounting and ownership are independent. Any interpreter holding a wrapper drops its ref on dealloc, regardless of who currently owns the impl. The proto-Region check guards data accessors, not the lifetime of the impl itself.
2. The Python wrapper, as a heap type with per-module state
Use PyType_FromModuleAndSpec (not the static-type pattern). Store
the type on per-module state and cache it in a thread_local
LOCAL_STATE so callbacks can find it without walking module state.
typedef struct {
PyTypeObject *your_type;
} your_module_state;
static thread_local your_module_state *LOCAL_STATE;
typedef struct {
PyObject_HEAD
your_impl *impl;
} YourObject;Data accessors must call your_impl_check_acquired before reading
the payload. Identity-only accessors (e.g. an address getter that
returns (uintptr_t)impl) may skip the check โ printing the address
of a Region handle without being inside the Region is allowed.
3. The producer callback โ declare with XIDATA_GETDATA_FUNC
Runs on the interpreter that currently owns the impl, every time
something asks XIData to package one of your objects. CAS the owner
field from this interpreter to BOCPY_NO_OWNER, then call
XIDATA_INIT. Failing the CAS surfaces as a RuntimeError and
aborts the handoff.
XIDATA_GETDATA_FUNC(_your_shared) {
YourObject *self = (YourObject *)obj;
your_impl *impl = self->impl;
if (impl == NULL) {
PyErr_SetString(PyExc_RuntimeError, "type not initialised");
return -1;
}
int_least64_t expected = bocpy_interpid();
int_least64_t desired = BOCPY_NO_OWNER;
if (!atomic_compare_exchange_strong(&impl->owner, &expected, desired)) {
PyErr_Format(PyExc_RuntimeError,
"cannot share: owned by interpreter %lld",
(long long)expected);
return -1;
}
XIDATA_INIT(xidata, tstate->interp, impl, obj, _new_your_object);
return 0;
}Why XIDATA_GETDATA_FUNC and not a hand-written signature? On older
CPython the callback is (obj, xidata) only โ no tstate. The macro
emits a small trampoline so the body is portable across every
supported CPython.
4. The consumer callback โ new_object reconstruction
Runs on the interpreter that is taking ownership. CAS the owner
from BOCPY_NO_OWNER to bocpy_interpid(), allocate a fresh
wrapper from the local heap type, and bump the impl refcount.
static PyObject *_new_your_object(XIDATA_T *xidata) {
your_impl *impl = (your_impl *)xidata->data;
int_least64_t expected = BOCPY_NO_OWNER;
int_least64_t desired = bocpy_interpid();
if (!atomic_compare_exchange_strong(&impl->owner, &expected, desired)) {
PyErr_Format(PyExc_RuntimeError,
"cannot acquire (expected BOCPY_NO_OWNER, observed %lld)",
(long long)expected);
return NULL;
}
PyTypeObject *type = LOCAL_STATE->your_type;
YourObject *self = (YourObject *)type->tp_alloc(type, 0);
if (self == NULL) {
/* CRITICAL: roll the owner back so a retry can succeed and
* the impl is not stranded with us as owner without a wrapper. */
atomic_store(&impl->owner, BOCPY_NO_OWNER);
return NULL;
}
self->impl = impl;
your_impl_incref(impl);
return (PyObject *)self;
}The wrapper-allocation rollback is easy to forget and impossible to recover from at runtime. Write it the same time you write the success path.
5. Module init โ multi-phase, per-interpreter-GIL aware
XIDATA_REGISTERCLASS registers into a per-interpreter registry.
It must run in every interpreter that will reconstruct one of your
objects โ which means every worker sub-interpreter, not just the main
one. Single-phase PyModule_Create modules load in the main
interpreter but cannot satisfy Py_MOD_PER_INTERPRETER_GIL_SUPPORTED,
and the registration never runs in worker interpreters; the consumer
callback then dereferences a NULL LOCAL_STATE and segfaults.
Use multi-phase init with a Py_mod_exec slot:
static int _your_module_exec(PyObject *module) {
your_module_state *state =
(your_module_state *)PyModule_GetState(module);
state->your_type = (PyTypeObject *)PyType_FromModuleAndSpec(
module, &YourType_Spec, NULL);
if (state->your_type == NULL) return -1;
if (PyModule_AddType(module, state->your_type) < 0) return -1;
if (XIDATA_REGISTERCLASS(state->your_type, _your_shared)) {
PyErr_SetString(PyExc_RuntimeError,
"could not register type for cross-interpreter sharing");
return -1;
}
LOCAL_STATE = state; /* prime the thread-local cache */
return 0;
}
static PyModuleDef_Slot _your_module_slots[] = {
{Py_mod_exec, (void *)_your_module_exec},
#if PY_VERSION_HEX >= 0x030C0000
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
#endif
#if PY_VERSION_HEX >= 0x030D0000
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
{0, NULL},
};Also implement m_traverse, m_clear, and m_free to manage the
type stored on per-module state โ this is standard multi-phase
hygiene, not bocpy-specific.
Hard rules
- Top-level
importof your extension in every Python file that schedules@whenbodies which observe your types. The transpiler propagates module-scopeimportstatements into the exported per-worker module. Runtime helpers likeimportlib.import_module(...),__import__(...), orpytest.importorskip(...)are invisible to the transpiler โ a worker without your extension loaded will skip the exec slot and the consumer callback will dereference a NULLLOCAL_STATE. XIDATA_REGISTERCLASSbelongs inPy_mod_exec, never inPyInit. The registry is per-interpreter.- Always pair the producer CAS with the consumer CAS. Producer:
bocpy_interpid() -> BOCPY_NO_OWNER. Consumer:BOCPY_NO_OWNER -> bocpy_interpid(). Anything else strands the impl. - Roll back the owner field on consumer-side wrapper allocation failure. Otherwise the impl is owned-but-unreferenced and no future handoff can succeed.
- Inside an
@when, neversend("tag", c.value)of a proto-Region resource.sendwould atomically move the impl out of the cown mid-behavior and leave the worker unable to release the cown afterwards. Send a copy (c.value.copy()) or send primitive summary data (c.value.address, a hash, a slice). The cown itself is the right primitive for handing the resource to another behavior โ schedule a downstream@whenon the same cown. - The cown graph still orders your work. The C ABI gives you
safe transport; ordering between behaviors still comes from
@when. If you find yourself reaching for athreading.Event, atomic flag, or polling loop to coordinate two behaviors on the same C type, re-readthinking-in-boc. - No
Py_BUILD_COREleakage. Guard for it with#errorat the top of your translation unit; a future bocpy refactor that forgets the#undefshould fail your build loudly, not at runtime. - No C++. Wrap the ABI in a thin C TU if your project needs C++.
Common pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Single-phase PyModule_Create |
Loads in main interpreter; segfaults in worker. | Switch to multi-phase init with Py_mod_exec and declare Py_MOD_PER_INTERPRETER_GIL_SUPPORTED. |
XIDATA_REGISTERCLASS in PyInit |
Consumer callback sees no type registered in its registry. | Move to the exec slot. |
| Forgetting to roll back owner on alloc failure | Impl stranded with no wrapper; future handoffs fail their CAS. | Add atomic_store(&impl->owner, BOCPY_NO_OWNER); before returning NULL from the consumer callback. |
pytest.importorskip("_your_ext") instead of top-level import |
Workers skip the exec slot; consumer dereferences NULL LOCAL_STATE. |
Use a plain top-level import _your_ext in any file that schedules @when bodies. |
send("tag", c.value) of a proto-Region object |
Worker cannot release the cown afterwards; runtime stalls. | Send a copy or summary data. |
Reading the payload outside a @when |
RuntimeError: the current interpreter does not own this type. |
Read inside a @when that holds the cown, or use an identity-only accessor (.address). |
| Identity-only getter calling the ownership check | .address raises RuntimeError from interpreters that hold a stale wrapper. |
Skip the ownership check on identity/lifetime accessors. |
Static-type pattern (PyTypeObject MyType = {...}) |
Type cannot be per-interpreter; LOCAL_STATE cache pattern won't fit. |
Use PyType_FromModuleAndSpec and store the type on per-module state. |
Including <stdio.h> before <bocpy/bocpy.h> |
Compile error on some toolchains; Python.h ordering rule. |
Move <bocpy/bocpy.h> to the very top of the translation unit. |
Hand-written (obj, xidata) producer callback |
Compile error on CPython <3.12 (legacy (tstate, obj, xidata)) or vice versa. |
Declare the callback with XIDATA_GETDATA_FUNC(name). |
Building without --no-build-isolation |
Wheel resolves against PyPI bocpy headers instead of your local install. | Always pip install --no-build-isolation .. |
| Refcount and ownership conflated | __dealloc__ raises RuntimeError from interpreters that hold a stale wrapper. |
Keep refcount independent of ownership โ drop refs on dealloc regardless of who owns the impl. |
Verification checklist
Before declaring a bocpy-aware C extension done, verify all of:
-
setup.pyusesbocpy.get_include()andbocpy.get_sources(). -
pyproject.tomllistsbocpyin both[build-system].requiresand[project].dependencies. - Translation unit includes
<bocpy/bocpy.h>at the top, with an#errorguard againstPy_BUILD_COREleakage. - Module uses multi-phase init and declares
Py_MOD_PER_INTERPRETER_GIL_SUPPORTED. - Type is heap-allocated via
PyType_FromModuleAndSpecand stored on per-module state. -
XIDATA_REGISTERCLASSis called from thePy_mod_execslot. - Producer callback declared with
XIDATA_GETDATA_FUNC. - Producer callback CASes owner from
bocpy_interpid()toBOCPY_NO_OWNERbeforeXIDATA_INIT. - Consumer callback CASes owner from
BOCPY_NO_OWNERtobocpy_interpid()before allocating the wrapper. - Consumer callback rolls owner back to
BOCPY_NO_OWNERif wrapper allocation fails. - Data-reading accessors call an
_check_acquired-style helper and raiseRuntimeErroron mismatch. - Identity/lifetime accessors do not call the check.
- A pytest test imports the extension at module scope (not via
importorskip) and exercises a@whenchain that round-trips a cown carrying one of your types through at least one worker. Use thetesting-with-bocsend/receiveassertion pattern. - Tests pass when installed with
pip install --no-build-isolation.
See templates/c_abi_consumer/test/test_consumer.py in the bocpy
source tree for the canonical test pattern โ a tail-recursive @when
chain that ships a counter cown between workers, with assertions
fired back via send / receive.