Problem
bocpy today gives you @when(*cowns) and a single process-global rundown: wait() blocks until everything in the process has quiesced. That coarse rendezvous is fine for scripts and demos, but it has two sharp limits:
- You can't wait for just your work. There's no way to say "block until this chunk of behaviors finishes" without also waiting for every unrelated behavior the same process happens to be running.
- You can't wrap bocpy code in a synchronous API. Exposing behaviors and cowns to bocpy-aware consumers works beautifully. But if you want to hand a plain, synchronous function to a non-bocpy caller -- one that internally schedules behaviors and returns a finished result -- your only rendezvous is
wait() / quiesce(), both of which drain the entire process, not just your call's work. So you can't cleanly build a blocking facade over bocpy internals.
We want structured concurrency: a first-class way to scope a unit of work, wait for exactly that work (including anything it transitively spawns), and get a result back -- without disturbing the rest of the process.
Proposed surface
A behaviors() scope
from bocpy import Cown, when, behaviors
c = Cown(0)
with behaviors() as scope:
@when(c)
def increment(c):
c.value += 1
@when(c) # transitively belongs to `scope`
def announce(c):
print(f"c is {c.value}")
# The `with` block exits only once both behaviors have finished.
# Behaviors running elsewhere in the process keep going undisturbed.
Work scheduled from inside the scope -- even a deep chain of @when callbacks hopping across worker sub-interpreters -- belongs to the same scope, so the block correctly waits for all transitive work.
An explicit Terminator
The scope handle is a first-class object you can also build and pass by hand:
from bocpy import Terminator, when
t = Terminator()
@when(c, terminator=t)
def isolated(c):
...
assert t.wait(timeout=2.0)
A result value out of the scope
Pass the scope as one of a behavior's cowns and assign scope.value:
with behaviors() as scope:
@when(a, b, scope)
def summarise(a, b, scope):
scope.value = a.value + b.value
print(scope.value) # None if nothing wrote it
scope is acquired like any other cown, so the write is race-free and committed by the time the block exits. Single slot, last-writer-wins; the same discipline makes reductions (scope.value = (scope.value or 0) + k) safe.
Optional per-scope snapshots
with behaviors(stats=True, noticeboard=True) as scope:
do_pipeline()
scope.stats # scheduler-stats delta for just this scope's window
scope.noticeboard # noticeboard snapshot taken at quiescence
What stays the same
@when, Cown, whencall, send/receive, and the noticeboard keep their exact signatures and semantics.
- Omitting
terminator= / not opening a scope -> today's process-global behavior.
- The public C ABI is untouched; downstream C extensions need no changes.
Guard rails (behavior changes to be aware of)
- Calling
wait() / quiesce() inside a with behaviors(): block raises instead of silently deadlocking.
- A user
Terminator that is still alive blocks the next runtime start().
- A behavior body declaring a parameter literally named
terminator raises at decoration time (reserved name).
Feedback welcome
- Naming:
behaviors() vs. something like scope() / nursery()? scope.value vs. scope.result?
- Should
stats / noticeboard snapshots ship in the first cut, or land as a follow-up?
- Anything in the guard rails above that would surprise you in real code?
The design is written up in full in PLAN.md on the branch -- this issue is the short version to drive discussion while the work is in flight.
Problem
bocpy today gives you
@when(*cowns)and a single process-global rundown:wait()blocks until everything in the process has quiesced. That coarse rendezvous is fine for scripts and demos, but it has two sharp limits:wait()/quiesce(), both of which drain the entire process, not just your call's work. So you can't cleanly build a blocking facade over bocpy internals.We want structured concurrency: a first-class way to scope a unit of work, wait for exactly that work (including anything it transitively spawns), and get a result back -- without disturbing the rest of the process.
Proposed surface
A
behaviors()scopeWork scheduled from inside the scope -- even a deep chain of
@whencallbacks hopping across worker sub-interpreters -- belongs to the same scope, so the block correctly waits for all transitive work.An explicit
TerminatorThe scope handle is a first-class object you can also build and pass by hand:
A result value out of the scope
Pass the scope as one of a behavior's cowns and assign
scope.value:scopeis acquired like any other cown, so the write is race-free and committed by the time the block exits. Single slot, last-writer-wins; the same discipline makes reductions (scope.value = (scope.value or 0) + k) safe.Optional per-scope snapshots
What stays the same
@when,Cown,whencall,send/receive, and the noticeboard keep their exact signatures and semantics.terminator=/ not opening a scope -> today's process-global behavior.Guard rails (behavior changes to be aware of)
wait()/quiesce()inside awith behaviors():block raises instead of silently deadlocking.Terminatorthat is still alive blocks the next runtimestart().terminatorraises at decoration time (reserved name).Feedback welcome
behaviors()vs. something likescope()/nursery()?scope.valuevs.scope.result?stats/noticeboardsnapshots ship in the first cut, or land as a follow-up?The design is written up in full in
PLAN.mdon the branch -- this issue is the short version to drive discussion while the work is in flight.