Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Inter-process communication in Yuneta

This document covers how things talk to each other in Yuneta: events flowing between gobjs in the same yuno, events crossing yuno boundaries over the network, the gates that turn external traffic (TCP/HTTP/WebSocket/MQTT) into events, and the part that a browser SPA plays.

Sibling to YUNO_LIFECYCLE.md and DEBUGGING.md. Same conventions: every claim cites file:line, ASCII diagrams are inline, sharp edges and recipes at the end.


1. Mental model

The unit of communication in Yuneta is the event:

                          ┌────────────────┐
                          │   event name   │   e.g. EV_RX_DATA
                          ├────────────────┤
                          │   kw (json_t)  │   payload
                          ├────────────────┤
                          │   src gobj     │   who sent it
                          ├────────────────┤
                          │   dst gobj     │   who receives it
                          └────────────────┘

Three scopes you need to keep separate in your head:

ScopeAPIWhere it lives
Direct intra-yunogobj_send_eventOne process, one event, one receiver
Broadcast intra-yunogobj_publish_eventOne process, fanout to subscribers
Inter-yuno (over wire)gobj_command / ieventsTwo processes, JSON-over-WebSocket

External traffic (a TCP client, an HTTP request, an MQTT publish, a browser WebSocket frame) enters through a gate — a tree of protocol/transport gclasses — and becomes an event for the service to handle. Outgoing traffic takes the reverse path.

A message climbs the gate stack: external bytes to the C_TCP_S transport, to the protocol gclass, to C_IEVENT_SRV, to the service action. The response follows the same path in the opposite direction. Each layer has its own trace level.

The same path in text:

   external bytes  ──► transport gclass ──► protocol gclass ──► service gclass
                       (C_TCP_S, C_UDP_S)   (C_PROT_HTTP_SR,    (your gclass)
                                             C_WEBSOCKET,
                                             C_PROT_MQTT2, …)
                                                    │
                                                    ▼
                                              your_action(dst, event, kw, src)
                                              │
                                              └─► may call gobj_publish_event,
                                                  gobj_command (remote), append
                                                  to a treedb topic, etc.

2. The event model

2.1 Events are declared with event_type_t

A gclass lists every event it can produce or receive in an event_type_t array. Declared at kernel/c/gobj-c/src/gobj.h:

typedef struct event_type_s {
    gobj_event_t event_name;
    event_flag_t event_flag;
} event_type_t;

Flags at gobj.h:

FlagMeaning
EVF_OUTPUT_EVENTThe gclass publishes this event. Required for gobj_publish_event.
EVF_PUBLIC_EVENTPart of the gclass’s public API (subscribers from other gclasses can subscribe to it).
EVF_SYSTEM_EVENTYuneta-internal event. Used by the framework, not user code.
EVF_NO_WARN_SUBSSilence the “Publish event WITHOUT subscribers” warning for optional subscribers.
EVF_AUTHZ_INJECTRequires __inject_event__ authorization to send to this gobj.
EVF_AUTHZ_SUBSCRIBERequires __subscribe_event__ authorization to subscribe.
EVF_KW_WRITINGThe action is allowed to modify kw in place, and not only consume it.

Example, the minimal gclass c_timer.c declaration (paraphrased):

event_type_t event_types[] = {
    {EV_TIMEOUT,            EVF_OUTPUT_EVENT},
    {EV_TIMEOUT_PERIODIC,   EVF_OUTPUT_EVENT|EVF_NO_WARN_SUBS},
    {NULL, 0}
};

The EVF_NO_WARN_SUBS on EV_TIMEOUT_PERIODIC matches CLAUDE.md’s rule: “missing subscriber is not a bug” annotation, never a generic noise suppressor.

2.2 States and the event→action table

Per-state transitions are declared with ev_action_t (gobj.h):

typedef struct {
    gobj_event_t  event;       // event that triggers this row
    gobj_action_fn action;     // function to call; may be NULL (no-op)
    gobj_state_t  next_state;  // target state; NULL means "stay (or transition manually)"
} ev_action_t;

A state is a named array of these rows, terminated by {0,0,0}. A gclass is a named array of states (states_t, gobj.h), terminated by {0,0}.

Minimal example from c_timer.c:

ev_action_t st_idle[] = {
    {EV_TIMEOUT_PERIODIC, ac_timeout, 0},   // fire ac_timeout, stay in ST_IDLE
    {0, 0, 0}
};
states_t states[] = {
    {ST_IDLE, st_idle},
    {0, 0}
};

2.3 The kw ownership rule

Every event-carrying API in Yuneta follows the same rule: the callee consumes one reference to kw. If the caller still needs kw, it must json_incref() first.

Macros at kwid.h:

#define KW_DECREF(ptr) if(ptr) { kw_decref(ptr); (ptr) = 0; }
#define KW_INCREF(ptr) if(ptr) { kw_incref(ptr); }

Action functions always receive ownership. They call KW_DECREF(kw) at the end, or they give kw to another API that consumes it, for example gobj_publish_event, gobj_send_event or msg_iev_build_response. A kw that nothing consumes is a leak. A kw that two things consume is a use-after-free.

The framework itself calls KW_DECREF(kw) at gobj.c when there is no action declared, so a missing action does not leak.


3. Intra-yuno event dispatch

3.1 gobj_send_event(dst, event, kw, src) — direct dispatch

The workhorse. Entry at kernel/c/gobj-c/src/gobj.c. Path:

  1. Find dst->current_state.

  2. Look up event in the state’s ev_action_list (_find_event_action).

  3. If not found:

    • If the gclass has mt_inject_event, delegate to it.

    • Else log "Event NOT DEFINED in state" and return -1.

  4. If found: change state, then exec the action (next section).

3.2 The “IMPORTANT HACK”: state changes before the action

Quoting verbatim from kernel/c/gobj-c/src/gobj.c:

/*
 *  IMPORTANT HACK
 *  Set new state BEFORE run 'action'
 *
 *  The next state is changed before executing the action.
 *  If you don’t like this behavior, set the next-state to NULL
 *  and use change_state() to change the state inside the actions.
 */
if(event_action->next_state) {
    gobj_change_state(dst, event_action->next_state);
}

int ret = -1;
if(event_action->action) {
    ret = (*event_action->action)(dst, event, kw, src);
} else {
    KW_DECREF(kw)
}

Practical consequence: inside an action, gobj_current_state(dst) returns the new state, not the one that received the event. If you need the previous state, capture it before the dispatcher gets there (or use gobj_last_state() which is set by gobj_change_state itself).

If your action needs to decide the transition based on kw content, set next_state = NULL in the table and call gobj_change_state() from inside the action.

3.3 gobj_publish_event(publisher, event, kw) — broadcast

Entry at gobj.c. Loops over publisher->dl_subscriptions and calls gobj_send_event(subscriber, event, kw2publish, publisher) for each, after applying per-subscription filters (__filter__, __local__, __global__, see §3.5).

If there are no subscribers, gobj.c logs “Publish event WITHOUT subscribers” at LOG_WARNING — unless the event_type_t declared EVF_NO_WARN_SUBS. This is the canonical “I tried to publish to nobody” warning. If you see it without a cause, the correction is not to silence with EVF_NO_WARN_SUBS indiscriminately (see CLAUDE.md “Optional-subscriber events”), but to confirm the gclass is the right flavour (SERVICE vs CHILD) and that its subscriber chain is correct.

3.4 gobj_subscribe_event / gobj_unsubscribe_event

Signatures at gobj.h:

json_t *gobj_subscribe_event(publisher, event, kw, subscriber);
int     gobj_unsubscribe_event(publisher, event, kw, subscriber);

Storage at gobj.c:

Both lists are kept in sync. Destroying a gobj unsubscribes it from everyone automatically (subscriptions are not gobj-life-extending — the framework cleans up).

event = NULL means “any event”. kw is not a payload. It is a configuration dict that accepts these keys:

KeyEffect
__config__Sub-keys: __hard_subscription__, __own_event__, __rename_event_name__, __first_shot__
__global__Base kw merged into every published kw before delivery
__local__Keys to delete from the published kw before delivery
__filter__Publish only if the published kw matches this selector

Unknown keys at the top level produce a warning (gobj.c).

3.5 CHILD vs SERVICE patterns

The two mt_create blocks from CLAUDE.md, restated here because they are the most common source of “Event NOT DEFINED in state” errors:

CHILD — the gobj was born with a parent (typical for protocol children, per-connection objects, transient helpers). The parent is the implicit audience:

hgobj subscriber = gobj_read_pointer_attr(gobj, "subscriber");
if(!subscriber) {
    subscriber = gobj_parent(gobj);
}
gobj_subscribe_event(gobj, NULL, NULL, subscriber);

The parent’s FSM must declare every event the child can publish — that is what trips people up. If you see “Event NOT DEFINED in state”, look at the parent, not at the child.

SERVICE — the gobj is registered as a service (gobj_create_default_service, gobj_create_service). Subscribers opt in explicitly via the subscriber attr:

const hgobj subscriber = gobj_read_pointer_attr(gobj, "subscriber");
if(subscriber) {
    gobj_subscribe_event(gobj, NULL, NULL, subscriber);
}

A SERVICE with no subscriber and no parent subscription will fire “Publish event WITHOUT subscribers” warnings. The right fix is to mark truly optional events with EVF_NO_WARN_SUBS, not to silence them generally.

3.6 mt_inject_event: the escape hatch

A gclass can set gmt->mt_inject_event to bypass the static FSM table. When gobj_send_event cannot find the event in the current state, it delegates to this method. Three cases use it: wildcard routing, dynamic dispatch, and gateways that do not know the events in advance. The method must consume kw like a normal action.

Do not use it to hide missing event declarations. That hides real bugs.


4. Inter-yuno: the ievent layer

4.1 The two ends

EndFileRole
C_IEVENT_SRVkernel/c/root-linux/src/c_ievent_srv.cListens, and receives connections from clients.
C_IEVENT_CLIkernel/c/root-linux/src/c_ievent_cli.cInitiates, and connects to a remote C_IEVENT_SRV.

Both sit on top of a WebSocket gclass (C_WEBSOCKET), which sits on top of TCP (C_TCP or C_TCP_S):

Two yunos over a WebSocket: yuno A stacks C_IEVENT_CLI over C_WEBSOCKET over C_TCP (client). Yuno B stacks C_IEVENT_SRV over C_WEBSOCKET over C_TCP (clisrv). JSON-over-WS frames flow between the ievent layers, and TCP or TLS between the transport layers. C_TCP_S is the listener that accepted the connection and forked the clisrv child.

The same stack in text:

    yuno A                                       yuno B
   ┌────────────────┐                       ┌────────────────┐
   │  C_IEVENT_CLI  │ ─── JSON over WS ───► │  C_IEVENT_SRV  │
   ├────────────────┤                       ├────────────────┤
   │  C_WEBSOCKET   │                       │  C_WEBSOCKET   │
   ├────────────────┤                       ├────────────────┤
   │  C_TCP (cli)   │ ────── TCP/TLS ─────► │  C_TCP (clisrv)│
   └────────────────┘                       └────────────────┘
                                                     ▲
                                                     │ (C_TCP_S accepted
                                                     │  the connection)
                                                     ▼
                                              ┌──────────────┐
                                              │   C_TCP_S    │
                                              └──────────────┘

The protocol is JSON-over-WebSocket frames. There is no separate “Yuneta wire format” header in the WS payload — each frame is a JSON object with event and kw fields (see §4.2).

4.2 The wire frame

Serialized in kernel/c/root-linux/src/msg_ievent.c:

json_pack("{s:s, s:o}",
    "event", event,
    "kw",    kw
)

Result is dumped with JSON_COMPACT and placed in a WS frame. The receiver does gbuf2json + kw_deserialize (msg_ievent.c).

Concretely, a command call on the wire looks roughly like:

{
  "event": "EV_MT_COMMAND",
  "kw": {
    "__md_iev__": { ... routing & user metadata ... },
    "__command__": "list-yunos",
    "kw": { "filter": ... }
  }
}

4.3 The __md_iev__ metadata block

Documented in kernel/c/root-linux/src/msg_ievent.h. Top-level shape:

__md_iev__
├── __msg_type__         "__command__" | "__stats__" | "__message__" |
│                        "__identity__" | "__subscribing__" | "__unsubscribing__"
└── ievent_gate_stack    [ { stack entry }, … ]   ← LIFO of hops
    │
    └── one entry per hop, fields:
        ├── src_yuno, src_role, src_service
        ├── dst_yuno, dst_role, dst_service
        ├── user, host             ← who initiated
        ├── __username__           ← from auth layer
        └── input_service, input_channel   ← stamped by SRV on receive

Other top-level keys in kw outside __md_iev__:

The stack is pushed on outbound, popped+reversed on the response, so the client gets back the same structure it sent (with response data added). Push/pop helpers: msg_iev_push_stack (msg_ievent.c:327), msg_iev_get_stack, msg_iev_pop_stack (msg_ievent.c:419).

IEVENT_STACK_ID = "ievent_gate_stack" constant at msg_ievent.h.

4.4 Identity card handshake

When C_IEVENT_CLI opens its WS connection, it sends EV_IDENTITY_CARD (declared at msg_ievent.h, defined at msg_ievent.c) carrying:

The server validates (c_ievent_srv.c):

  1. Role match.

  2. Optional yuno-name match.

  3. Destination service exists (gobj_find_service(iev_dst_service)).

  4. Auth (jwt or cookie).

Steps 1 and 2 are case-insensitive, and so is every later per-message re-check of dst_role / dst_yuno on both sides of the link. Yuno, role and service names are one case-insensitive namespace: services are registered and looked up lowercased, so a peer that spells a role Agent reaches the same yuno as one that spells it agent.

On success: stores client_yuno_role, client_yuno_name, client_yuno_service, authenticated in its own attrs (c_ievent_srv.c) and replies with EV_IDENTITY_CARD_ACK. The client unblocks from ST_WAIT_IDENTITY_CARD_ACK (c_ievent_cli.h).

Authentication also returns a services_roles dict — one entry per service the user holds a role in (the primary dst_service plus any required_services the user is authorized for, computed from real treedb roles, not from the client-supplied list). Since 7.6.0 the server captures its keys into the channel’s authorized_services attr: the set of services that this channel can reach. The no-treedb path yields {dst_service:[]}, so the set degrades to the single primary service. This realizes the long-standing available_services design — one authentication can legitimately grant several services (the GUI frontends authenticate against db_history_wz and reach treedb_wattyzer, treedb_authzs, … over the same channel).

Since 7.6.1 the authenticate response also carries a superuser flag, captured into the channel’s is_superuser attr. It is TRUE when the user holds an effective wildcard role (service="*", that is, root), computed from the wildcard itself, not from a literal role name. The local trusted yuneta user (admitted only over localhost) now goes through the same get_user_roles() filter as any user instead of a hardcoded empty role set, so it picks up its real root role and becomes a superuser.

After the handshake the channel is fully bidirectional — events flow either way.

4.5 Routing inside the receiver

ac_on_message in c_ievent_srv.c:933:

  1. iev_create_from_gbuffer (msg_ievent.c:152) deserializes the WS frame.

  2. Inspect the latest ievent_gate_stack entry — dst_yuno, dst_role, dst_service (c_ievent_srv.c).

  3. Validate the role/name.

  4. gobj_find_service(iev_dst_service) — case-insensitive lookup. Special names:

    • __default_service__ → the yuno’s gobj_default_service().

    • __yuno__ and __root__ → both resolve to the top-level yuno gobj (treated as aliases by gobj_find_service).

  5. Authorize the per-message service (since 7.6.0). The is_service_authorized() check runs in the common path of ac_on_message (so it covers command / stats / subscribe / unsubscribe / inject) and ac_mt_stats, comparing the resolved service against the channel’s authorized_services set (captured at identity-card time, §4.4). A peer authenticated for service A cannot reach a service B it holds no role in, even by naming B in its own routing stack. Since 7.6.1 a superuser channel (is_superuser, §4.4) bypasses this gateroot means any realm, any service and any permission, so it reaches __yuno__ and any sibling service. That is not a cross-service escalation. What a command can DO is still governed by the additional default-off per-command authz gate, see YUNO_AUTH.md §4.5.

    A refused message never strands the channel: reject_unrouted_iev() answers command / stats with a negative EV_MT_*_ANSWER (re-arming the read) and drop()s the channel for the no-answer types. It never does a silent return -1, which leaves the socket connected but deaf (a zombie).

  6. Dispatch by __msg_type__ (all gated by authorized_services, superuser bypassing as in step 5):

__msg_type__Action on the receiver
__command__gobj_command(service, cmd, kw, src)
__stats__gobj_stats(service, stats, kw, src)
__subscribing__gobj_subscribe_event(service, event, kw, remote_proxy)
__unsubscribing__symmetric
__message__gobj_send_event(service, event, kw, src) raw event delivery

Answers travel back the way they came — mind where the serializer sits. A __command__ / __stats__ reply is sent to the requester recorded in the ievent stack (dst_service of the top frame). But the ievent serializer for a link is not always in the same place. On a server-accepted link the serializer is the C_IEVENT_SRV below the C_CHANNEL, so C_CHANNEL.ac_send_iev pushes the inner event down to it. On a client-initiated link, for example the controlcenter link of an agent, a C_IEVENT_CLI connects out. There the serializer is the C_IEVENT_CLI at the top of the stack, and below the channel is a raw C_PROT_TCP4H. So you must give an answer that goes back up a client link to the C_IEVENT_CLI itself, not to the channel or the iogate. Its EV_SEND_IEV action unwraps and serializes the inner event. If you route the answer as if the serializer were below the channel, a bare EV_SEND_IEV or EV_MT_*_ANSWER goes on the wire, and the peer rejects it. This is how a command cascaded controlcenter → agent → managed-yuno gets its answer back to the SPA (since 7.6.8).

4.6 Subscribing across yunos

A remote subscription is only a __subscribing__ ievent. The receiver calls gobj_subscribe_event locally, with the C_IEVENT_SRV (or a proxy gobj on its side) standing in as subscriber. When the local service publishes the event later, the framework calls gobj_send_event on that proxy, which marshals the event back over the WS frame to the remote subscriber.

The remote side does not need to keep the connection idle while it waits. Events can fire when the publisher decides. From the point of view of the subscriber, remote events look the same as local ones.

4.7 Two identities travel on a channel — do not confuse them

A command crossing an ievent link carries two different identities, and they authorize different things. Getting them mixed up leads to the wrong conclusion about who is allowed to do what at the far end.

IdentityWhere it livesWhat it isWho checks it
Channel/session user__md_iev__ievent_gate_stack[]userthe yuno that dialled the link, from its own JWTthe server side, when accepting the connection
Operatorkwusername`the human (or client) that issued the commandthe service at the far end, via authz_checker

The controlcenter case makes it concrete. The agent dials out to the controlcenter, so on that link the agent is the client and its session user is its own JWT identity (yuneta_agent@…) — which is why that principal belongs in the controlcenter’s authz list and not in the agents’. But when an operator drives a node through the controlcenter, the operator’s __username__ is propagated end to end and is what the node’s agent evaluates:

  1. C_IEVENT_SRV on the controlcenter authenticates the operator and overwrites kwusername`` with the authenticated principal — a wire client cannot spoof it.

  2. cmd_command_agent checks its own command-agent permission for that operator, then deletes a fixed key list from the kw before forwarding — __username__ is not in that list, so it survives.

  3. The kw goes down the reverse channel unchanged.

  4. On the agent, authz_checker reads kwusername`` first, and only reads gobj_read_str_attr(src, "__username__") only when the kw has none.

So a principal that must run authz-gated commands on a node needs to exist in that node’s authz, not only in the controlcenter’s. Verified end to end by enabling the C_IEVENT_CLI ievents trace on a target agent and reading the inbound frame: the gate stack showed user: yuneta_agent@… while the kw carried the operator’s __username__.

⚠️ Asymmetry worth knowing: the inbound path on the client side, ac_mt_command in c_ievent_cli.c, has an empty Check AUTHZ banner and neither injects nor sanitizes __username__ — it hands the wire kw straight to gobj_command. A yuno therefore trusts whatever identity the peer it dialled asserts. That is defensible while the peer is your own controlcenter, but it means the controlcenter is fully trusted by every node that connects to it, and it is load-bearing for NODE_SEALING.md, where that link is the only remaining door.


5. Commands and stats

Higher-level API on top of the event machinery.

5.0 Addressing a command: every command goes to a service

A yuno is a hierarchical tree of gobjs, and some of them are services. A service is named and externally addressable, and gobj_create_service or gobj_create_default_service registers it. Every command goes to a service. If you do not name one, the command goes to the default service. This is the single rule that newcomers miss, so it is stated explicitly here:

gobj_find_service() resolves the destination service name (case-insensitive). Two names are special:

From ycommand (the destination service is a connection key, not a command argument — a frequent mistake is writing service= inside -c):

ycommand -c 'roles'                 # → __default_service__ (here C_AGENT) → "command not available"
ycommand -S authz -c 'roles'        # → the `authz` service  (-S/--yuno_service)

Through the agent — two C_AGENT dispatch commands (c_agent.c command-agent / command-yuno):

# a service of the AGENT itself:
ycommand -c 'command-agent service=authz command=roles'
ycommand -c 'command-agent service=__yuno__ command=services'   # list the agent's services

# a service of a MANAGED yuno (no id= ⇒ fan-out to ALL managed yunos):
ycommand -c 'command-yuno id=<yuno> service=__yuno__ command=services'
ycommand -c 'command-yuno id=<yuno> service=<service> command=<cmd> kw="{...}"'

So: pick the service first (services lists them, with their gclass), then command-agent for the agent, command-yuno for a managed yuno, plain -S for a direct connection. Default everywhere is __default_service__.

5.1 The command table (SDATACM)

A gclass exposes commands by declaring them in a command_table of SDATACM / SDATACM2 rows. Each row: name, parameter schema, permission schema, handler function, description. See c_agent.c for a real example, and the agent’s own YUNO_LIFECYCLE.md for the table semantics.

5.2 gobj_command — the public entry point

Signature at gobj.h:

PUBLIC json_t *gobj_command(hgobj gobj, const char *command, json_t *kw, hgobj src);

When gobj is local: looks up the command in the gclass’s command_table, checks permissions, calls the handler synchronously, returns a json_t * response (typically built with msg_iev_build_response, see §5.4).

When gobj is a C_IEVENT_CLI: builds an EV_MT_COMMAND ievent, sends it over the WS, blocks on the answer event EV_MT_COMMAND_ANSWER. (Or, if the caller passed an async pattern, the response arrives as a callback.)

5.3 The three command events

SymbolWhere used
SDATACMDeclared once per command in the gclass command table
EV_MT_COMMANDWire event for invoking a command remotely
EV_MT_COMMAND_ANSWERWire event for the response
EV_ON_COMMANDLocal event a service publishes when its command result is ready (mostly for async commands)

The constants are in msg_ievent.h and msg_ievent.c. Do not confuse the two: SDATACM is a static declaration, and EV_MT_* are runtime events that travel on the wire.

5.4 msg_iev_build_response

Defined at msg_ievent.c. Every command handler returns one of these to keep the response shape uniform:

json_t *msg_iev_build_response(
    hgobj gobj,
    int result,           // 0 = ok; negative = error
    json_t *jn_comment,   // human-readable message (may be NULL)
    json_t *jn_schema,    // optional schema of returned data
    json_t *jn_data,      // the data itself (may be NULL)
    json_t *kw            // owned — original request kw, for context
);

Note the comment in the source: // OLD msg_iev_build_webix(). Legacy codebases still mention the old name. Both names mean the same thing.

The agent’s YUNO_LIFECYCLE.md shows the gobj_yuno_role_plus_name() prefix convention for jn_comment. See feedback_build_command_response_yuno_prefix.

5.5 gobj_stats and EV_MT_STATS

Same shape as commands but for “give me a stats dict” calls. Wire events are EV_MT_STATS / EV_MT_STATS_ANSWER (msg_ievent.c). Use commands for actions, stats for observation.


6. Gates: how external traffic becomes events

6.1 The protocol/transport tree

A gate is a stack of gclasses, each handling a layer of the protocol:

   service gclass             ← your business logic, registered service
        ▲
        │   EV_ON_MESSAGE (HTTP request / WS message / etc.)
        │
   protocol gclass            ← C_PROT_HTTP_SR, C_WEBSOCKET, C_PROT_MQTT2, …
        ▲
        │   EV_RX_DATA (raw bytes)
        │
   transport gclass           ← C_TCP, C_TCP_S, C_UDP_S
        ▲
        │   bytes on socket
        │
     external client

Each layer is a separate gobj, chained with gobj_set_bottom_gobj() and gobj_bottom_gobj(). The transport publishes EV_RX_DATA upward. The protocol parses it and publishes its own event upward, for example EV_ON_MESSAGE. Then the service handles it.

6.2 Transport layer essentials

C_TCP and C_TCP_S in kernel/c/root-linux/src/c_tcp.c, c_tcp_s.c. Headline events:

EventDirectionMeaning
EV_CONNECTEDout (up)TLS handshake done (or plain TCP open if not TLS)
EV_DISCONNECTEDoutConnection closed
EV_RX_DATAoutBytes arrived. The payload is in kw["data"]
EV_TX_READYoutOK to send more (flow control)
EV_TX_DATAin (down)Send these bytes
EV_DROPinClose the connection

States typical of C_TCP: STOPPED, DISCONNECTED, WAIT_CONNECTED, CONNECTED, WAIT_HANDSHAKE (if TLS), IDLE. See c_tcp.c.

C_TCP_S accepts and spawns a clisrv-mode C_TCP per connection. The filter for what gclass tree to instantiate above each clisrv is held in the child_tree_filter attribute (c_tcp_s.c).

6.3 HTTP server example: C_PROT_HTTP_SRC_TCP_S

c_prot_http_sr.c: builds a ghttp_parser (a wrapper around llhttp. Yuneta replaced the older http_parser library with it. See the memory note project_llhttp_integration.) Output event: EV_ON_MESSAGE with parsed headers, method, URL, body in kw.

Service gclass subscribes to that and dispatches by URL or method.

6.4 WebSocket: C_WEBSOCKETC_PROT_HTTP_SRC_TCP_S

C_WEBSOCKET (c_websocket.c) handles the HTTP Upgrade handshake then parses RFC 6455 frames. Output EV_ON_MESSAGE carries either the text or binary payload. The iamServer attribute (c_websocket.c) distinguishes server-mode from client-mode parsing of masked vs unmasked frames.

6.5 top_side and bottom_side convention

The protocol/transport chain is held together by gclass-level pointer attributes named conventionally bottom_side (downward) — set via gobj_set_bottom_gobj() (and read with gobj_bottom_gobj()). The upward direction is not pointered explicitly — events publish to subscribers, and the parent is the natural subscriber for CHILD-pattern gclasses.

You also see __top_side__ and __bottom_side__ as keys inside kw in some cross-yuno cases, for example master and non-master treedb access (see the memory note feedback_cross_yuno_via_store_not_command). Those are routing markers in the kw. They are not the same as the gobj-tree convention.

6.6 TLS

kernel/c/ytls/ is a runtime-selectable abstraction over OpenSSL and mbedTLS. C_TCP_S passes its ytls pointer down to each accepted clisrv (c_tcp_s.c):

gobj_write_pointer_attr(clisrv, "ytls",    priv->ytls);
gobj_write_bool_attr   (clisrv, "use_ssl", priv->use_ssl);

In C_TCP itself, if use_ssl=TRUE, on EV_CONNECTED the gobj wraps the socket via ytls_new_secure_filter() (c_tcp.c), and from then on EV_RX_DATA carries decrypted plaintext while outbound bytes are encrypted before hitting the wire. The protocol gclass above is unaware TLS exists.

6.7 public_services vs required_services

In each yuno’s config (c_yuno.c):

A service not in public_services is invisible to remote callers, even if it exists locally. This is the simplest enforcement against accidental exposure.


7. The SPA case

A browser SPA is one more C_IEVENT_CLI. Only two things change: the runtime is JavaScript (kernel/js/gobj-js/src/c_ievent_cli.js) instead of C, and the transport is the native WebSocket of the browser. From the point of view of the yuno, a SPA and another yuno are the same.

7.1 Handshake

The SPA sends EV_IDENTITY_CARD over the WS like a C client. The jwt field is typically read from the browser session (Keycloak token, see auth memory notes). The server’s identity card validation is the same code path as for C clients (c_ievent_srv.c).

7.2 What a SPA can do

Anything a C client can. Concretely:

7.3 Live log + dev panel

See DEBUGGING.md §8 — the SPA’s developer panel uses exactly this mechanism to display the wire-level ievent traffic in real time. The teardown order gotcha (set_remote_log_functions(null) BEFORE do_disconnect) is documented there.


8. Sharp edges

8.1 The “IMPORTANT HACK” state-before-action

Already covered in §3.2. The single most common subtle bug for newcomers: reading gobj_current_state(dst) inside an action expecting the previous state. Capture it before the dispatcher gets there, or use gobj_last_state().

8.2 “Event NOT DEFINED in state” almost always means a parent’s FSM

When a CHILD-pattern gobj publishes an event, the parent’s FSM must declare it. The error originates in gobj_send_event on the parent, but the stack trace mentions the child. Always inspect the parent’s event_action_list and event_types[]. CLAUDE.md’s “GClass subscription model” section gives the rule. The diagnostic emoji is 📛.

8.3 EVF_NO_WARN_SUBS is not a noise suppressor

It is the explicit “missing subscriber is not a bug for this event” annotation. If you use it to silence a noisy warning, it often hides a real mismatch between the SERVICE pattern and the CHILD pattern. CLAUDE.md is clear on this. See feedback_gclass_visual_layout.

8.4 Subscription lists are in both gobjs

If you write tooling that walks the subscription graph, remember dl_subscriptions (publisher → subscribers) and dl_subscribings (subscriber → publishers) are two directed lists, not one. Destroying a gobj walks both.

8.5 kw is owned by the callee

Forgetting this leaks JSON memory (often a slow drip). Double-decref crashes immediately. The rule:

If you must keep kw, for example to publish it and then publish a derived event, use KW_INCREF or json_incref first.

8.6 __temp__ is stripped at the yuno boundary

Useful for in-process scratch data inside kw. Useless for anything you need on the far side of an ievent — that gets discarded by msg_ievent.c. If you need persistent metadata across hops, put it under __md_iev__ or use the stack.

8.7 msg_iev_build_webix is the same as msg_iev_build_response

The old name lingers in comments (msg_ievent.c) and possibly in some test fixtures. They are aliases. Use the new name in new code.

8.8 __default_service__ resolution is case-insensitive

gobj_find_service lowercases (gobj.c:5076). "My_Service" and "my_service" are the same service. Do not use case to make two names different.

8.9 SPAs see only public_services

A common confusion is “I added the command but the SPA cannot call it.” Read the yuno config. The service must be in public_services (c_yuno.c).


9. Recipes

9.1 Add a new event to a gclass

  1. Declare the symbol once (header):

    GOBJ_DECLARE_EVENT(EV_MY_THING);
    GOBJ_DEFINE_EVENT(EV_MY_THING);
  2. Add it to the gclass’s event_types[] with the right flag (EVF_OUTPUT_EVENT if you publish it. Add EVF_PUBLIC_EVENT if subscribers from other gclasses subscribe to it).

  3. If the gclass receives it, add an ev_action_t row to the relevant states’ ev_action_lists and write the action function.

  4. If the gclass publishes and the consumer is a parent (CHILD pattern), add EV_MY_THING to the parent’s event_types[] and an action row in the parent’s relevant states. Without them you get “Event NOT DEFINED in state” on the first publish.

9.2 Subscribe locally to another gobj’s events

gobj_subscribe_event(
    other_gobj,        // publisher
    EV_MY_THING,       // or NULL for "any event"
    NULL,              // no subscription config
    gobj               // subscriber (often `gobj` from inside mt_create)
);

If the publisher can live longer than the subscriber, do not forget to unsubscribe in mt_stop or mt_destroy.

9.3 Subscribe to events from a remote yuno

In the local yuno, create a C_IEVENT_CLI pointing at the remote yuno (url, wanted_yuno_role, wanted_yuno_name, wanted_yuno_service, jwt). After the identity card ACK, call:

gobj_subscribe_event(c_ievent_cli, EV_MY_THING, NULL, my_local_service);

The framework will forward the __subscribing__ ievent and re-deliver remote publications as local events on my_local_service. Identical syntax to local — only the source gobj differs.

9.4 Call a command on a remote yuno

Once the identity card is ACKed:

json_t *resp = gobj_command(
    c_ievent_cli_instance,
    "list-yunos",
    json_pack("{s:s}", "filter", "running"),
    my_local_service
);

resp is the same shape msg_iev_build_response builds locally. If you need the async pattern, which does not block, pass a subscriber-style kw and listen for EV_MT_COMMAND_ANSWER.

9.5 Expose a new command to the SPA

  1. Add a row to your gclass’s command_table with SDATACM2:

    SDATACM2(DTP_SCHEMA, "my-cmd", 0, 0, pm_my_cmd, cmd_my_cmd, "Do my thing"),
  2. Write cmd_my_cmd() returning a msg_iev_build_response.

    1. Register the service publicly. Make sure that the service name is in the public_services array of the yuno.

  3. From the SPA:

    gobj_command(c_ievent_cli, "my-cmd", { ... }, this);
  4. Verify with ycommand:

    ycommand -c 'command-yuno id=<yuno> service=<service> command=my-cmd kw="{...}"'

9.6 Add a new HTTP gate

  1. Pick a port and TLS context.

  2. In the service’s mt_create, build the gobj tree:

    hgobj tcp_s = gobj_create_pure_child("my_listener", C_TCP_S, json_pack(
        "{s:s, s:i}", "url", "tcp://0.0.0.0:8080", "use_ssl", 0
    ), gobj);
  3. Set child_tree_filter on the C_TCP_S so each accepted clisrv gets a C_PROT_HTTP_SR on top.

  4. Subscribe to EV_ON_MESSAGE from each child to receive parsed requests. Use gobj_publish_event(child, EV_TX_DATA, …) to write responses.

  5. Start with gobj_start_tree(tcp_s).


10. Code pointers

WhatWhere
Event type declarationskernel/c/gobj-c/src/gobj.h
EVF_* event flagskernel/c/gobj-c/src/gobj.h
Minimal gclass examplekernel/c/root-linux/src/c_timer.c
gobj_send_event dispatcherkernel/c/gobj-c/src/gobj.c:7441
State-before-action (“IMPORTANT HACK”)kernel/c/gobj-c/src/gobj.c
gobj_publish_eventkernel/c/gobj-c/src/gobj.c:8877
gobj_subscribe_event config keyskernel/c/gobj-c/src/gobj.h
Subscription storagekernel/c/gobj-c/src/gobj.c
mt_inject_event hookkernel/c/gobj-c/src/gobj.h, gobj.c
KW_DECREF / KW_INCREFkernel/c/gobj-c/src/kwid.h
C_IEVENT_SRV / C_IEVENT_CLIkernel/c/root-linux/src/c_ievent_srv.{c,h}, c_ievent_cli.{c,h}
__md_iev__ structurekernel/c/root-linux/src/msg_ievent.h
IEVENT_STACK_IDkernel/c/root-linux/src/msg_ievent.h
Stack push/popkernel/c/root-linux/src/msg_ievent.c
Wire frame pack/unpackkernel/c/root-linux/src/msg_ievent.c
msg_iev_build_responsekernel/c/root-linux/src/msg_ievent.c:541
Identity card validationkernel/c/root-linux/src/c_ievent_srv.c
Service routingkernel/c/root-linux/src/c_ievent_srv.c
gobj_find_service + special nameskernel/c/gobj-c/src/gobj.c:5076
gobj_command / gobj_stats public APIkernel/c/gobj-c/src/gobj.h
HTTP server protocolkernel/c/root-linux/src/c_prot_http_sr.c
WebSocket protocolkernel/c/root-linux/src/c_websocket.c
TCP transport (states + TLS hookup)kernel/c/root-linux/src/c_tcp.c
TCP server (child_tree_filter)kernel/c/root-linux/src/c_tcp_s.c
TLS abstractionkernel/c/ytls/src/ytls.h
public_services / required_serviceskernel/c/root-linux/src/c_yuno.c
JS-side C_IEVENT_CLIkernel/js/gobj-js/src/c_ievent_cli.js