Review round 1. The sizing amendment's worst case is one socket client with all four subscriptions (15 snapshots, about 1.8 MB, 472,061 bytes measured because fan-out shares artifacts), not seven stuck subscribers. The lifecycle amendment gains deploy-time validation, the flyedge cpuset and the absolute bus dir; a known-limits list records what review round 1 left as notes: feed counters off the container, store quota per router, rollback while in bus mode, and the old fixtures.
12 KiB
flybus: the communications bus
Status: crate landed; the feed rides it behind FLY_FEED_VIA=bus, off by default.
Written 2026-09-22, amended 2026-09-23 (EDGE-01, below). Index only; the
authority for the API and the wire format is the crate's own
README, and the audit of the crate against
the draft is the conformance report.
What it is
flybus is a local RPC and pub/sub bus for Tokio processes on one host, with immutable
file-backed artifacts for large payloads. One library, one router, one wire protocol:
messages are small strict-JSON envelopes carrying metadata and artifact references, while
bulk bytes (frames, audio, spike bitsets) live in a store directory the router owns.
Ownership follows deliveries and explicit holds; the router moves messages and tracks
ownership and does not interpret them. It implements the Flybus v1 draft (bus-v1, draft 1
of 2026-09-18) over the ipc-v1 scalar encodings.
Transports are an in-memory pair, an accepted AsyncRead + AsyncWrite stream, or a Unix
socket. Identity is bound by the launcher before Hello and checked against a Policy of
per-client grants; open/unbound mode is for trusted tests only and is not authentication.
Nothing in the crate is specific to a game, a brain or a stream.
What it is meant to replace
Today the three processes talk over two ad-hoc loopback surfaces:
| Today | Under the bus |
|---|---|
Feed: WebSocket 127.0.0.1:7400/feed, one binary message per snapshot at 30 Hz, header plus RGBA frame, audio and spike attachments, re-serialized per consumer |
One latest-mode topic per stream, with the frame as a sealed artifact shared by fan-out instead of copied per subscriber |
Control: loopback HTTP 127.0.0.1:7401 (/stimulate, /chat, /checkpoint, /pause, /status, ...) |
RPC services with per-client grants, FIFO dispatch, explicit cancel states and backpressure |
| Per-surface limits, rate limits and timeouts written twice | Limits and Policy in one place, negotiated at Hello |
The feed and control contracts in feed-protocol.md and control-api.md stay binding until a migration replaces them. The bus does not change any published contract by existing.
Crate layout
services/flysim/crates/flybus, a workspace member of the flysim workspace. flysim depends
on it for the feed publisher (src/feedbus.rs) and fly-edge for the subscriber.
| Module | Contents |
|---|---|
wire |
Scalars, strict JSON, Envelope, ArtifactRef, Attachment, Location, framing, CONTRACT and contract_digest() |
error |
ErrorCode, Dispatch, BusError |
limits |
Limits and its hello encoding |
policy |
Policy, Grants, Pattern |
router |
Router, RouterConfig, RouterStats, UnixListenerHandle; the state machine in router/state.rs |
client |
Client and the handle types |
store |
The router-side file store and client-side location resolution |
transport |
Transport and the Stream trait |
Dependencies are already in the workspace lockfile: tokio, serde, serde_json, sha2,
libc. Tests run every integration case twice, once in memory and once over a Unix socket:
wire negotiation, RPC authority and cancellation, pub/sub credits and retention, artifact
allocate/seal/read with quotas and router restarts, plus the conformance suites and an
--ignored perf measurement.
Wiring still pending
flysim publisher.Done 2026-09-23 behindFLY_FEED_VIA=bus: see "Feed over the bus".- flysim control services. The control endpoints as RPC services with grants, so the "no button endpoint" structural guarantee is expressed as a grant table.
- Stage and bridge clients. Both are TypeScript/Node; the crate is Rust only, so either
a binding or a thin translating edge process is required before they leave the WebSocket
and HTTP surfaces. The operator chose the edge process (port decisions, 2026-09-23); for
the feed it exists (
fly-edge), and they keep the WebSocket contract unchanged. The control API (:7401) is the next slice and stays in flysim until then. Sizing.Decided 2026-09-23: amendment "Feed sizing" below.Lifecycle.Decided 2026-09-23: amendment "Feed store lifecycle" below.- Migration order. The feed is the cheaper first move; control should follow only once the bus carries the feed in production for a full session.
Feed over the bus (2026-09-23, EDGE-01)
feed.via (FLY_FEED_VIA) picks who serves ws://127.0.0.1:7400/feed. direct is the
default and is the behaviour that predates the bus. With bus:
sim thread --watch<Snapshot>--> publisher task --flybus (in memory)--> Router
(unchanged) (flysim-bus runtime) | <bus_dir>/edge.sock
v (bound to "fly-edge")
fly-edge: Subscription -> watch -> flysim::feed :7400
- flysim does not bind
feed.bind. It starts aRouteron a runtime of its own (two threads,flysim-bus), store root<bus_dir>/store, closed policy:flysimmay declare and publishfly.feed.snapshots,fly-edgemay only subscribe to it, and the Unix socket<bus_dir>/edge.sockis launcher-bound tofly-edge. - The topic is
retained: latest. Each publication is one snapshot: the attachments the header lists as sealed artifacts namedframe(image/x-rgba),audio(audio/x-f32le),spikes(application/x-spike-bitset), and the header as the payload{"header": {...}}. A header over 48 KiB of JSON goes as aheaderartifact instead, so the 65,536-byte envelope limit can never make a snapshot unpublishable. - The sim thread is untouched. The publisher reads the same
watchslot the direct server reads, so a slow bus skips snapshots the way a slow WebSocket client does, and nothing on the bus can hold the loop's publish.fly_bus_published_totalandfly_bus_publish_failures_totalcount it. fly-edgesubscribeslatest, one in flight, with replay, rebuilds eachSnapshotwithfeedbus::receiveand serves it with flysim's ownfeed::router.hello,wants, drop-oldest, the idle header and the framing are therefore the same code, and the bytes are the same bytes:crates/fly-edge/tests/parity.rsreplays the committed stage fixtures through both paths at once and requires byte-equal messages per client flavour.fly_frames_sent_totalandfly_feed_clientsmove to the edge with the clients; it exports them under the same names onFLY_EDGE_METRICS_ADDR(127.0.0.1:9102ininfra/units/flyedge.service), and watchdog check 2 followsFLY_FEED_VIAinfly.envto them. flysim's own copies read 0 in bus mode;/statusis otherwise unchanged. The edge also exportsfly_edge_bus_connected,fly_edge_bus_lost_total,fly_edge_bind_failures_total(the bus answered but :7400 was taken, most likely by a flysim still in direct mode) andfly_edge_decode_failures_total.- Nothing about the fly changes: the readout, the reward catalog, the adapter version and the
compatibility string are byte-identical in both modes (
--print-compatibility).
Amendment 2026-09-23: feed sizing
Measured on the live fly (release build, the real cartridge): a running snapshot is a
92,160-byte frame (160x144 RGBA; not the 640x480 "1.2 MB" the pending list assumed), a
17,407-byte spike bitset (139,255 neurons), about 12,800 bytes of audio at realtime
(1,600 stereo f32 frames per 30 Hz snapshot at 48 kHz) and a 2 to 3 KB header: 122,367
bytes of artifacts, about 3.7 MB/s at 30 Hz. flysim::feedbus::limits():
| Limit | Value | Why |
|---|---|---|
max_clients |
8 | connections, pending handshakes included: the in-process publisher and the edge's one socket seat |
max_subscriptions_per_client |
4 | the edge needs 1; this is what bounds the worst case |
max_latest_in_flight |
2 | the default; the edge asks for 1 |
max_artifact_bytes |
4 MiB | ten seconds of audio that piled up behind a late publish |
max_store_bytes |
32 MiB | tmpfs, so RAM; ten times the worst case below |
max_retained_bytes |
8 MiB | one retained snapshot, plus a large header artifact |
max_owners_per_client / reserved |
64 / 8 | three artifacts per delivery, a few deliveries |
| others | small counts | one topic, no services |
A latest subscriber that never consumes pins at most its queued slot plus its in-flight
credits (3 snapshots); the topic pins one retained value; the publisher holds one snapshot of
staging plus the sealed copy while sealing. Only one client can subscribe at all: the publisher
is in process, and edge.sock is launcher-bound to fly-edge, which the router admits once at
a time (a second connection is refused as already connected). The worst case is therefore that
one client holding all 4 subscriptions it may open, none consuming: 4 x 3 + 1 + 2 = 15
snapshots, about 1.8 MB, and publication never waits on any of them (a latest subscriber is
never a reason to refuse a publication, bus-v1 section 9). crates/fly-edge/tests/stall.rs
measures exactly that seat: four hoarding subscriptions, a fifth refused, a second connection
refused, pacer lag 0, no publication refused. The 15 is an upper bound; the measured store
was 472,061 bytes (under 4 snapshots), because fan-out adds roots and never copies, so four
subscriptions stuck on the same publications pin the same artifacts.
Amendment 2026-09-23: feed store lifecycle
- Location.
feed.bus_dir(FLY_BUS_DIR),/run/fly/buson the containers: tmpfs, 0700, owned byfly, created by tmpfiles and again by flysim. The store root is<bus_dir>/store, the socket<bus_dir>/edge.sock. A reboot empties it. - Owner. The router lives in flysim; its lifetime is flysim's. flysim removes a stale
socket file at start, and
Router::newremoves any store directory whoseflockis free, i.e. one a crashed flysim left behind. A clean stop removes its own directory. The edge owns nothing on disk. - Order. flysim first, the edge after it:
flyedge.serviceisAfter=andRequires=flysim.service, so an explicit stop or restart of flysim (the unstick rule's restart included) takes the edge with it. A crash-restart of flysim needs nothing: the edge sees the connection close, drops every WebSocket client, unbinds :7400 and reconnects every 500 ms, binding :7400 again only when the first snapshot of the new router arrives. To the stage that is exactly a flysim restart in direct mode: refused, then back. - Default.
flyedge.serviceis in no target and07-enable.shdoes not enable it;05-deploy.shwritesFLY_FEED_VIA=directunless the env file says otherwise, and refuses anything butdirectorbus(any case, written lowercased). The switch and the way back are in the unit's header. The edge gets a cpuset drop-in on the page's CPUs with the other units, so once enabled it never runs on flysim's. - Paths.
feed.bus_dirmust be absolute and non-empty (checked in both modes), since flysim and the edge each resolve it. - Migration order is unchanged: the feed first; control only after the bus has carried the feed in production for a full session.
Known limits (review round 1, 2026-09-23)
Accepted for now and written down rather than fixed:
- Feed counters off the container. In bus mode flysim's
:9101reportsfly_feed_clientsandfly_frames_sent_totalas 0, and the edge's copies are on loopback:9102only. The watchdog followsFLY_FEED_VIA; anything that scrapes:9101from off the container (the metrics dashboard) goes blind to the feed until it also scrapes the edge. - Store quota is per router, not per client. Any client on
edge.sockmay allocate artifacts up to the store cap; a hostile process running as the same user could fill the store and make flysim's publications fail. The loop is unaffected (a refusal is counted, never waited on), but the feed would stall. Same-user processes are inside the trust boundary (crate README, "Limitations"). - Rollback while in bus mode. Rolling back to a release without
fly-edgewhilefly.envstill saysbusleaves no one on :7400, and check 2 then reads the edge's absent:9102and escalates. Switch back todirectfirst (the unit header's way back), then roll back. - Old fixtures.
cold-open,steadyandbig-momentpredategame.sceneand cannot be a RustFeedHeader, so fixture parity coversmacros,shop,centerandbigpad.