Event buses and persistent registration

CE::Events::EventSystem provides the global default access layer. Its owned CE::Events::EventBus contains registrations. A subsystem may instead own a bus to isolate its channels and lifecycle; a bus is a registry, not a thread or worker pool.

CE::Events::EventBus local_bus;
auto id = local_bus.register_listener("level-loaded", callback);
local_bus.dispatch("level-loaded", payload);
// CE::Events::EventSystem::get() exposes the same API through its default bus.

Registrations

Registration is persistent. The bus strongly owns its callback until explicit unregister or bus destruction; throwing away id does not remove it. Identifiers include bus identity, so an ID from a different bus cannot remove a local listener.

Immediate delivery

Immediate delivery runs on dispatch’s caller, in registration order within that dispatch. Overlapping producers can overlap callbacks; immediate listeners must be safe on each producer thread. Nested dispatch is synchronous and completes inside the outer callback. Exceptions propagate to the immediate caller.

The registry lock is released before callbacks run. Registration during dispatch joins the next snapshot, not the already detached one. String channel names and std::any payload typing retain their current application-level contract.

Removal and shutdown

unregister_listener(id) invalidates invocation entry before it returns. A listener already running may finish. For a callback borrowing an object (for example [this]), call unregister_and_wait(id) before destroying that object, or unregister then call wait_for_listener(id). The barrier waits only for invocations already entered, not for queued tasks which can safely discard invalid entries. Do not wait while holding a lock needed by the callback. Self-unregister works; waiting from the same listener, including nested invocation, rejects instead of deadlocking. Callback exceptions still release in-flight accounting.

close() invalidates all registrations and rejects subsequent registration and dispatch. Concurrent close/removal calls serialize invalidation with registry removal: every returning closer has closed all remaining invocation gates. Callback captures are still released outside both locks, so their destructors may reenter the bus. Close does not wait for running callbacks. Bus destruction closes without blocking; callers must settle any in-flight callbacks that borrow a bus or other target before destroying it. Registry snapshots and invocation guards retain callback ownership as needed, without permitting a fresh invocation after invalidation. Queued delivery is optional and described below.

Typed channels

CE::Events::EventChannel<Payload> owns an exact channel name. Its name plus its unqualified, copy-constructible payload type identify a channel within each bus. Equivalent name/type values share registrations, independent of token lifetime; another payload type with the same name is a separate channel. Legacy string/std::any channels retain their application-defined type contract and are separate from typed channels. Type identity is in-process; names and RTTI are not a serialized protocol.

struct LevelLoaded {
    int level;
};
const CE::Events::EventChannel<LevelLoaded> loaded{"level-loaded"};
int last_level = 0;
auto id = local_bus.register_listener(loaded, [&](const LevelLoaded& event) {
    last_level = event.level;
});
local_bus.dispatch(loaded, LevelLoaded{3});
local_bus.unregister_and_wait(id);

Typed submission requires the exact payload type rather than implicit numeric or other conversions. It copies an lvalue or constructs from an rvalue into an owned payload before dispatch; even std::any can be a typed payload without flattening its contents. Each listener receives the existing invocation-owned payload copy through a borrowed const Payload&. Copy it for retained use, and keep pointees alive according to Payload’s own ownership. A checked erased-type lookup precedes the typed callback; inconsistent internal payloads throw bad_any_cast instead of reaching that callback. Public named producers cannot target a typed channel.

Persistent registration, snapshot order, invalidation, waits, close and optional delivery use the same listener machinery as named channels. CE::Events::EventSystem forwards both APIs to its default bus. Initial typed-payload construction failures reach the producer before the registry dispatch begins. Per-listener queued copying, callback/target failures and cancellation still belong to the registered error sink; invalidation waits protect the callback’s borrowed target, not that error sink’s independent lifetime through pending-ticket destruction. No typed API chooses a thread or changes worker-stream ordering.

CE::Platform::window_resized_event in core/display/window-interface.h is the canonical typed channel for CE::Platform::WindowResized. Native GLFW explicitly publishes both it and the legacy named "window-resized" notification; this producer bridge does not make generic named and typed channels equivalent. Register with the typed channel to receive a checked const CE::Platform::WindowResized&, optionally selecting platform or simulation delivery as below. Its dimensions are copied, while its window pointer remains borrowed and cannot be dereferenced on simulation. The display/window contract defines observation, ordering and native failure behavior.

Optional queued delivery

Pass a delivery callable and error sink to register_listener. The bus accepts only the small bool(Work) delivery contract, with move-owned Work; its header has no dependency on dispatchers, runtime, or worker pools. A target owns deferred work when it returns true. False or an exception rejects it without retaining it. It must not invoke work inline or wait for it, and must provide FIFO execution for each listener stream. Concurrent producers serialize enqueue for a given listener. There is no global completion order between listeners or independent targets.

Queued tasks copy their payload and retain registration state. At actual execution they enter the same invalidation handshake as immediate callbacks. Callback exceptions, payload/task preparation failures, rejected posts, and accepted tasks later dropped by the target reach the required error sink. Target cancellation reports future_error/broken_promise; explicit unregister/close discards pending callbacks intentionally. Error sinks receive the original preparation/target exception; such queued failures do not also escape from dispatch. Immediate failures still propagate to the caller. Sinks must not throw and must remain valid through pending-task destruction; a throwing sink terminates rather than disappearing in a discarded target future.

The local delivery ticket owns the copied payload as well as rejection reporting. If a target destroys rejected work during its offer, final payload destruction waits until the listener’s posting lock has been released. Payload destructors, including final shared-owner deleters, may therefore dispatch again without reentering that lock. Payload-copy failure still reports the original exception.

The completion barrier protects the listener callback’s borrowed target. An error sink is independent: prefer an owned logging/reporting handle, not the same raw this. Unregister can race a delivery already being offered; that task can still be accepted but will not enter the invalidated listener.

CE::Events::platform_delivery(engine.platform_dispatcher().submission()) and CE::Events::simulation_delivery(runtime.simulation_dispatcher().submission()) are optional composition adapters in core/engine/event-delivery.h. Their namespace is CE::Events; this separate composition header keeps the bus independent of CE::Runtime and CE::Workers. Their saved endpoints reject safely after service closure. CE::Events::worker_delivery(group) provides one serial drain stream on the group’s shared parallel capacity. Copies of that delivery callable share FIFO execution and completion order, including across listeners. Separate adapter construction creates independent streams. Ordinary jobs in the group remain parallel; a pool’s FIFO selection alone does not impose completion order.

Accepted stream work drains before group completion. Group closure rejects new stream work even when a pump is active. A failed/cancelled pump releases its pending captures outside stream locks, letting owned event tickets report loss. Do not block a stream callback on another callback in that same stream.

Worker-stream acceptance and cancellation

Initial pump submission is serialized with stream publication. Another producer cannot return accepted before that submission succeeds. Closure checks share the stream mutex with the pump’s final empty-queue check. Submission failure removes only the initiating request, and its captures leave the stream lock before error reporting. Lock order is stream then pool; worker accounting releases the pool lock before touching a stream or releasing callback captures.

Cancellation belongs to the submitted callable. An atomic preparing/accepted/ cancelled handshake records loss before callback entry without taking the producer’s already-held stream lock. Before publication, the producer withdraws only its own request. After publication, unentered callable destruction abandons the stream outside producer posting locks. This permits error-sink redispatch and capture- destructor reentry without running another listener’s cancellation under that lock.

Validation procedures includes controlled pump rejection, concurrent producers, payload-copy invalidation, native-policy suppression, and reentrant recovery. Typed identity/ownership, compile-time constraints, waits, queued failure/error-sink lifetime and worker FIFO have additional regression coverage. Linux/X11 typed-event and resize acceptance is complete. Remaining Windows acceptance is shelved in the platform plan.