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.
Security
Section titled “Security”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
Originheader, done by the HTTP server against itscors_originsetting (sighost -o <origin>). A request carrying anOriginmust match it (or-o '*'), otherwise the upgrade is refused with403 forbidden_origin; with no-oany request that carries anOriginis refused. A request with noOriginheader 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.
Enabling
Section titled “Enabling”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=ONcmake --build buildbuild/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).
Wire protocol
Section titled “Wire protocol”Every frame is a JSON text frame carrying one object. Names and ids are non-empty strings of at most 63 bytes.
Client to server
Section titled “Client to server”{"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.
Server to client
Section titled “Server to client”| 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.
Values
Section titled “Values”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.
Example session
Section titled “Example session”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.
Connection-level behaviour
Section titled “Connection-level behaviour”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).
Delivery
Section titled “Delivery”- 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. seqnumbers the signals that were queued for a subscription, starting at 0 and rising by one per deliveredsignal. Dropped signals do not consume aseq, so a gap is never visible inseq; thedroppedmarker is the only gap signal.seqis 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.
Limits
Section titled “Limits”| 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 |
No history
Section titled “No history”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.
Idle cost
Section titled “Idle cost”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.
Restart behaviour
Section titled “Restart behaviour”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.
Known limitations
Section titled “Known limitations”- No authentication (see Security).
- No history, replay or filtering by value; clients filter on their side.