Eventable (mixin)
Per-object publish/subscribe events. Mix Eventable into any class and its
instances gain their own event channels: subscribe callbacks to named events,
then emit those events to fire every subscriber.
It’s a thin layer over the core _Stack
primitive — each event is a stack of handlers scoped to the object — so it
inherits _Stack’s two best properties for free: arbitrary event names, and
automatic cleanup when the object is destroyed.
Contents
- Dependencies
- Mixing In
- Quick Start
- Methods
- The Callback Contract
- Dispatch Order (LIFO)
- Error Handling
- Per-Object Isolation
- Lifecycle: Free Cleanup on Destroy
- Class-Level Events
- Common Patterns
- Design Notes
- Eventable vs Signal
Dependencies
. boop Eventable
Eventable uses the core _Stack primitive, which is always available once
boop is loaded — no other classes required.
Mixing In
boopClass Button isa:SomeBase mixin:Eventable has:label public:new,press
Any instance of Button now has on, emit, off, count, and clear.
A class may mix in Eventable alongside other mixins; class methods win over
mixin methods, and earlier-listed mixins win over later ones (call
$obj.Eventable::on to force Eventable’s version if a name collides).
Quick Start
. boop Eventable
boopClass Button mixin:Eventable public:new,press
into=b Button
# Subscribe a couple of listeners
on_click() { printf 'clicked at %s,%s\n' "$2" "$3"; } # $1=event, $2..=args
log_click() { printf 'audit: click\n'; }
$b.on click on_click
$b.on click log_click
# Fire the event, forwarding coordinates
$b.emit click 12 34
# audit: click <- log_click (subscribed last, fires first)
# clicked at 12,34 <- on_click
Methods
on EVENT CALLBACK
Subscribe CALLBACK (a function name) to EVENT on this object. The newest
subscriber fires first (see dispatch order).
$obj.on ready initialize_widget
$obj.on ready log_ready
Both EVENT and CALLBACK are required; a missing one is an _Error and
returns non-zero. EVENT can be any string — dots, spaces, anything — because
it’s a _Stack name, not a bash identifier.
emit EVENT [args...]
Fire every handler registered for EVENT on this object, in LIFO order,
forwarding the event name and any extra arguments to each handler.
$obj.emit ready
$obj.emit data "$payload" "$source"
Emitting an event with no subscribers is a harmless no-op. emit does not
return a meaningful value — it’s a broadcast, not a query.
off EVENT CALLBACK
Remove the first registration of CALLBACK from EVENT. If the same callback
was subscribed twice, the second registration remains.
$obj.off ready log_ready
$obj.off ready never_subscribed # no-op, no error
count EVENT → into=
Return the number of handlers currently registered for EVENT.
into=n $obj.count ready # n="2"
into=n $obj.count unknown # n="0"
clear EVENT
Remove all handlers for EVENT.
$obj.clear ready
into=n $obj.count ready # n="0"
The Callback Contract
A handler is invoked as:
callback <event> [emit-args...]
$1— the event name that fired (clean, not an internal channel id).$2…— whateveremitforwarded after the event name.
on_data() {
local event="$1" # "data"
local payload="$2"
local source="${3:-}"
process "$payload"
}
$obj.on data on_data
$obj.emit data "$blob" upstream
Handlers must be defined functions. Their return value is ignored by the emitter (this is a notification, not a pipeline) — but see Error Handling for what a non-zero return triggers.
Dispatch Order (LIFO)
Handlers fire in reverse subscription order: last subscribed, first called. This matches the usual “most recently attached handler gets first crack” expectation and mirrors Signal’s ordering.
$obj.on save write_disk # fires last
$obj.on save validate # fires first
$obj.emit save # validate, then write_disk
If you need first-subscribed-first ordering, subscribe in the reverse order, or model it explicitly — the LIFO guarantee is stable and part of the contract.
Error Handling
A handler that returns non-zero does not halt dispatch — the remaining handlers still fire. This is deliberate: subscribers are independent parties, and one buggy listener shouldn’t silently deprive the others of a notification they registered for.
The failure is not swallowed silently, though. Eventable logs a _Warn
naming the offending listener:
[WARN] __Eventable.invoke: Eventable: listener 'bad_handler' returned nonzero (save)
- The warning is visible at the default log level and silenceable via
_LogLevel errorif you find it noisy. - Because
_Warnreturns 0, dispatch staysset -e-safe. - Under the opt-in
_FatalLevel warn(or_FatalLevel warn <HostClass>), that warning escalates to a crash — the intended strict-mode behaviour for CI and the like. In normal operation (_FatalLevel crash, the default) a failing listener never aborts the run.
A handler’s own stderr is never suppressed, so it can complain in its own voice too.
Per-Object Isolation
Each object’s event channels are entirely its own. Subscribing to click on
one button has no effect on another.
into=a Button
into=b Button
$a.on click handle_a
$b.on click handle_b
$a.emit click # only handle_a runs
$b.emit click # only handle_b runs
This falls out of _Stack’s identity scoping: the handler stacks are keyed by
the object’s ID.
Lifecycle: Free Cleanup on Destroy
Because subscriptions live in __boop_static under the object’s <id>.
prefix, $obj.destroy sweeps them away with everything else the object owns.
There is no _destroy hook to write and no leak to worry about:
into=w Button
$w.on click handler
$w.destroy # the click subscription is gone with it
This is the payoff of building on _Stack rather than a bespoke registry:
lifecycle is handled by the core, uniformly, for free.
Class-Level Events
Called on the class rather than an instance, the identity falls back to the class name, giving you shared/broadcast channels:
Button.on globalReset reset_all_buttons
Button.emit globalReset
Use this sparingly — class-level subscriptions persist for the life of the process (classes aren’t destroyed) and are shared by all callers.
Common Patterns
Widget lifecycle hooks
boopClass Widget mixin:Eventable public:new,mount,unmount
Widget.mount() { local _Self="${_Self:-}"; $_Self.emit mounted; }
Widget.unmount() { local _Self="${_Self:-}"; $_Self.emit unmounted; }
into=w Widget
$w.on mounted start_animation
$w.on unmounted stop_animation
One-shot listener
__once() {
_Self="$obj" $obj.off ready __once # unsubscribe self
printf 'ready, once\n'
}
$obj.on ready __once
Fan-out to independent subscribers
$order.on placed notify_customer
$order.on placed reserve_inventory
$order.on placed emit_metrics
$order.emit placed "$order_id" # all three fire, independently
Design Notes
Built on _Stack. Each event is a _Stack named evt.<event>, scoped to
the object. Eventable adds no storage of its own — on is _Stack push, off
is _Stack remove, emit is _Stack each through a small trampoline, count
is _Stack size, clear is _Stack clear.
Clean event names, arbitrary strings. Because event names are _Stack
keys (associative-array keys), not bash variable names, any string works —
including dotted (user.login) or spaced names. The earlier hand-rolled
Emitter sketch composed the object ID and event into a bash variable name
and broke on dots; _Stack sidesteps that entirely.
The listener runs with the caller’s IFS. The internal dispatch trampoline
is careful not to set a local IFS before invoking a handler — that would leak
into the handler via bash’s dynamic scoping and silently change its
word-splitting. IFS is pinned only around Eventable’s own warning message.
Eventable vs Signal
Both layer LIFO handler stacks over _Stack, but they serve different needs:
| Eventable | Signal | |
|---|---|---|
| Scope | Per-object (or per-class) channels | Process-global, per OS signal |
| Trigger | Manual $obj.emit EVENT |
OS signals via trap, or Signal.dispatch |
| Event names | Any string you choose | Real signal names (INT, TERM, EXIT, …) |
| Failing handler | Logs a _Warn, continues |
Silently continues (|| true) — trap-safe |
| Cleanup | Automatic on $obj.destroy |
Signal.clear |
Reach for Eventable when objects need to announce things to interested observers; reach for Signal when you’re handling actual OS signals or need one shared, process-wide handler stack.