Skip to content

Live signal feed

The signal feed streams the values crossing chosen capsule terminals to WebSocket clients, as they happen. It is the live counterpart to the REST API: REST edits and inspects the project, the feed shows the signals flowing through it.

It is a runtime module (signal_feed, sig_signal_feed.h) that rides on the shared HTTP server: same address and port as the REST API, path /feed, a WebSocket upgrade (RFC 6455, version 13), JSON text frames.

The feed is unauthenticated. Anyone who can reach the port can subscribe to any terminal of any service and read every value that crosses it, for as long as they stay connected. There is no login, no token and no per-terminal access control.

  • The only check is the Origin header, done by the HTTP server against its cors_origin setting (sighost -o <origin>). A request carrying an Origin must match it (or -o '*'), otherwise the upgrade is refused with 403 forbidden_origin; with no -o any request that carries an Origin is refused. A request with no Origin header is accepted.
  • That check only stops other web pages in a browser from opening the feed on a visitor’s behalf. It does nothing against any other client: curl, a script or another host simply omit or forge the header.
  • The default bind is loopback (127.0.0.1). The HTTP server logs a warning when it is bound to a non-loopback address, because the unauthenticated API and feed are then reachable from the network.
  • Authentication is required before this is relied on. Until it exists, run the feed only on a loopback interface or a network you fully trust, and do not use -o '*'.

See also Security in the REST API.

The feed needs the HTTP server, which needs the socket layer:

cmake -S . -B build -DSIG_NET=ON -DSIG_HTTP_SERVER=ON -DSIG_MGMT=ON -DSIG_SIGNAL_FEED=ON
cmake --build build
build/host/posix/sighost -p <project> -m 8080 -o http://localhost:5173

-DSIG_SIGNAL_FEED=ON without -DSIG_HTTP_SERVER=ON is a configuration error. sighost registers the module whenever it serves HTTP, so -m [<addr>:]<port> turns the feed on at ws://<addr>:<port>/feed; -o <origin> is the origin allowed to open it from a browser (see Security). Without -m there is no feed.

A host that registers modules itself registers sig_signal_feed_module() after sig_http_server_module() and sig_mgmt_http_module(). It takes no configuration. Without the HTTP server the runtime’s start fails with SIG_ERR_NOT_FOUND.

Upgrade refusals come from the HTTP server before the feed sees the request: 400 bad_request for a missing or non-13 Sec-WebSocket-Version or an empty Sec-WebSocket-Key, 403 forbidden_origin as above, and 503 unavailable if the feed declines the connection (for example it is not running during a runtime restart).

Every frame is a JSON text frame carrying one object. Names and ids are non-empty strings of at most 63 bytes.

{"op":"subscribe","id":"a","service":"main","instance":"counter","terminal":"count"}
{"op":"unsubscribe","id":"a"}

id is chosen by the client and must be unique among that connection’s live subscriptions; every server message about a subscription echoes it. Subscribing needs a running project and names an existing service, instance and terminal. Both input and output terminals can be subscribed; the reply says which it was.

Message Meaning
{"op":"subscribed","id":"a","dir":"out"} Subscription accepted. dir is "out" for an output terminal (seen when the capsule emits) or "in" for an input terminal (seen just before its handler runs)
{"op":"signal","id":"a","seq":0,"t":1761212345678,"type":"int","value":3} One signal. seq counts the delivered signals of this subscription from 0; t is the Unix time in milliseconds when the signal was observed
{"op":"dropped","id":"a","count":12} count signals were lost at this point in the stream (see Delivery)
{"op":"error","id":"a","error":"not_found"} A command failed. id is omitted when the command had none

Error codes:

error When
bad_request Not valid JSON or not an object, unknown op, missing or invalid id/service/instance/terminal, subscribing with an id already in use, unsubscribing an unknown id, or the runtime is not running
not_found The service, instance or terminal does not exist in the running project
too_many The connection already has 16 subscriptions

An error reply leaves the connection open.

type is one of int, bool, double, string, bytes, map, list, and value is the signal as JSON, encoded as in Values: nested data and JSON: maps become objects with keys in sorted order, lists become arrays, bytes become a padded base64 string (the type field tells you it was bytes), and a non-finite double becomes null.

The client subscribes to an output terminal and a terminal that does not exist, then unsubscribes:

-> {"op":"subscribe","id":"a","service":"main","instance":"counter","terminal":"count"}
<- {"op":"subscribed","id":"a","dir":"out"}
<- {"op":"signal","id":"a","seq":0,"t":1761212345678,"type":"int","value":1}
<- {"op":"signal","id":"a","seq":1,"t":1761212346678,"type":"int","value":2}
-> {"op":"subscribe","id":"b","service":"main","instance":"counter","terminal":"nope"}
<- {"op":"error","id":"b","error":"not_found"}
<- {"op":"signal","id":"a","seq":2,"t":1761212347678,"type":"int","value":3}
<- {"op":"dropped","id":"a","count":12}
<- {"op":"signal","id":"a","seq":3,"t":1761212360678,"type":"int","value":16}
-> {"op":"unsubscribe","id":"a"}

unsubscribe has no reply on success. Closing the connection drops all its subscriptions.

Closing the connection with a WebSocket close frame is the answer to a protocol violation, not an error message:

Close code When
1002 A protocol error: a binary frame, a fragmented message, an unmasked client frame, reserved bits set, or an invalid control frame
1009 A client frame whose payload exceeds 4096 bytes
1013 The feed already has 8 connections (the upgrade succeeds, then this close frame is sent)
1000 Echoed when the client sends a close

Pings are answered with pongs. The server also pings each connection every 30 s and closes one it has heard nothing from for 75 s (see Limits).

  • Late but complete. Signals are queued per subscription and flushed to the socket about every 20 ms. A slow reader sees values late, but in order and without gaps, as long as the queues have room.
  • Drops are explicit. The tap runs on the emitting service’s thread and never blocks, so when a subscription’s queue is full the signal is discarded and counted. The next message the client reads at that position in the stream is {"op":"dropped",...,"count":N}; consecutive drops merge into one marker. A single value larger than the per-message cap is dropped the same way.
  • seq numbers the signals that were queued for a subscription, starting at 0 and rising by one per delivered signal. Dropped signals do not consume a seq, so a gap is never visible in seq; the dropped marker is the only gap signal. seq is per subscription and restarts at 0 for a new subscription, even on the same terminal.
  • Backpressure. When more than 1 MiB is waiting to be written to a connection, the feed stops reading from it and stops draining its subscriptions’ queues until the client catches up; the queues then fill and further signals are dropped and counted.
Limit Value
Connections 8 (the 9th is closed with 1013)
Subscriptions per connection 16 (the 17th gets too_many)
Client frame payload 4096 bytes (larger closes with 1009)
Name / id length 63 bytes
Queue per subscription 256 signals and about 256 KiB (estimated from the value’s size)
One signal’s value about 64 KiB (estimated) and nesting no deeper than the JSON limit; larger is dropped and counted
Unsent data per connection 1 MiB, then backpressure as above
Flush interval 20 ms
Stalled output a connection with unsent data and no write progress for 30 s is dropped
Unflushed close a connection whose close frame is not written within 5 s of being queued is dropped
Server ping / silence a ping every 30 s; a connection with no inbound traffic (any frame, a pong included) for 75 s is dropped

A subscription sees signals from the moment it is made. There is no replay of earlier values and no “current value” on subscribe: a terminal that emits rarely stays silent until its next signal.

The runtime tap is installed only while at least one subscription exists, and removed when the last one goes away. With no subscribers the signal path pays nothing for the feed. See Architecture.

The feed carries signal values only. It is not a log stream: sig_log lines are not delivered.

Subscriptions are keyed by service, instance and terminal names, so they survive a restart of an individual service from the management API: the runtime reapplies the tap to the new service and values keep flowing.

They do not survive a restart of the runtime. Stopping the runtime stops the feed module, which closes every feed connection and drops its subscriptions. Clients reconnect and resubscribe.

  • No authentication (see Security).
  • No history, replay or filtering by value; clients filter on their side.