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:
| Scope | API | Where it lives |
|---|---|---|
| Direct intra-yuno | gobj_send_event | One process, one event, one receiver |
| Broadcast intra-yuno | gobj_publish_event | One process, fanout to subscribers |
| Inter-yuno (over wire) | gobj_command / ievents | Two 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.
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:
| Flag | Meaning |
|---|---|
EVF_OUTPUT_EVENT | The gclass publishes this event. Required for gobj_publish_event. |
EVF_PUBLIC_EVENT | Part of the gclass’s public API (subscribers from other gclasses can subscribe to it). |
EVF_SYSTEM_EVENT | Yuneta-internal event. Used by the framework, not user code. |
EVF_NO_WARN_SUBS | Silence the “Publish event WITHOUT subscribers” warning for optional subscribers. |
EVF_AUTHZ_INJECT | Requires __inject_event__ authorization to send to this gobj. |
EVF_AUTHZ_SUBSCRIBE | Requires __subscribe_event__ authorization to subscribe. |
EVF_KW_WRITING | The 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:
Find
dst->current_state.Look up
eventin the state’sev_action_list(_find_event_action).If not found:
If the gclass has
mt_inject_event, delegate to it.Else log
"Event NOT DEFINED in state"and return -1.
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:
In the publisher:
dl_subscriptions— list of who subscribes to me.In the subscriber:
dl_subscribings— list of whom I subscribe to.
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:
| Key | Effect |
|---|---|
__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¶
| End | File | Role |
|---|---|---|
C_IEVENT_SRV | kernel/c/root-linux/src/c_ievent_srv.c | Listens, and receives connections from clients. |
C_IEVENT_CLI | kernel/c/root-linux/src/c_ievent_cli.c | Initiates, 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):
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 receiveOther top-level keys in kw outside __md_iev__:
__md_yuno__— set by the responder viamsg_iev_set_back_metadata()(msg_ievent.c). Survives the round-trip.__temp__— stripped at the yuno boundary (msg_ievent.c). Use it freely for transport-local bookkeeping.__top_side__,__bottom_side__— see §6.5.
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:
src_yuno,src_role,src_service— who I amdst_yuno,dst_role,dst_service— who I want to talk tojwt— optional bearer for auth (also accepted from Cookie if empty,c_ievent_srv.c)user,host,pid,watcher_pid— caller metadata
The server validates (c_ievent_srv.c):
Role match.
Optional yuno-name match.
Destination service exists (
gobj_find_service(iev_dst_service)).Auth (
jwtor 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:
iev_create_from_gbuffer(msg_ievent.c:152) deserializes the WS frame.Inspect the latest
ievent_gate_stackentry —dst_yuno,dst_role,dst_service(c_ievent_srv.c).Validate the role/name.
gobj_find_service(iev_dst_service)— case-insensitive lookup. Special names:__default_service__→ the yuno’sgobj_default_service().__yuno__and__root__→ both resolve to the top-level yuno gobj (treated as aliases bygobj_find_service).
Authorize the per-message service (since 7.6.0). The
is_service_authorized()check runs in the common path ofac_on_message(so it coverscommand/stats/subscribe/unsubscribe/inject) andac_mt_stats, comparing the resolved service against the channel’sauthorized_servicesset (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 gate —rootmeans 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, seeYUNO_AUTH.md§4.5.A refused message never strands the channel:
reject_unrouted_iev()answerscommand/statswith a negativeEV_MT_*_ANSWER(re-arming the read) anddrop()s the channel for the no-answer types. It never does a silentreturn -1, which leaves the socket connected but deaf (a zombie).Dispatch by
__msg_type__(all gated byauthorized_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.
| Identity | Where it lives | What it is | Who checks it |
|---|---|---|---|
| Channel/session user | __md_iev__ievent_gate_stack[]user | the yuno that dialled the link, from its own JWT | the server side, when accepting the connection |
| Operator | kwusername` | the human (or client) that issued the command | the 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:
C_IEVENT_SRVon the controlcenter authenticates the operator and overwriteskwusername`` with the authenticated principal — a wire client cannot spoof it.cmd_command_agentchecks its owncommand-agentpermission for that operator, then deletes a fixed key list from the kw before forwarding —__username__is not in that list, so it survives.The kw goes down the reverse channel unchanged.
On the agent,
authz_checkerreadskwusername`` first, and only readsgobj_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_commandinc_ievent_cli.c, has an emptyCheck AUTHZbanner and neither injects nor sanitizes__username__— it hands the wire kw straight togobj_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 forNODE_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:
__default_service__— the application’s own default service (the one created withgobj_create_default_service). This is the target when no service is specified.__yuno__(alias__root__) — the top-levelC_YUNOroot gobj, common to every yuno. Use it to reach the yuno itself, for exampleservices,view-configand the trace commands.any other string → looked up among the yuno’s registered services.
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¶
| Symbol | Where used |
|---|---|
SDATACM | Declared once per command in the gclass command table |
EV_MT_COMMAND | Wire event for invoking a command remotely |
EV_MT_COMMAND_ANSWER | Wire event for the response |
EV_ON_COMMAND | Local 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 clientEach 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:
| Event | Direction | Meaning |
|---|---|---|
EV_CONNECTED | out (up) | TLS handshake done (or plain TCP open if not TLS) |
EV_DISCONNECTED | out | Connection closed |
EV_RX_DATA | out | Bytes arrived. The payload is in kw["data"] |
EV_TX_READY | out | OK to send more (flow control) |
EV_TX_DATA | in (down) | Send these bytes |
EV_DROP | in | Close 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_SR → C_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_WEBSOCKET → C_PROT_HTTP_SR → C_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):
public_services: array of service names this yuno exposes to outside clients. Anything listed here is reachable viagobj_find_serviceon incoming ievents and is whatC_IEVENT_SRV’s identity-card validation accepts as adst_service.required_services: array of service names this yuno depends on. Used by the agent during startup ordering — yunos with unmet requirements wait.
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:
Call a command:
gobj_command(c_ievent_cli_instance, "list-yunos", kw, src)in JS marshals anEV_MT_COMMANDievent and awaits the answer.Query stats: same with
gobj_stats.Subscribe to events:
gobj_subscribe_eventon the client-sideC_IEVENT_CLIregisters a__subscribing__ievent. The publisher yuno answers withEV_ON_*events that travel over the WS.
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:
Calling
gobj_send_event(dst, ev, kw, src):kwis consumed.Calling
gobj_publish_event(p, ev, kw):kwis consumed.Calling
msg_iev_build_response(g, r, c, s, d, kw): the lastkwparameter is consumed.
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¶
Declare the symbol once (header):
GOBJ_DECLARE_EVENT(EV_MY_THING);GOBJ_DEFINE_EVENT(EV_MY_THING);Add it to the gclass’s
event_types[]with the right flag (EVF_OUTPUT_EVENTif you publish it. AddEVF_PUBLIC_EVENTif subscribers from other gclasses subscribe to it).If the gclass receives it, add an
ev_action_trow to the relevant states’ev_action_lists and write the action function.If the gclass publishes and the consumer is a parent (CHILD pattern), add
EV_MY_THINGto the parent’sevent_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¶
Add a row to your gclass’s
command_tablewithSDATACM2:SDATACM2(DTP_SCHEMA, "my-cmd", 0, 0, pm_my_cmd, cmd_my_cmd, "Do my thing"),Write
cmd_my_cmd()returning amsg_iev_build_response.Register the service publicly. Make sure that the service name is in the
public_servicesarray of the yuno.
From the SPA:
gobj_command(c_ievent_cli, "my-cmd", { ... }, this);Verify with
ycommand:ycommand -c 'command-yuno id=<yuno> service=<service> command=my-cmd kw="{...}"'
9.6 Add a new HTTP gate¶
Pick a port and TLS context.
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);Set
child_tree_filteron theC_TCP_Sso each accepted clisrv gets aC_PROT_HTTP_SRon top.Subscribe to
EV_ON_MESSAGEfrom each child to receive parsed requests. Usegobj_publish_event(child, EV_TX_DATA, …)to write responses.Start with
gobj_start_tree(tcp_s).