Pinned Cowns

A PinnedCown is a Cown whose value never leaves the main interpreter. Behaviors whose request set contains any pinned cown run on the main thread, drained by pump() (called from your event loop) or implicitly by wait(). Use a pinned cown when the underlying value cannot survive an XIData round-trip: pyglet shapes, Tk widgets, open file handles, ctypes pointers, GPU contexts, asyncio loops.

When to Use a Pinned Cown

Reach for PinnedCown when the value:

  • has a __reduce__ that raises or silently reconstructs a broken object on the other side of the worker boundary,

  • is a handle into a library loaded only by __main__ (pyglet GL state, a Tk root, an open SQLite connection, a CUDA context),

  • must observe identity across acquires (id(value) stable between behaviors).

A regular Cown stores its value as cross-interpreter data and the same Python object is never observed twice in a worker. A PinnedCown holds its value as a plain PyObject reference in the main interpreter; every acquire sees the same object.

Pump Contract

pump() drains the main-thread queue of pinned-aware behaviors. Each behavior runs to completion before the next starts. The pump is non-preemptive: deadline_ms gates starting the next behavior, not interrupting one already running.

from bocpy import pump

result = pump()                       # drain to empty
result = pump(deadline_ms=4)          # wall-clock budget
result = pump(max_behaviors=8)        # hard count
result = pump(raise_on_error=True)    # re-raise first body exception

The result is a PumpResult NamedTuple:

  • executed — pinned behaviors whose lifecycle (acquire attempt → optional body → release) ran to completion.

  • deadline_reachedTrue iff the loop exited because deadline_ms tripped before the queue drained.

  • raised — pinned behaviors whose body raised an Exception captured to the result cown’s .exception.

Script-mode programs need not call pump() explicitly — wait() pumps internally when any PinnedCown exists in the process.

The Coarse-Grained Dispatch Pattern

The pinned arm is single-consumer: only the main thread drains the pump queue. If you schedule a pinned behavior per item, those behaviors serialise on the main thread and you lose worker parallelism.

Schedule pinned behaviors coarsely — one per logical frame or batch, not per item:

  1. Wrap per-item state in regular Cowns.

  2. Schedule worker @whens that compute per-item physics and return a result Cown.

  3. Schedule one pinned @when per frame that captures all the result cowns together with the main-thread handle, and performs the batched write-back.

The boids example follows this pattern: worker behaviors compute per-cell flock physics in parallel; one pinned behavior per frame writes the results back into the global pyglet-visible position and velocity matrices. Dispatch through the pump queue is ~1 per frame, not N.

Integrating with an Event Loop

Pyglet

Call pump() at the top of each scheduled tick so the prior frame’s write-back drains before new work is scheduled:

import pyglet
from bocpy import PinnedCown, pump, start, when

start()
canvas = PinnedCown(MyCanvas())   # holds a pyglet handle

def update(dt):
    pump()                         # drain prior frame's write-back
    # ... schedule worker behaviors that return result cowns ...
    results = [worker_compute(i) for i in range(num_items)]

    @when(*results, canvas)
    def _writeback(*args):
        *cells, canvas = args
        for cell in cells:
            canvas.value.draw(cell.value)

pyglet.clock.schedule_interval(update, 1 / 60)
pyglet.app.run()

Tk / asyncio

Drive pump() from a periodic callback (root.after(ms, ...) for Tk, loop.call_later(...) or an asyncio-friendly periodic task for asyncio). The same coarse-grained pattern applies: keep dispatch rate at one pinned behavior per logical batch.

Starvation and the Watchdog

The watchdog is disabled until you call set_pump_watchdog(). No call means no warnings — the runtime stays silent regardless of how long the pinned queue has been non-empty.

Once enabled, if pinned work piles up because the host event loop is wedged or not calling pump() often enough, the watchdog logs a warning carrying the queue’s age and depth. The threshold gates on queue-non-empty time: a program that runs only unpinned work indefinitely never trips it.

from bocpy import set_pump_watchdog

set_pump_watchdog(warn_ms=1000)   # enable warn-at-1s (matches the kwarg default)
set_pump_watchdog(warn_ms=None)   # disable
  • No call ⇒ no watchdog. The runtime ships with the warn threshold unset; you opt in by calling set_pump_watchdog() at least once.

  • warn_ms (default 1000 when the kwarg is omitted) logs a warning carrying the queue’s non-empty duration (ms) and current depth. Pass None to turn the warning off.

  • on_starve lets the host replace the default logging sink. Use it to escalate (on_starve=lambda s, m: pytest.fail(m) in tests, a counter / alert hook in production).

The watchdog deliberately never raises on its own: the pinned queue is bounded by the live PinnedCown count by construction, so there is no back-pressure threat the library can defend against without lying about it. Fail-fast policy belongs in the host’s on_starve callback, where the calling code can record the right context and pick the right exception class.

Hosts that need to tune wait()’s internal pump cadence call set_wait_pump_poll(). The default cadence is 50 ms, which is the upper bound on how long the auto-pump loop will park between checks when no broadcast wakes it.

Main-thread Direct Reads

The pinned-cown contract refuses worker-side reads of the underlying value (the owner CAS rejects them). The symmetric question — “may the main thread read the value directly, outside a pinned @when body?” — has a narrower answer:

  • Reading the underlying object from the main thread is safe iff no pinned @when is currently executing against that cown. Pinned bodies run synchronously inside pump(); once pump() (or wait()’s auto-pump) returns, no body holds the cown, and an immediate main-thread read sees a consistent value.

  • Reading from the main thread while pump() is dispatching a body that targets the same cown is undefined. Do not alternate between “I’m pumping” and “I’m reading directly” inside the same callback.

  • The safe pattern is: stash any main-thread alias for read-only rendering / event-loop integration, but treat the pinned @when body as the only writer.

In the boids example, the Simulation object aliases the same Matrix under self.positions (for pyglet rendering) and self.positions_cown = PinnedCown(positions) (for the per-frame write-back). The render path runs on the main thread between pump() calls; the write-back runs inside pump(). They never overlap.

Thread Affinity and Free-Threaded Builds

  • PinnedCown may only be constructed from the main interpreter; a worker that calls PinnedCown(x) raises RuntimeError.

  • pump() must run on the main interpreter. On classic CPython, any thread within the main interpreter may pump (the per-interpreter GIL serialises).

  • On free-threaded builds (Py_GIL_DISABLED) only one thread at a time may pump, enforced by a CAS on pump entry that raises RuntimeError if a second thread tries to enter concurrently. The CAS is cleared on every exit path, including BaseException propagation from a pinned body.

  • pump() is not reentrant. Calling pump() from inside a pinned-behavior body raises RuntimeError.

Handle vs. Value

A PinnedCown handle (the Python wrapper and its C capsule) is a normal cross-interpreter shareable. It travels via the same XIData mechanism as a regular Cown and may be:

  • shipped as a captured variable to a worker behavior,

  • embedded in any value graph stored in a regular Cown (Cown(PinnedCown(x)) is supported),

  • placed in a noticeboard entry via notice_write() or notice_update().

What never crosses interpreter boundaries is the value. A worker that ends up holding a pinned-cown handle can do exactly one useful thing with it: schedule pinned @whens against it, which the runtime auto-routes to the main pump queue. Any attempt to acquire the value from a worker is rejected by the C-level owner CAS.

Mixed Request Sets

A behavior may freely combine pinned and unpinned cowns; the 2PL acquisition order is unchanged. As soon as the request set contains any pinned cown, the body runs on the main thread. Unpinned cowns in the set still travel through XIData into the main interpreter for the body’s duration.

Exception Model

Body exceptions follow the same rules as worker behaviors: captured on the result Cown and surfaced through cown.exception. The default pump() does not re-raise; pass raise_on_error=True to opt into fail-fast propagation. BaseException (KeyboardInterrupt, SystemExit, GeneratorExit) propagates from pump() immediately after the offending behavior’s per-iteration cleanup completes; any behaviors still queued remain in the pinned queue and resume on the next pump() (or wait()-driven auto-pump) call.

Free-Threaded Trajectory

On free-threaded CPython (the 3.13t and 3.15t builds), PinnedCown works identically to classic CPython — the sub-interpreter boundary still exists for FT workers, and free-threaded support is “experimental” across all of bocpy. PinnedCown inherits that label. The single-pumper CAS prevents silent data races from concurrent pumpers, raising RuntimeError instead.

Long term, bocpy will fork into a classic-CPython build (using sub-interpreters — where PinnedCown is meaningful) and a free-threaded build (running workers as plain main-interpreter threads — where every cown is effectively pinned and PinnedCown becomes a no-op). In that future, the single-pumper CAS is removed. Out of scope for v1.

API Reference

See API for the autodoc-generated reference for PinnedCown, pump(), PumpResult, set_pump_watchdog(), and set_wait_pump_poll().