Events GitHub issue

vibecode
{"vibecode": {
    "doc": "events",
    "role": "spec for Caspian's event-broadcasting system — any object can broadcast events; any object (or any anonymous closure) can register a handler for someone else's events. The source broadcasts via %self.object.broadcast. Handlers register via three forms: a method on a listener, a closure on a listener, or a closure directly on the broadcaster. All registrations live in a single engine-level event table; GC cleans up rows when participants go out of scope.",
    "status": "spec — major decisions settled; some open points remain (unregister API, custom exception class names, class-level opt-in)",
    "audience": "Caspian programmers using events; engine implementers wiring the broadcast path",
    "key_concepts": ["explicit_source_broadcast_via_percent_self_object_broadcast",
        "three_registration_forms_method_name_closure_on_listener_closure_on_broadcaster",
        "single_engine_level_event_table",
        "weak_references_to_participants_strong_references_to_anonymous_closures",
        "row_lifetime_bounded_by_participants_alive",
        "handler_signature_includes_source_event_name_and_payload_args",
        "registration_order_for_handler_invocation",
        "idempotent_registration_for_method_name_form",
        "exceptions_bubble_normally_remaining_handlers_dont_fire",
        "synchronous_nested_broadcast_no_special_cases",
        "gc_cleans_up_rows_for_either_end_going_out_of_scope",
        "introspection_supported"]
}}
Open issues (1)

File: documentation/requirements/caspian/events/index.md § Example: chat server (#example-chat-server)

§ Example: chat server
Take out this whole section.

Any Caspian object can listen for events broadcast by any other object. The source explicitly broadcasts via %self.object.broadcast; handlers register through one of three forms — a method on a listener, a closure tied to a listener, or a closure tied directly to the broadcaster. The engine routes broadcast → handler calls.

Two things this design avoids by construction:


Broadcast GitHub issue

A source broadcasts by calling %self.object.broadcast from inside one of its own methods:

%self.object.broadcast 'event_name', arg1, arg2, ...

Example — a socket broadcasting a new inbound connection:

%self.object.broadcast 'new_connection', {'received': 'something'}

If no one is listening for 'new_connection' on this source, the call does nothing and returns 0.


Three ways to register a handler GitHub issue

A handler can be registered in three forms. All three put a row in the event table and dispatch on broadcast the same way; they differ in what holds the handler and whose lifetime governs the registration.

Method-name form (on a listener) GitHub issue

A listener registers by naming one of its own methods:

$listener.object.listen_to $source, 'event_name', 'method_name'

No validation at registration time. The source isn't checked to ever broadcast 'event_name'; the listener's class isn't checked to have 'method_name'. Registration is purely bookkeeping.

Example — $logger listens for 'new_connection' on $socket, dispatching to its write_to_log method:

$logger.object.listen_to $socket, 'new_connection', 'write_to_log'

Closure form (on a listener) GitHub issue

A listener can pass a closure directly to listen_to instead of a method-name string:

$listener.object.listen_to($source, 'event_name') do(...closure_params...)
    # handler body
end

The closure captures its lexical scope at the registration site. Use this when the handler doesn't justify a named method on the listener's class (an inline reaction, a quick observer, etc.).

Example — register a closure on $logger that logs new connections on $socket:

$logger.object.listen_to($socket, 'new_connection') do($event_name, $payload)
    puts 'new connection: ' + $payload.received
end

Closure form (on the broadcaster) GitHub issue

A closure can be registered directly on the broadcaster, with no listener identity:

$source.object.on_broadcast('event_name') do(...closure_params...)
    # handler body
end

The closure is anchored to the broadcaster; when the broadcaster is collected the registration goes with it. Each on_broadcast call adds a new closure to the broadcaster's set for that event — never overrides a previous registration.

Example — wire up a quick logger directly on the server:

$server.object.on_broadcast('got_request') do($event_name, $request)
    %stdout.puts 'request: ' + $request.summary
end

This form is useful when the developer holds a reference to the broadcaster, wants to attach a reaction, and has no separate listener object to bind it to. In spirit it's the same as a listener registering — just without the ceremony of inventing a listener.

When to use each GitHub issue

All three forms register the same way underneath (see the event table) and fire in registration order.


Handler signature GitHub issue

When the broadcast fires, the system invokes the handler with this signature:

In all three cases, the trailing $args... are exactly what the broadcaster passed to %self.object.broadcast after the event name.

Example — same signature shapes for the two registration styles:

class
    method &write_to_log($server, $event_name, $request)
        # method-name form: source is the first parameter
    end
end
$logger.object.listen_to($server, 'got_request') do($event_name, $request)
    # closure-on-listener: source is captured lexically as $server
end
$server.object.on_broadcast('got_request') do($event_name, $request)
    # closure-on-broadcaster: source is reachable via $server too
end

Cross-role broadcasts work automatically: the broadcaster broadcasts in its own role, the handler runs in its own role (the method's class role or the closure's defining role), only the payload args cross the role boundary.


The event table GitHub issue

All registrations live in a single engine-level data structure called the event table. There is one event table per Caspian process; every register/unregister/broadcast goes through it.

Each row carries:

The table holds weak references to participants and strong references to anonymous closures. Specifically:

Broadcast lookup. On %self.object.broadcast 'event_name', args..., the engine queries the event table for rows where source == %self AND event_name == 'event_name', then invokes each row's handler in registration order. The hash-indexed lookup returns empty in O(1) for sources with no listeners, so the "zero listeners" fast path falls out of the data structure without needing a separate counter.

Row removal. A row is removed when either of its participants is collected (a source going out of scope removes all its rows; a listener going out of scope removes all rows where it's the listener; for the on_broadcast form there's no listener, so only the source's lifetime applies). The engine implements this through whatever GC mechanism is appropriate to the host runtime — weak-reference notifications, finalizers, or sweeps — but the observable contract is just "row alive iff participants alive."

Routine cleanup is silent: a participant going out of scope is the normal way to stop listening, and the engine doesn't warn about it.


Behavior GitHub issue

Registration order GitHub issue

When multiple handlers are registered for the same event on the same source, they fire in the order they were registered. First registered → first fired.

Idempotent registration (method-name form) GitHub issue

Calling listen_to with the same (listener, source, event_name, method_name) tuple a second time is silently ignored. Registration is idempotent for the method-name form — no duplicates, no error, no warning.

Closures don't have comparable identity in the same way, so the idempotency rule doesn't apply to closure forms: each listen_to (closure variant) or on_broadcast call creates a distinct row, even if the closure body is identical. Registering "the same" closure twice produces two rows that both fire on broadcast.

Multiple handlers on the same source / event GitHub issue

Same source + event_name with multiple registrations creates multiple rows. Each fires on broadcast:

$foo.object.listen_to $socket, 'new_connection', 'log'
$foo.object.listen_to $socket, 'new_connection', 'audit'
# Both $foo.log and $foo.audit fire when $socket broadcasts 'new_connection'.

$socket.object.on_broadcast('new_connection') do($event_name, $payload)
    %stdout.puts 'metric: connection received'
end
# This closure also fires, in registration order with the others.

Self-listening GitHub issue

An object can listen to itself. No special case — the broadcaster and listener happen to be the same object:

$foo.object.listen_to $foo, 'my_event', 'my_handler'

The same applies to on_broadcast — registering a closure on the broadcaster for one of its own events is just the normal use case.

Payload mutation GitHub issue

Handlers see live references to the payload args, not copies. A handler that mutates a payload object visibly affects subsequent handlers and the broadcaster's view of that object after broadcast returns. This is normal Caspian object-reference behavior; the event system doesn't copy or freeze payloads.

Exceptions bubble normally GitHub issue

If a handler raises an exception, it bubbles up through %self.object.broadcast the same way any exception bubbles through a method call. Remaining handlers don't fire — they're never reached because the exception unwinds the stack. The dispatching code can catch the exception with normal catch.

Synchronous nested broadcasts GitHub issue

If a running handler calls %self.object.broadcast (on the same source, on a different source — doesn't matter), the nested broadcast runs to completion before the outer handler continues. Single-threaded straight-through call chain. No reentry suppression, no queueing, no special cases.

The natural consequence: a handler that broadcasts back to its source can cause unbounded recursion. That's user responsibility — the engine doesn't intervene.

Garbage collection GitHub issue

The contract is simple: a row in the event table is alive only while its participants are alive.

No notification is sent to surviving participants — they just stop being involved in broadcasts that no longer exist. All cleanups are silent, considered normal lifecycle behavior. The rows aren't extending anyone's lifetime; the participants live wherever the program's normal references live them, and routine GC handles the rest.

Introspection GitHub issue

The system supports queries for debugging and inspection:

Closure entries don't have a method-name handle, so they probably surface with source-location info (the file:line where they were registered) as their identifier. Exact API surface is TBD.


Custom exceptions GitHub issue

Two custom exception classes carry event-system-specific failures (final class names TBD):

Class (working name) When raised
puck.uno/error/trigger/missing_method A broadcast tries to call a method that doesn't exist on the listener. The registration succeeded earlier; the failure shows up at dispatch time.
puck.uno/error/trigger/source_gone A broadcast attempts to fire on a source whose rows should have been cleaned up but weren't. Shouldn't happen in normal operation; the class exists for engine bad-state recovery.

Both classes inherit from the standard Caspian exception base, can be caught with catch, and carry context about which source, event, listener, and handler was involved.


Open points GitHub issue


Example: simple content broadcaster GitHub issue

A minimal server that opens a TCP listener, broadcasts whatever content arrives, and lets external code register handlers for the broadcast.

The server class GitHub issue

caspian
$server = class
    method &run($port)
        @listener = %net.tcp_listen('0.0.0.0', $port)

        @listener.wait do($content)
            %self.object.broadcast 'got-content', $content
        end
    end
end

Script GitHub issue

caspian
$srv = $server.new()

# Log content to stdout
$srv.object.on_broadcast('got-content') do($event_name, $content)
    %stdout.puts 'got: ' + $content
end

$srv.run 6667

What's happening GitHub issue

  1. $srv.run(6667) opens a TCP listener and enters its wait loop.
  2. Content arrives over the network.
  3. The wait closure fires with the content.
  4. The closure calls %self.object.broadcast('got-content', $content). %self inside the closure is $srv (the closure was defined in $srv.run's body), so the broadcast comes FROM the server.
  5. The driver's registered closure handler fires and prints the content.
  6. Handler returns. Broadcast returns. Wait returns to waiting.

Notes GitHub issue


Example: chat server GitHub issue

Open issues (1)

File: documentation/requirements/caspian/events/index.md § Example: chat server (#example-chat-server)

Take out this whole section.

A small chat room — server, per-client wrappers, and a driver — that demonstrates layered broadcasting (sockets broadcast, clients broadcast, server broadcasts), all three registration forms, and the cleanup pattern when clients disconnect.

The chat server GitHub issue

Listens for incoming connections; wraps each one in a chat_client; broadcasts each received message to all other clients; removes clients on disconnect.

caspian
$chat_server = class
    field :port,         class: :number, integer_only: true, required: true
    field :client_class, class: :class,                       required: true

    method &new(port:, client_class:)
        @port = $port
        @client_class = $client_class
        @clients = []
    end

    method &start()
        @listener = %net.tcp_listen('0.0.0.0', @port)
        # Listen for new connections on our socket
        %self.object.listen_to(@listener, 'new_connection', 'on_new_connection')
    end

    method &on_new_connection($broadcaster, $event_name, $payload)
        # Wrap the raw socket in a chat_client
        $client = @client_class.new(
            socket: $payload.connection,
            nick:   'guest-' + $payload.connection.id.first(8)
        )
        @clients << $client

        # When this client sends a message, broadcast it to the room
        %self.object.listen_to($client, 'message', 'on_client_message')

        # When this client disconnects, clean up
        %self.object.listen_to($client, 'closed', 'on_client_closed')

        # Greet the new client
        $client.write('welcome, ' + $client.nick + "\n")
    end

    method &on_client_message($from_client, $event_name, $payload)
        # Rebroadcast to every other connected client
        $line = $from_client.nick + ': ' + $payload.text + "\n"
        @clients.each do($c)
            if $c != $from_client
                $c.write($line)
            end
        end
    end

    method &on_client_closed($client, $event_name, $payload)
        @clients.remove($client)
    end
end

The chat client GitHub issue

Represents one connected user. Listens on its own socket for incoming data and translates byte-level events into message-level events.

caspian
$chat_client = class
    field :nick, class: :string, required: true

    method &new(socket:, nick:)
        @socket = $socket
        @nick = $nick
        # Listen on our own socket for incoming data
        %self.object.listen_to(@socket, 'data_received', 'on_data')
        %self.object.listen_to(@socket, 'closed',        'on_socket_closed')
    end

    method &on_data($broadcaster, $event_name, $payload)
        # Each line received is a message
        $line = $payload.bytes.trim
        if $line != ''
            # Broadcast 'message' so the room sees it
            %self.object.broadcast('message', {text: $line})
        end
    end

    method &on_socket_closed($broadcaster, $event_name, $payload)
        # Bubble the closed event up so the server can clean up
        %self.object.broadcast('closed', {reason: $payload.reason})
    end

    method &write($text)
        @socket.send_all($text)
    end
end

The socket broadcast call sites GitHub issue

The socket layer itself initiates the cascade. Inside the tcp_listener.accept function (illustrative — the real function body lives in the network layer):

caspian
function &accept(opts: null)
    $new_conn = .wait_for_kernel_accept
    $remote   = $new_conn.remote_addr
    %self.object.broadcast('new_connection', {
        connection:  $new_conn,
        remote_addr: $remote
    })
    $new_conn
end

Inside the connection socket's read loop:

caspian
function &on_kernel_data_arrival($bytes)
    %self.object.broadcast('data_received', {bytes: $bytes})
end

Script GitHub issue

caspian
$server = $chat_server.new(port: 6667, client_class: $chat_client)
$server.start

# Optional — quick anonymous metrics directly on the server using on_broadcast
$message_count = 0
$server.object.on_broadcast('message_relayed') do($event_name, $payload)
    $message_count = $message_count + 1
end

# Run forever; the server is event-driven through the socket layer
forever
    $server.accept_one    # blocks; broadcasts cascade through it
end

Event cascade for one received message GitHub issue

  1. Kernel delivers a byte buffer to $client.@socket.
  2. The socket broadcasts 'data_received' with {bytes: ...}.
  3. The chatclient's on_data handler runs (in chatclient's role).
  4. on_data calls %self.object.broadcast 'message', {text: ...}%self is the chat_client, so the broadcast is FROM the client.
  5. The server has registered for 'message' on this client. Its on_client_message handler fires.
  6. on_client_message iterates @clients and writes the line to every other client's socket. Each write calls @socket.send_all — synchronous I/O completes before the next iteration.
  7. All writes done. Broadcast count returns. on_data returns. The socket's broadcast returns. The kernel-data-receive returns.

Notes on the example GitHub issue


See also GitHub issue


© 2026 Puck.uno