Architecture
The design of FastIPC, written as a design document: the problem it solves, the requirements it has to meet, and how the design meets them, from the overall shape down to cache lines and memory orderings. Every name on this page is a file, type or function you can open on GitHub.
1. Summary
the short versionFastIPC passes messages between processes on the same machine through shared memory. Sending a message is a copy into memory the other process can already see, with no system call while both sides are busy, so small messages move at tens of millions per second and a round trip takes a few hundred nanoseconds (Performance).
The speed is the easy part. The hard part is everything around it: finding the other process, setting up the shared memory safely, waking a receiver that is asleep, and noticing when the other side exits or crashes. FastIPC splits every connection in two. A shared-memory segment carries the messages and does nothing else; an ordinary local socket sets the connection up, and its close reports the other side's death. Each half is built for one job, so neither gets in the other's way.
On that base the library offers four ways to talk: a stream between two processes, a broadcast from one publisher to many subscribers, a work queue from many producers to many consumers, and a service from many clients to a pool of servers, with a reply to each request. All four sit behind one C API of 30 functions, usable from C, C++, Zig, Python, C#, Java, Rust, Lua, JavaScript and Go, on Linux, Windows and macOS. A process written in one language talks to a process written in any other.
FastIPC deliberately does one job. It is a small library, not a framework: no broker or daemon to run, no configuration, no third-party dependencies, no serialization format, and no event loop or threading model it imposes on the application. A project adopts it by linking one library of under a megabyte and calling a handful of functions, without reshaping its architecture around it.
How to read this page. Sections 1 to 3 describe the problem and the shape of the solution, and assume no knowledge of the code. Sections 4 to 7 take each kind of channel in depth, with byte layouts, diagrams and the synchronization between processes. Sections 8 and 9 argue why it is correct and say how that is tested, section 10 lists what the design gives up, and section 11 maps everything to the source.
2. Problem and requirements
why it existsBackground
Processes on one machine usually talk through the kernel: a pipe, a Unix domain socket, a named pipe on Windows, or TCP over loopback. These are easy to use and well understood, but every message costs at least one system call on each side and a copy into and out of the kernel, and a receiver that is waiting has to be woken by the scheduler. For small messages that overhead dominates: the useful work is moving a few dozen bytes, but the price is several trips into the kernel.
Shared memory takes the kernel out of the path. Two processes map the same physical memory, and what one writes, the other can read directly. But shared memory on its own is just bytes. It gives no way to find the other process, no way to know that new data has arrived short of polling, no notice when the other process dies, and no protection when the other side writes something wrong. Applications that use it directly end up reinventing those pieces, and the bugs are subtle: lost wake-ups, torn reads, a crash in one process that leaves the other waiting forever.
FastIPC exists to give applications the speed of shared memory with the convenience and robustness of a socket.
Who it is for
Any system that splits work across processes on one machine and needs those processes to talk quickly. Some typical shapes:
- Mixed-language applications. A Python application whose hot spot is rewritten in Rust, C++ or Go and run as its own process: the Python side sends work over a stream or a work queue and gets results back, at memory speed, without embedding the fast code in the interpreter. Any of the ten languages can sit on either end.
- Game development. An editor talking to the running game, an engine talking to external tools, or a game split into a simulation process and a rendering process. Messages are small and frequent, and a crash on one side must not hang the other.
- Robotics. Sensor, perception and control processes on one computer. A broadcast fans one camera or lidar stream out to many consumers while storing each frame once; lossy subscribers such as a visualizer never slow the publisher, while gated ones such as a recorder miss nothing.
- Latency-sensitive systems, such as trading: market data broadcast to strategy processes on the same machine, and orders sent back over streams, with round trips in the hundreds of nanoseconds when both sides spin.
- Worker pools. A work queue spreads jobs over consumer processes, in any language, and hands a job to another worker if the process that took it crashes before finishing.
- Request and reply. A service lets many client processes call a pool of server threads or processes: each request goes to one server, and its reply comes back to the client that sent it, without a thread or a connection per client on the server's side.
Requirements
| Id | Requirement | What it means |
|---|---|---|
| R1 | Fast on the message path | While both sides are busy, sending and receiving involve no system call, no lock and no allocation. Small messages move at memory speed. |
| R2 | Any message size | A message of any length goes through a fixed-size buffer and arrives whole, with no allocation per message. |
| R3 | Easy to set up | Connect by name, in one call, with a timeout. No broker, daemon or configuration file. Failures come back as error codes. |
| R4 | Prompt notice of a dead peer | When the process at the other end exits or crashes, the survivor finds out at once, with no heartbeats or timeouts to tune, and nothing is left behind to clean up. |
| R5 | Robust against a faulty peer | Whatever the other process writes into shared memory, this process doesn't crash, hang, or touch memory outside the segment. |
| R6 | More than one pattern | Besides a two-way stream between two processes: a broadcast to many readers, a work queue shared by many workers, and a service whose servers reply to many clients, in any processes. |
| R7 | Any language, every supported OS | One C ABI that every language can call and one wire protocol, so processes in different languages interoperate. Linux (x86-64 and ARM64), Windows 11 and macOS 14.4+. |
| R8 | Lightweight and easy to embed | One small shared library with no third-party dependencies, no broker or daemon, and no configuration. It makes no demands on the host: it brings no event loop, installs no signal handlers, and treats messages as plain bytes. And a codebase small enough for one person to read in a few sittings. |
Non-goals
- Talking across machines. FastIPC is for processes on one machine; there is no network transport.
- A security boundary. Both sides run as the same user and are trusted not to be malicious, only not to be bug-free (R5). The design limits the damage a buggy peer can do; it doesn't defend against a hostile one.
- Detecting a hung peer. A process that stops responding but doesn't exit is not reported. Detecting it would take heartbeats and timeouts, which R4 rules out (§10).
- Durability. Messages live in memory only. Nothing is written to disk, and messages in flight can be lost when the process holding them ends.
- Fast connection setup. Setup runs once per connection and is built to be simple and careful rather than fast: thousands of connections per second, not millions.
- Serialization. A message is a run of bytes. What goes in it (a plain struct, JSON, Protocol Buffers, FlatBuffers) is up to the application.
- Old systems. Only current OS versions and CPUs are supported (platform support), so the code uses modern APIs directly, without fallbacks.
3. Design overview
the shape of the solutionTwo connections in one
The requirements pull in two directions. R1 and R2 ask for a message path with nothing on it but the message. R3, R4 and R5 ask for careful setup, clear errors and reliable failure detection. FastIPC meets both by giving every connection two channels, each built for one of those jobs:
- A shared-memory segment carries the messages. Both processes map the same block of memory, the segment, and messages pass through circular buffers inside it, called rings. Sending is a copy into memory the other process can already see, so the message path never enters the kernel, except to sleep when a side has nothing to do and to wake a side that slept.
- A control connection handles everything else. Alongside the segment runs an ordinary local socket (a named pipe on Windows), the control connection. It carries the handshake that sets the segment up, and that is where setup errors surface. After the handshake it goes quiet, and its remaining job is to close: the kernel closes a process's sockets whenever it exits, crashes included, so the close tells the other side its peer is gone.
Keeping the two apart lets each stay simple. The message path carries no setup or failure handling to slow it down, and setup can afford to check everything carefully, because it runs once per connection, not once per message.
Four kinds of channel
| Kind | Who talks | Delivery | Inside the segment |
|---|---|---|---|
| Stream (§4) | one server and one client | every message to the other side, in order, both ways | two rings, one per direction |
| Broadcast (§5) | one publisher, up to 1,024 subscribers | every message to every subscriber, in one order; each subscriber is either lossy (never slows the publisher) or gated (misses nothing) | one ring of messages, stored once, that every subscriber reads |
| Work queue (§6) | up to 63 producers and 64 consumers | each message to one consumer, and again to another if that consumer's process ends before acknowledging it; no ordering | a ring per producer, plus a return ring for redelivery |
| Service (§7) | up to 64 clients and 64 servers | each request to one server, and its reply back to the client that sent it, in the order of that client's requests; a request whose server ends before replying is answered as abandoned, never sent again | a ring per client, holding its requests and the room of their replies |
The process that creates a broadcast, a work queue or a service is its owner, and the processes that join it are its members; each member has its own control connection to the owner. A small background task per connection, its watcher, waits for the control connection to close and marks the peer as gone. The owner of a broadcast, a queue or a service runs one poller task instead, which admits new members and watches all of their control connections at once.
Design rules
Two more terms: the data path is the code that runs for every message (sending, receiving, and waiting for data or for room), and everything else is the connection's lifecycle (setting it up, noticing that it has ended, closing it). Three rules hold throughout the code:
- Every piece of state has one owner. The thread running a handshake owns its side of the setup. Once connected, the watcher owns the control connection and the application's threads own the rings. The lifecycle (whether a connection is being set up, running or over) is tracked in each process's own memory, never in the segment, which holds the rings and nothing else.
- Setup happens where the caller can see it. Connecting and accepting run on the thread that calls them, bounded by the call's timeout, and failures come back as ordinary error codes. A stream uses no hidden threads at all. An owner of a broadcast, a queue or a service needs its poller, so that new members can join whatever the owner's own threads are doing.
- The data path allocates nothing and trusts nothing. Another process wrote half of what each side reads, so every index and frame header read from the segment is checked before use.
How the design meets each requirement
| Req. | How | Details |
|---|---|---|
| R1 Fast | Rings in shared memory where each index has exactly one writer, laid out so the two sides never write the same cache line. A waiting side spins briefly, then sleeps on a futex or event, and a sender only makes a system call if the other side is actually asleep. | §4.4–§4.6 |
| R2 Any size | A message bigger than the ring travels as several pieces, each copied straight into the caller's buffer while the sender writes the next. | §4.4 |
| R3 Easy setup | A name maps to a local socket or pipe address; the handshake runs on the calling thread within its timeout; every outcome is an error code. | §4.1, §4.2 |
| R4 Dead peer | The kernel closes a dead process's sockets; a watcher task waits for exactly that and wakes any thread waiting on the rings. | §4.8 |
| R5 Faulty peer | Every index and header read from shared memory is checked before use; each side keeps its own copy of what it must not lose (the capacity, a broadcast's payload positions); a broadcast's publisher-only region is read-only for subscribers. | §4.10, §5.2, §5.5 |
| R6 Patterns | The broadcast, the work queue and the service reuse the stream's base (names, control connections, segments, wake-ups) and add their own segment layouts and an owner that admits members; the service is the work queue's ring set with replies. | §5, §6, §7 |
| R7 Languages and OSes | A frozen C header; every wire layout an extern struct checked at compile time; each OS behind one contract, so the protocol is the same everywhere. | §11 |
| R8 Lightweight | About 8,000 lines of Zig, built into a library of 430 to 460 KB on Linux and macOS (about 715 KB on Windows) that links only the operating system's own libraries. The API is 28 blocking calls with timeouts that any thread may make, so it fits whatever threading model or event loop the host already has. | §11 |
4. The stream, end to end
docs/protocol.md §1–§7A stream connection links one server and one client. Its control connection sets it up and later reports when it ends; its messages travel through a segment holding two rings, one per direction. Each ring is SPSC (single producer, single consumer), so each of its indices has exactly one writer.
4.1Names and the control connection
A name is 1 to 245 characters of [A-Za-z0-9_.-], not starting with . or - (names.isValid). Both sides turn it into the same rendezvous address: on Linux, the abstract AF_UNIX socket fastipc-<euid>-<name> (no file on disk, and private to the user through the uid); on Windows, the pipe \\.\pipe\fastipc-<session>-<name>; on macOS, a socket file in the user's private directory, claimed by flocking a lock file beside it. A name too long for the address keeps its first 32 characters (8 on macOS), followed by ~ and 32 hex digits of its SHA-256. A valid name can't contain ~, so a shortened name never collides with a real one.
Only two 64-byte frames ever cross the control connection (wire_protocol.ControlStreamSegment and wire_protocol.ControlStreamReady): the server's SEGMENT, with the ring capacity, the segment size and a random 16-byte session id, and the client's READY. On Linux and macOS, SEGMENT also carries the segment's file descriptor as SCM_RIGHTS ancillary data; on Windows the client opens the segment by its session id instead. After READY, nothing more is sent. From then on the control connection's only job is to close when a side goes away.
core/names.zig:isValid,linuxRendezvous,windowsRendezvous,macosRendezvous,sessionObjectstream/frames.zig:encodeSegment,checkSegment,encodeReady,isReady; the layouts arewire_protocol.ControlStreamSegmentandwire_protocol.ControlStreamReady; the frame's size, magic and header arecore/wire.zigplatform.claim,connect,acceptWithin,sendFrame,recvSegmentin each OS's files (Windows:windows/pipe.zig; macOS:macos/rendezvous.zigfor the claim and the connect); what Linux and macOS share,sendFrameandrecvSegmentwith itsSCM_RIGHTSwalk (takeDescriptors,receiveFailure), inplatform/posix.zig
4.2The handshake, on the caller's thread
fipc_stream_create (create in stream/server.zig) does nothing but claim the name (Listener.claimName); no background thread starts. fipc_stream_accept (accept) does all of the setup on the calling thread, in a loop of single steps around two waits:
- Wait for a client (
acceptClient→control.acceptClient→platform.acceptWithin), Then, without waiting: check for a fork and the peer's user (platform.peer), create the segment and connection object (newSetup), and send SEGMENT. The setup is nowpending. - Wait for READY (
awaitReady→platform.readWithin), collecting bytes intopending. Once READY is complete, the hand-over (handOver) moves the connected handle into the connection, publishes its session and the CONNECTED phase, starts its watcher (endpoint.startAccepted) and returns the connection.
Resumable. A timeout can only end a call inside a wait, and pending survives it, so the next call picks up where the last one stopped. A server polling with a timeout of 0 admits a client within two calls. Every path out of pending other than the hand-over goes through dropPending. While the last connection returned is still open (client_open), accept returns INVALID immediately: one client at a time.
On the other side, fipc_stream_connect (connect → handshake in stream/client.zig) retries until its deadline. Each attempt runs connectOnce (if nobody is listening or the server is busy, it backs off from 1 ms, doubling up to 100 ms, and tries again), then awaitSegment, then attach (map and check, §4.3), and finally sends READY. Since each side blocks on the other, a client and its server must run on different threads.
stream/listener.zig:Listener,Pending,claimName,cancel,close,dropPendingstream/server.zig:create,accept,Step,acceptClient,newSetup,awaitReady,handOverstream/client.zig:connect,handshake,attempt,awaitSegment;core/control.zig:acceptClient,dropClient,retrying,connectOnce,readOffered,attach,Backoff- Tested step by step by a paced client in
stream/handshake_test.zig, and by peers that crash at each step in the slow tier
4.3The segment
A segment is align_up(640 + 2 × capacity, 4096) bytes: a 640-byte header (wire_protocol.SegmentHeader), then the server-to-client ring (s2c), then the client-to-server ring (c2s). The capacity is a power of two from 1 KiB to 2 GiB.
wire_protocol.zig, which checks them at compile time, and from docs/protocol.md §4.1 and §5.1. Each 64-byte RingWriter and RingReader starts 8 bytes into a line, so each line holds exactly one index: head at 8, tail at 72, and so on.Creation (segment.create, server side). On Linux: memfd_create, ftruncate, then fallocate of the whole size, so a full /dev/shm fails now with NO_MEMORY instead of later with SIGBUS; then seals, so nobody can resize it. On macOS: shm_open under a random name, unlinked at once. On Windows: a pagefile-backed section named after the session id, plus four named auto-reset events. The kernel zeroes the memory; the server writes the header and only then offers the segment.
Attach (segment.attach, client side) checks everything before use: the seals and exact size on Linux, the object's type and size on macOS, then the header's magic FIPCSEG1, capacity, header size and session id against what SEGMENT said. The client writes nothing to the segment before READY. After that, neither side ever reads capacity from shared memory again: each keeps a private copy (Hot.view_capacity), so the peer can't change it underneath them.
stream/segment.zig:size,header,data,create,writeHeader,attach,matches;core/shared_memory.zig:Session,createUnderRandomId,openOfferedwire_protocol.zig:SegmentHeader,RingIndices,RingWriter,RingReader,segment_magicplatform.createSegmentHandle,createSegment,openSegment;Mapping.ringEvent(Windows)
4.4The ring and its frames
head and tail aren't offsets but monotonic byte counts: head counts every byte the writer has ever published, tail every byte the reader has consumed. They only wrap at 264. A position's offset in the ring is pos mod capacity (a mask, since the capacity is a power of two), and the ring holds head − tail bytes. Counting this way avoids the classic ambiguity of offset-based rings, where equal offsets could mean either empty or full.
A ring holds frames: a 16-byte header (wire_protocol.FrameHeader: flags, frag_len, total_len) followed by a piece of a message, padded to 16 bytes. A frame never wraps: if the next frame doesn't fit before the ring's end, the writer fills the end with a PAD frame and starts at offset 0. So every piece is contiguous and 16-byte aligned, which is what lets the zero-copy calls hand out a plain pointer into the ring.
A message longer than one piece (at most capacity − 64 bytes, fipc_max_piece) travels as several frames: the first carries START and the total length, the last carries END. A receive checks the total length against the caller's buffer before consuming anything, so TOO_LARGE leaves the message intact. It then copies the pieces out one by one, publishing tail after each so the writer can reuse the room. That way any message size fits through any ring size, with no allocation. If a receive stops halfway through a message, the rest of it becomes orphans (pieces without START), which the next receive skips. If a writer stops mid-message, it simply begins its next message with START, which tells the reader to drop the unfinished one.
inline fn plan(space: Space, capacity: u32, frame: u64) Plan {
if (space.free > capacity or space.contiguous % wire_protocol.frame_align != 0) return .corrupt;
if (space.contiguous < frame) return .pad;
if (space.free < frame) return .wait;
return .reserve;
}Pieces of 64 bytes or more go through copyLong (core/copy.zig), which copies in 32-byte vectors with aligned stores, so the speed doesn't depend on whichever memcpy the library happens to link against.
stream/ring.zig:Ring(forSend,forRecv,offset,fits,readHeader,writeHeader),frameLencore/copy.zig:copy,copyLongstream/data_path.zig:send,Piece,reserve,reserveSlow,Space;recv,nextMessage,skipToMessage,nextFrame,takeMessage,takePieces,continues
4.5Indices, kept indices and cache lines
A cache line is the unit the CPU moves between cores: 64 bytes on x86-64 (whose prefetcher also fetches lines in adjacent pairs), 128 bytes on Apple Silicon. When two cores write different variables that happen to share a line, the line bounces between them on every write even though no data is actually shared. That's false sharing, and the layout avoids it both inside the segment and inside each process.
In the segment, each index sits on 128 bytes of its own, with the sleep flag of the side that waits for it (§4.3), so a publish (store the index, swap the flag) touches one line, and on either architecture no other side's store takes it.
In the process, the connection object starts with three 128-byte lines laid out by hand (std.atomic.cache_line), checked by a comptime block in endpoint.zig:
| Line | Written by | Holds |
|---|---|---|
Hot | the lifecycle, rarely | the view of the session (header and data pointers, the capacity, which side, view_kind, a broadcast's, a queue's or a service's connection's object), requests (cancel, shutdown), phase |
RecvLine | the receiving thread | the acquired zero-copy message, head_seen, send_head_seen |
SendLine | the sending thread | the zero-copy reservation, tail_seen, head_sent |
So one thread can send while another receives on the same connection, and neither writes a line the other writes. The view is published once, before CONNECTED: first its fields (monotonic), then view_session and view_published (release). Every data call starts by loading view_published (acquire).
Kept indices. A reader remembers the last head it loaded (head_seen) and only loads the shared one again once it has used up everything before it; a writer does the same with tail (tail_seen). Since indices only grow, a kept copy can only lag the shared one, never run ahead of it: nothing unpublished is read and nothing unconsumed is overwritten. Meanwhile each index's line stays in its writer's cache instead of crossing between cores on every message. That alone measured at about 2 to 4 times the small-message rate.
Waiting for an eighth. A writer that finds the ring full spins until an eighth of the ring is free (writer_spin_share), not just enough for its frame, and then fills that room without loading tail again.
core/endpoint.zig:Hot,RecvLine,SendLine,Endpoint,View,publishView,dropViewstream/ring_wait.zig:until(the inline first look withhead_seen),writer_spin_share;data_path.reservewithtail_seen
4.6Waits and wake-ups
A side only waits when it has nothing to do: a reader for data, a writer for room. It spins first (up to 4,096 CPU pause hints, spin_iters), then sleeps in the kernel in slices of at most 100 ms. Neither side makes a system call until one of them has given up spinning.
Sleeping uses a sleep flag next to the index, and every change to a flag is an atomic swap:
pub inline fn publishHead(hot: *const Hot, ring: Ring, head: u64) void {
ring.indices.writer.head.store(head, .release);
wakeIfAsleep(hot, sleeper(ring, .data));
}
inline fn wakeIfAsleep(hot: *const Hot, who: Sleeper) void {
if (who.flag.swap(0, .acq_rel) == 1) wake(hot, who.flag, who.object);
}The waiter must set its flag with a swap, not a plain store. On x86 a store can sit in the store buffer while a later load runs ahead of it (store buffering); the waiter could then miss a publish and sleep a whole slice with data waiting. A litmus test in ring_wait.zig races the real protocol against that weaker variant: the real one must never lose a wake-up, and the weak one must lose some on the test machine, or the test is skipped, since a zero there would prove nothing. A writer's publish costs one plain store and one locked swap; the waiter's swaps are all on its slow path. The reader's publish of tail only looks at the writer's flag, and swaps it when it is set: the room's wake is inexact, and the reader makes it exact on its slow path, before it sleeps (docs/protocol.md §6.1). A writer waits for room only on a full ring, and a locked swap for every message a reader consumes would cost x86 a quarter of its small-message rate.
| OS | Sleep | Wake |
|---|---|---|
| Linux | FUTEX_WAIT on the flag, non-private (the word is shared between processes) | FUTEX_WAKE of one waiter |
| macOS | os_sync_wait_on_address with the SHARED flag (macOS 14.4+) | os_sync_wake_by_address_any |
| Windows | WaitForSingleObject on one of four named auto-reset events per connection (-s2c-data, -s2c-space, -c2s-data, -c2s-space) | SetEvent; the event stays set until a wait consumes it |
Spinning politely. Every time a reader looks at head, it pulls that line away from the writer, which has to win it back for its next publish. So a reader that is only receiving (it has sent nothing since its last wait, per SendLine.head_sent) looks once, then lets stream_pace pause hints go by before it looks again (spin): 32 on x86-64 Linux, 64 on Windows, 512 on aarch64, each tuned on real hardware and kept in the stream_paces table. A reader waiting for a reply looks after every hint.
Waking your own waiters. fipc_cancel and the peer's end use the same rule: publish the change in requests or phase, then swap this side's own two flags to 0 (wakeOwnWaiters). The peer's sleepers are never touched.
stream/ring_wait.zig(its top comment is the proof):until,sleepUntil,spin,announceSleep,checkStop,publishHead,publishTail,wakeIfAsleep,wakeOwnWaiters; the test "no wake-up is lost: store-buffering litmus of the publisher and the waiter"core/wait.zig:Deadline,spin_iters,stream_pace,slice_ms,nextSleepMs,sleepMsWithin,backInIoplatform.ringWait,platform.ringWakeinlinux.zig,macos.zig,windows.zig
4.7Zero-copy
Zero-copy send. sendAcquire reserves one frame at the head and records it on SendLine; the application writes straight into the ring; sendCommit then writes the header (START | END) and publishes. A zero-copy message is always a single piece, so it can be at most fipc_max_piece bytes.
Zero-copy receive. recvAcquire returns a one-piece message in place and records it on RecvLine; recvRelease (or the next receive) consumes it exactly once, and only if it is still the next frame.
stream/data_path.zig:sendAcquire,sendCommit,recvAcquire,releaseHeld;core/calls.zig:sendCommit,commitOther,recvRelease,releaseOtherc_api.zig:zero_copy_call, which inlines the zero-copy exports on Linux only (faster there, slower on Windows)
4.8The watcher: noticing a dead peer without heartbeats
Once a connection is up, its watcher (control.runWatcher, a task on the connection's Io) does exactly one thing: it reads from the control connection. Nothing is ever sent there again, so the read only returns when the peer's end closes, and the kernel closes a process's sockets and pipes whenever it exits, crashes included. So there is no heartbeat, no shared clock and no timeout: end-of-file is the death notice. Whatever the read returns (unless this side's own close stopped it), the watcher closes the connection, publishes PEER_DEAD and wakes this side's ring waiters.
From then on, sends return DISCONNECTED immediately, but receives first deliver every message the peer finished sending: a waiting receive takes one more look at the ring after it sees the end (checkStop in ring_wait.zig). The trade-off: a hung peer is never detected, and a crashed one only once its process has fully exited.
Close (endpoint.close → teardown) follows the stop rule: set requests.shutdown; unblock the watcher's read with platform.unblock (on Linux and macOS, a shutdown of the socket, which ends a blocked read without a signal); wait for the watcher with platform.endTask (on Windows this cancels the pipe read through Io); then clear the view and unmap, under the endpoint's mutex. The library never installs a signal handler and never sends a signal.
core/control.zig:runWatcher,readUntilEnd(a connection's read),readFrame;calls.readControl,calls.applyPeerEnd;broadcast/client.zigreadPublisherFrames(a subscriber's)core/endpoint.zig:interrupt,cancel,close,teardown,requestStop,dropView,free
4.9The process: the shared Io, handles, fork and keep-loaded
The shared Io. All watchers in a process run on a single std.Io.Threaded, reference-counted by the library's objects (process_state.acquire, release): the first object creates it and the last one released joins its threads. Objects from the native Zig API share it too.
Handles. Every OS handle an object holds sits in a named slot of its Handles (listening, cancel, connected, spare, segment, received, and a broadcast's, a queue's or a service's poller and poller_signal), opened and closed under the process-wide handle lock, so leaks and fork can be reasoned about one slot at a time.
fork (Linux, macOS). A child created by fork() inherits every descriptor but none of the threads. pthread_atfork handlers take the reference-count lock and the handle lock before the fork. In the child, afterForkInChild walks the fork registry of every Handles, closes each descriptor (only closes: a shutdown would end the parent's connection too), clears each view_published and marks the object as forked. Calls on an inherited object return INVALID, and the first one logs why. One handle can't be created under the handle lock, the socket that accept returns, so control.acceptClient compares the fork count (forkCount) before and after the accept and drops a client accepted across a fork.
Keep-loaded. Fork handlers can't be unregistered, so if the library were unloaded, the next fork would jump into unmapped code. To prevent that, keepLoaded reopens the library's own image with RTLD_NOLOAD | RTLD_NODELETE when the handlers are first registered, and a later dlclose leaves it in place.
core/process_state.zig:acquire,release,Handles,Slot,lockHandles,registerForkHandlers,keepLoaded,prepareFork,afterForkInChild,reportInherited,forkCountplatform.keepImageLoaded(dladdr, thendlopenwithNOLOADandNODELETE)
4.10Trusting nothing in shared memory
The peer runs as the same user, so it's trusted not to be malicious, but not trusted to be correct. Corrupt content makes a call fail with INVALID; it never causes a crash, a hang or an access outside the mapping. Concretely:
- A ring holding more than its capacity (
head − tail > capacity) is corrupt for both sides (ring_wait.corrupt, andSpace.plan's.corrupt). Without that check, a tail past the head would make every wait return "ready" and send a receive walking the ring forever. - A head off a 16-byte boundary is corrupt for its writer (
Space.plan). - A frame that would end past the ring's end is corrupt for its reader and left where it is (
Ring.fits). Its 16-byte header has already been read by then, at most 15 bytes past the ring's end, but that memory is still mapped: the other ring or the segment's last page follows. - A piece that doesn't continue its message is consumed and its message dropped (
continues), and so is a START whose total length is no longer than its own piece (skipToMessage): better to lose one message than wedge the ring. total_lenneeds no bound: the reader never allocates, so an absurd length is just TOO_LARGE.- The capacity is never read from shared memory after the attach.
stream/data_path_test.zig's faulty-peer tests, and the fuzz harness (tests/fuzz), which corrupts the control connection and the rings
5. The broadcast
docs/protocol.md §9A broadcast connects one publisher to up to 1,024 subscribers, in any of the user's processes. Every subscriber sees every message in the same order, and each message is stored once, in a single segment that all subscribers map. A subscriber is either lossy (it never holds the publisher back, and gets OVERRUN if it falls a full lap behind) or gated (the publisher waits for it, and it can read messages in place).
5.1Roles and objects
- The owner is the publisher's process.
fipc_broadcast_create(server.create) claims the name as a stream server does (Admission.init) and builds the wholeChannelup front, so nothing grows as subscribers join: the segment with every slot, theRoster, one member record per slot (so the poller never allocates), the publisher's private record of payload positions (payload_positions, a sixteenth of the capacity) and the poller task, which admits joiners from then on (§5.6). It returns the publisher's connection. There is no separate listener: the publisher's connection owns the channel, and closing it ends the broadcast and frees the name at once. - The publisher is an
Endpointof view kindbroadcast_publisher, with no control connection and no watcher. Its state,writer.Writer, belongs to the sending thread. Its sends only ever wait for gated subscribers, and never return DISCONNECTED. - A subscriber is an
Endpointof view kindbroadcast_subscriber, with its own mapping, its receive state (reader.Reader) and a watcher, just like a stream connection. - A member (
server.Member) is the owner's record of one slot: while a subscriber holds it, that subscriber's control connection and what the poller has read from it. Member records only change under the lock of the channel'sOwner(core/owner.zig, shared with queues and services), which the data path never takes. - The poller (
pollMembers), one task per broadcast, admits joiners and watches every member's control connection, so a broadcast costs one thread no matter how many subscribers join.platform.Polleris level-triggered epoll on Linux and kqueue on macOS. On Windows it usesReadFileExreads whose completion routines run during the poller thread's alertable waits; not a completion port, becauseIoruns its own routines on the same pipes. - The member count (
Channel.joined, read byfipc_broadcast_wait_for_members) can be read or waited on from any thread. Each change bumps a process-local word (CountWaiters.changes) and wakes its futex waiters, which only sleep while the word still holds the value they loaded before checking the count. - Dispatch. The data calls (
core/calls.zig) loadview_kindonce and switch on it: a stream call goes tostream/data_path.zig, a broadcast call to the publisher's or the subscriber's side. A broadcast message is always a single piece, and a broadcast'sfipc_max_pieceis a quarter of the capacity.
broadcast/server.zig:Channel,Member,create,closePublisher,channelOf,waitForMembers,interrupt;broadcast/client.zig:readerOf,interrupt,leave;core/calls.zig:send,recvbroadcast/segment.zig:Layout;broadcast/roster.zig:Roster;broadcast/writer.zig:Writer;broadcast/reader.zig:Reader
5.2The broadcast's segment: two regions
The layout (segment.describe) puts everything only the publisher writes first and everything subscribers also write after it, each region starting on a 64 KiB boundary (Windows' mapping granularity, and a multiple of every page size). Once attached, a subscriber makes the first region read-only in its own view (platform.protectReadOnly), so a stray write in a subscriber's process faults there instead of corrupting every subscriber's messages. Lines here are 128 bytes, the size of an Apple Silicon line and of x86's adjacent-line pair, so the words only the publisher writes, and each subscriber's cursor, never share a line with a word someone else writes. The words both sides write (armed_any with wake_gen, writer_sleeps, the two bitmaps) get lines of their own.
segment.attach).What a buggy subscriber can do. As with a stream's peer (§4.10), subscribers run as the publisher's user and are trusted not to be malicious, but not to be correct. Any subscriber can write all of region B: every slot's cursor, the wake words and the bitmaps. So a buggy one can disrupt delivery to the others: lost messages, or messages overwritten before a gated subscriber read them; sends that stall or time out; late or spurious wake-ups; even a gated subscriber unsubscribed because its cursor was set back while it slept, making it look stalled. What it can't do is crash anyone. The publisher relies on its own records (§5.5), and every subscriber bounds every index and checks every cell before copying, so a bad value makes the reading call fail with INVALID, never access memory outside the segment.
broadcast/segment.zig:describe,valid,create,attachwire_protocol.zig:BroadcastInfo,BroadcastHeader,BroadcastCell,BroadcastShared,BroadcastSlot,broadcast_busy,broadcast_chunkssegment.Layout: this process's pointers into its mapping, never re-read from the description
5.3A publish, step by step
Message i (counting from 0) uses cell i mod cell_count, where cell_count = capacity / 64, and its payload sits in the data ring at payload_position, a monotonic byte count like a stream ring's indices. Writer.send is reserve, a copy, then publish:
- Place (
payload_ring.place): right after the previous payload, its length rounded up to 64 bytes. If it won't fit before the ring's end, it goes at the start instead; a payload never wraps. - Room: check the next index against
cell_limitand the payload's end againstdata_limit. Past either one, wait for gated subscribers (room, §5.5). With no gated subscribers, both limits simply advance a lap at a time. - Frontier: if the payload ends beyond the last announced frontier, store a new
frontier(the payload's end, rounded up to the next sixteenth of the ring) andnext_mark(this message's index), then a store fence. The publisher only ever writes data below a frontier it has already announced. - Copy the payload into the data ring.
- Cell (
writeCell). The cell is a seqlock: a sequence word marks it "being written" and then "complete", so a reader can detect a torn read. Storeseq = (i+1) | busy, a store fence, the fields, thenseq = i+1with release. - Record the position in the owner's own
payload_positions. - Publish
next = i+1(sequentially consistent), then loadarmed_any. Only if some subscriber has armed for a wake-up does the publisher do anything more (wakeSubscribers, §5.7).
pub fn writeCell(cell: *wire_protocol.BroadcastCell, index: u64, at: u64, len: u32) void {
const sequence = index + 1;
cell.seq.store(sequence | busy, .monotonic);
fence.storeFence();
cell.payload_position.store(at, .monotonic);
cell.len.store(len, .monotonic);
cell.flags.store(0, .monotonic);
cell.seq.store(sequence, .release);
}Because seq is the index plus one, 0 means "never written", so a freshly zeroed segment is already a valid empty broadcast and nothing needs to initialize the cells. Per message, the publisher pays for one copy, the cell's seqlock (a store fence, which is a compiler barrier on x86-64 and dmb ishst on aarch64, plus a release store), one store into its own record, one sequentially consistent store of next and one load of armed_any: roughly what a stream ring's publish costs. The frontier's store and fence only happen once per sixteenth of the ring. None of it depends on how many subscribers there are.
broadcast/writer.zig:Writer.send,sendAcquire,sendCommit,reserve,publish,payloadPositionOf;writeCell
5.4A lossy read, and how a lap is detected
A lossy subscriber at position p never writes anything the publisher reads. It copies each message out, then proves the copy wasn't overwritten:
- Load the cell's
seq(acquire) and classify it (cellState): equal top+1means this is the message (here); larger, ignoring the busy bit, means the publisher has already reused the cell (lapped); anything else means not yet (pending). readCell: loadpayload_positionandlen, a load fence, thenseqagain. Ifseqchanged, the publisher started rewriting the cell in the meantime.- Check the fields (
validPiece: 1 tomax_piecebytes, inside the ring), then copy (seqlockCopy). - A load fence, then load
frontier. Accept the copy only iffrontier − payload_position ≤ capacity.
const piece = readCell(cell, expected_sequence) orelse return .torn;
if (!reader.layout.validPiece(piece.payload_position, piece.len)) return .corrupt;
len.* = piece.len;
if (piece.len > buf.len) return error.TooLarge;
seqlockCopy(buf[0..piece.len], reader.layout.bytes(piece.payload_position, piece.len));
// After the copy's loads: a frontier past a ring from the payload means the writer may have overwritten part
// of what was copied
fence.loadFence();
if (reader.layout.header.frontier.load(.monotonic) -% piece.payload_position > reader.layout.capacity) return .torn;After a lap, the receive returns OVERRUN once, and resync jumps the subscriber to next_mark: the publisher's index when it announced its current frontier, clamped to the published messages after the subscriber's position. That lands it on a recent message straight away, rather than catching up a lap at a time. The receive reports how many messages were skipped, always at least 1, since the torn one counts. The copy deliberately reads memory the publisher may be writing at that very moment; that's how a seqlock works, and it is the one race the library tells ThreadSanitizer to ignore (seqlockCopy).
broadcast/reader.zig:Reader.recvLossy,copyChecked,overrun,resync;cellState,readCell,seqlockCopy,max_indexbroadcast/litmus_test.zig: "the cell seqlock" and "the frontier" litmus tests (their weak variants show the forbidden outcome only on aarch64; elsewhere the tests skip)
5.5A gated read, and how room is computed
A gated subscriber owns a cursor in its slot: the index of the first message it hasn't consumed. The publisher never writes cell i until every gated cursor has reached at least i + 1 − cell_count, and never writes payload bytes a full ring past a cursor's message. So a gated subscriber can read its message in place (nextInPlace, then either copy it for recv or hand out the pointer for recvAcquire) and store its cursor with release when it's done (publishCursor).
Room, from the publisher's side. The publisher keeps free_index, an index every gated cursor is known to have reached, and derives two limits from it: cells below free_index + cell_count, and data below payloadPositionOf(free_index) + capacity. Most sends compare against those two numbers and touch nothing else. When a send passes one, room computes need, the oldest message that has to be consumed to make room, and sweeps the cursors of every slot set in the gated bitmap. If the sweep finds a cursor below need, it stops at that laggard: the publisher waits for it (waitForLaggard), and the next pass resumes from there (Scan). Scan.prepare sets up each pass: it keeps the cursors already seen only while they still matter for need, and loads the publisher's next, which caps free_index, before the scan's first bitmap load (§5.6 explains why). With no gated subscribers, a sweep reads one line once per lap.
The publisher trusts its own records. A subscriber's process could map the segment writable and change anything in region B, or even a cell. So the publisher reads payload positions from payload_positions, in its own memory, never from the cells, and it only waits for and wakes slots its Roster holds, whatever bits the segment shows (nextGatedSlot ANDs the segment's gated word with the roster's). A corrupt cursor can only hold back or release that subscriber's own messages.
Stalls. Every wait for room also runs checkStalls. A gated subscriber whose cursor hasn't moved for the broadcast's max_stall_ms, counted from the first send it held back (time spent in sends that timed out adds up, in Stall), is reported once to on_stall (server.onStall), which sends it UNSUBSCRIBE (§5.8). Several stuck subscribers are handled together, each on its own clock, and the publisher's sleep is cut short when the next stall falls due.
broadcast/reader.zig:Reader.recv,recvAcquire,release,nextInPlace,publishCursor;broadcast/writer.zig:Writer.room,sweep,nextGatedSlot,isGated,waitForLaggard,checkStalls;Scan(prepare),Stall
5.6Joining and leaving: slots and generations
A broadcast's handshake has the same shape as a stream's, with different frames (wire_protocol.ControlBroadcastOffer, wire_protocol.ControlBroadcastJoin, wire_protocol.ControlBroadcastSlot: OFFER, JOIN, WELCOME, and later UNSUBSCRIBE and UNSUBSCRIBED). The owner runs its side on its poller (pollMembers, using the steps in core/admission.zig), never on an application thread, so a process can join its own broadcast from any thread, including the one that created it. No step blocks: each either completes at once or leaves the joiner for the poller's next wait. At most 8 joiners can be between their accept and their JOIN at any time:
- Listen while a slot is free, a joiner record is free and no accept back-off is running (
Admission.listenWhile,Poller.listen). Otherwise stop listening (Poller.unlisten), and joiners wait in the backlog, or for a pipe instance, within their own timeouts. The poller's wait only times out at a joiner's deadline or the end of a back-off, so an idle broadcast causes no wake-ups at all. To start and stop listening, Linux adds the listening socket to its epoll set and removes it; macOS adds and deletes anEVFILT_READ; Windows issues an overlappedConnectNamedPipewhose event joins the poller's alertable wait, and cancels and awaits it. - Accept the client without waiting (
Admission.accept→control.acceptClient: the fork check and the peer's user, then on Windows a fresh pipe instance) into a free joiner record, with its JOIN due within 2 s. Claim a slot (server.claimSlot→Roster.claim): the next free one, round robin, so a just-freed slot is the last to be reused. Its generation goes up by one and its cursor is set to 0. Send OFFER with the slot and generation, and watch the joiner's connection alongside the members'. - Read JOIN (
onJoinerReady,frames.checkJoin: the slot and generation must be echoed back, the role must be valid, and a gated role is only accepted if the broadcast allows gated subscribers). The connection moves into the slot's member record (Admission.handOver). ThenstartMemberruns under the broadcast's lock: for a gated joiner, mark the slot gated in the roster; start the subscriber (broadcast.startSlot, below); count the member; send WELCOME, then watch the connection (Poller.watch). Because the poller is level-triggered, it still reports an end that happened before the watch, on its own thread, after the member has joined. So a subscriber that has been told it joined is always watched and always counted. - Drop a joiner that disconnects, sends anything other than its JOIN, or sends nothing for 2 s after its accept (
dropJoinerfrees its slot and closes its connection); a genuine joiner sees the end and tries again within its own timeout. So a joiner that stalls or sends garbage holds up neither the other joiners nor any member's end, and ties up one of the 8 records and one slot for 2 s at most.
The generation keeps frames and stall reports about a slot's previous subscriber from being mistaken for ones about its current subscriber.
The start. Before counting the subscriber or sending WELCOME, the owner fixes where the subscriber starts, by storing the published next as the slot's cursor. For a gated joiner it stores it twice, the double store (broadcast.startSlot):
const cursor = &layout.slots[slot].cursor;
if (gated) {
cursor.store(layout.header.next.load(.seq_cst), .monotonic);
setBit(&layout.shared.gated, slot, .seq_cst);
}
cursor.store(layout.header.next.load(.seq_cst), .release);Why twice? A sweep that sees the new gated bit reads a cursor at or before the start. A sweep that doesn't see the bit loaded its bitmap words before the bit was set, and the next it caps free_index with was loaded earlier still, so it is at most the start. Either way, the publisher can't overwrite the message the new subscriber starts at. Why the owner, not the subscriber? The publisher's count (fipc_broadcast_wait_for_members) includes the subscriber before WELCOME is sent, and the subscriber's process may take a while to read it. If the subscriber picked its own start from next after WELCOME, it would miss whatever the publisher sent in between. The owner's start is at or before every message sent after the count includes the subscriber (the count's increment is a release, and its load an acquire).
The joiner (client.join → client.attemptJoin) attaches and checks the segment, makes region A read-only, sends JOIN and reads WELCOME (if it reads an end instead, the owner dropped it, and it starts over), then begins at its slot's cursor (Reader.init).
Fixing the start at admission has two consequences. A gated joiner holds the publisher back from the moment it's admitted, like any gated subscriber, max_stall_ms included; one that dies right after WELCOME does so until the poller sees its end. And a lossy joiner of a busy broadcast whose process is slow to start reading may find the publisher already a lap ahead: its first receive then returns FIPC_OVERRUN, for messages sent after it was counted.
Leaving. When a gated subscriber closes, it clears its gated bit and wakes the publisher (Reader.leaveChannel), under the guard and only if its subscription is still running. Its watcher then closes the control connection, and the owner's poller sees the end and frees the slot (Roster.release). If the owner's read fails in any other way, or an unexpected frame arrives, the subscriber may still be running with the slot mapped, so the slot is retired instead (owner.MemberEnd, from endOf; Roster.retire): held by nobody and given to nobody until the broadcast ends.
broadcast/server.zig:create,pollMembers,hasFreeSlot,acceptJoiner,onJoinerReady,dropJoiner,claimSlot,offerFrame,startMember,onMemberReady,endOf,freeSlot,endSlotLocked;core/owner.zig:Owner(init,startPoller,stopPoller,deinit),MemberEnd;core/admission.zig:Admission(listenWhile,waitMs,accept,offer,joinerOf,take,handOver,drop,dropAll,overdue),Joiner,max_pending,join_deadline_ms;platform.Poller(watch,wait,listen,unlisten,take,unwatch,drain)roster.Roster:claim,hold,holdGated,release,retire,free;client.join,attemptJoin;control.awaitOffer;frames.decodeOffer,checkJoin;shared_memory.attachDescribed
5.7The sleeping words and the wake-ups
A subscriber with nothing to read (Reader.wait) spins, then sleeps. The spin's pace adapts (Reader.spin): if its first look found a message, the subscriber is trailing a busy publisher, so the next spin waits twice as long before looking, up to the platform's stream pace. Before sleeping, announceSleep runs: on Linux and macOS, set the sleeping flag (used by its own interrupt, below); read wake_gen; arm the slot (its bit in the armed bitmap, then its word's bit in armed_any); if gated, also store the cursor and check writer_sleeps; then check next, the cancel request, the phase and ended one last time, and sleep only if none of them changed. All of these accesses are sequentially consistent.
The publisher's wake (wakeSubscribers) runs after it stores next, and only if armed_any isn't 0: swap armed_any to 0, swap each armed word it points to to 0, keep only the bits of slots its roster holds, and wake those.
| OS | A subscriber sleeps on | The publisher wakes with |
|---|---|---|
| Linux, macOS | wake_gen, while it still holds the value read before arming (a non-private futex, or os_sync_wait_on_address SHARED) | one bump of wake_gen and one wake-all: a single system call however many sleep |
| Windows | its own slot's named auto-reset event (-bc-wake-<slot>) | SetEvent on each taken slot's event (no address wait works across processes there, and a shared semaphore's permits could be taken by the wrong sleeper) |
No wake is lost. The argument (docs/protocol.md §9.5) is the stream ring's, generalized to a bitmap, and it depends on all these accesses falling into one total order. A subscriber's own interrupt (a cancel, or the end of its subscription) wakes every sleeper of the broadcast on Linux and macOS, so it only does so if its receive actually announced a sleep (the sleeping flag) and the publisher hasn't already woken everyone on close. Otherwise N subscribers noticing the publisher's end would wake every sleeper N times.
The publisher waiting for room sleeps on writer_sleeps, the way a stream writer sleeps on its flag, in slices starting at 1 ms and doubling up to 10 ms (room_slice_ms). On every receive, a gated subscriber stores its cursor with release and checks the flag with a relaxed load, because the exact pair would cost a locked instruction per message. So the publisher can miss a cursor move that races with its own announcement; the 10 ms slice caps that delay. Before it goes to sleep itself, though, the subscriber does use the exact, sequentially consistent pair.
broadcast/reader.zig:Reader.wait,spin,stopped,announceSleep,wakeOwn;broadcast/writer.zig:Writer.publish,wakeSubscribers,announceRoomSleep,endChannel;broadcast/segment.zig:wakeAll,wakeWriter;core/roster.zig:Roster.setWakesplatform.broadcastWait,broadcastWake,wakes_each_slot,SlotWake,createSlotWake,openSlotWake,setSlotWake;names.slotObject- Litmus tests: "a subscriber's sleep against the writer's publish" and "the writer's sleep for room against a gated subscriber's cursor before its sleep"
5.8Ends: the segment guard, unsubscribe and revoke
A gated subscriber writes to its slot (its cursor) and its gated bit. Once its subscription ends, the owner may hand the slot to the next joiner, so the departing subscriber must have stopped writing first. The segment guard guarantees that:
- A gated call enters (
Reader.enterSegment): storein_segment = 1, then load the phase, both sequentially consistent. Only a call that saw CONNECTED may write the slot (live). It keeps the guard while it sleeps, so its mapping stays valid while it waits, and leaves on return. - The watcher that ends the subscription (
client.endSubscription) runsReader.drop: store the new phase, then loadin_segment, both sequentially consistent. It then wakes any sleeping receive and waits, in 1 ms naps, until no call is inside. Only then does it close the connection (which tells the owner) or revoke the mapping.
The two halves form the classic store-then-load pair (a Dekker pair): at least one side must see the other's store. Either the call sees the new phase and writes nothing, or the watcher sees the call inside and waits for it to leave.
if (!reader.gated) return;
reader.in_segment.store(1, .seq_cst);
const phase = endpoint.hot.phase.load(.seq_cst);
if (phase == .unsubscribed) {
reader.in_segment.store(0, .release);
return error.Unsubscribed;
}
reader.live = phase == .connected;Unsubscribe (gated subscribers only). A stall report sends UNSUBSCRIBE, and the subscriber's watcher runs client.unsubscribe: it ends the subscription as unsubscribed through the guard, then revokes the whole mapping (platform.revoke: PROT_NONE, or PAGE_NOACCESS on Windows) under the endpoint mutex, so a message the application still holds in place faults instead of silently showing whatever the publisher writes there next. Finally it answers UNSUBSCRIBED, which frees the slot. From then on every receive returns UNSUBSCRIBED. A subscriber whose whole process is paused can't answer, so the publisher's sends time out until it resumes or exits.
The publisher's close (server.closePublisher) ends the broadcast. First it stores ended = 1 (after its last message) and wakes every sleeper once, so each subscriber drains what was published and reports DISCONNECTED without waiting on its control connection. Then it stops the poller and waits for it to finish, never cancelling it through Io. On Windows the poller's last step cancels every read it has in flight, plus the listening instance's connect, and runs their completion routines (Poller.drain), so none is left queued to the thread. Finally it closes the joiners' connections and the listening handle, which frees the name at once, and then every member's connection.
If the publisher's process dies instead, ended is never set. A gated subscriber can then no longer tell whether the publisher is still writing, so it stops trusting in-place reads and copies and checks each remaining message the way a lossy subscriber does (recvChecked), up to where its subscription ended (drain_end).
broadcast/reader.zig:Reader.enterSegment,leaveSegment,drop,outside,recvChecked,channelEnded;broadcast/writer.zig:Writer.endChannelbroadcast/client.zig:endSubscription,unsubscribe,subscriberFrame,readPublisherFrames(a subscriber's watcher reads whole frames);broadcast/server.zig:onStall,onMemberReady,closePublisher;control.runWatcher- The litmus test "the segment guard against the drop: never a revoke while a receive is inside"
5.9Built to be killed
A broadcast spans many processes, and any of them can die at any instruction. Here is what happens in each case:
- A subscriber dies: the kernel closes its control connection, the owner's poller sees the end, frees the slot, clears its gated bit and wakes the publisher. No timeout is involved.
- A joiner dies mid-join: before sending JOIN, the owner reads an end where JOIN should be and frees the slot. After JOIN it is already a member, so its end frees the slot and clears its gated bit (set before WELCOME); until the poller sees that end, a gated joiner holds the publisher back.
- A subscriber writes garbage: every index it can influence is bounded (
max_index, 263), every cell checked before a copy, every bit filtered through the owner's roster; the publisher's positions come from its own memory. A bad value makes the reading call fail with INVALID, never access memory outside the segment. - A subscriber stops but doesn't die: a gated one is unsubscribed after
max_stall_msand its view revoked; a lossy one simply gets lapped. - The publisher dies: each subscriber's watcher reads the end, drains what was published and reports DISCONNECTED.
The tests cover this with peers that crash at every step of the handshake and of a join (core/test_hooks.zig, in the slow tier), checked message streams to more subscriber processes than there are CPUs, and the chaos harness (devtool test chaos), which kills, pauses and restarts publishers and subscribers in a loop and checks for leaks.
6. The work queue
docs/protocol.md §10A work queue takes messages from up to 63 producers and hands each one to one of up to 64 consumers, in any of the user's processes. Each producer gets its own ring inside the queue's single segment, which every member maps; consumers take messages from any producer, and the queue promises no ordering. Each message goes to exactly one consumer, and is only delivered again, to another consumer, if the first one's process ends before acknowledging it: the owner then copies it into an extra ring, the return ring. fipc_recv acknowledges inside the call, which makes it at-most-once. For in-place receives the release is the acknowledgement: release before doing the work for at-most-once, or after it for at-least-once, in which case the work must tolerate a duplicate.
6.1Roles and objects
- The owner is the process that calls
fipc_queue_create(server.create). It claims the name as a broadcast owner does and builds theQueueup front: the whole segment with every slot, one roster per role (core/roster.zig: which slots are held, their generations and, on Windows, their wake events), one member record per slot, and the poller task, which admits joiners from then on. TheQueueis the handle the call returns (fipc_queue_t), and the handle is the queue: closing it (fipc_queue_close) ends the queue for every member and frees the name. - The owner isn't a member. To send or receive, it joins its own queue with
fipc_queue_join, by name, from any thread, exactly as another process would, and its poller admits it like any other joiner. Since admission runs on the library's thread, even a single-threaded process can create a queue and then join it. - A producer and a consumer are
Endpoints of view kindsqueue_producerandqueue_consumer, each with its own mapping, itsclient.Member(itsclient.State, and aproducer.Produceror aconsumer.Consumer) and a watcher, just like a subscriber. - A member (
server.Member) is the owner's record of one slot: while a producer or consumer holds it, that member's control connection and the slot's generation. One poller per queue (pollMembers, on the sameplatform.Polleras a broadcast) admits joiners and watches every member's connection, so a queue costs one thread no matter how many members join. The lock of the queue'sOwner(core/owner.zig, the type a broadcast and a service use too) guards the rosters and member records, and the data path never takes it.
queue/server.zig:Queue,create,waitForMembers,cancel,close;core/members.zig:Members,Member;queue/client.zig:State,newMember,producerOf,consumerOf;core/roster.zig:Roster
6.2The queue's segment: a ring per producer
The layout (segment.describe) starts with a header, one 128-byte line each for the description, ended, the ready bitmap (bit p: "ring p may have a message") and the idle bitmap (bit c: "consumer c is parked"). Next comes one 128-byte slot per consumer, holding its park flag sleeps and its claim. Then one region per ring: a control block (tail, head and the room flag room_sleeps, each on its own line), the cells (one per 64 bytes of capacity) and the data ring. The producers' rings come first, then the return ring, ring number max_producers, whose producer is the owner. Consumers find it through bit max_producers of the same ready word, so the message path pays nothing extra for it; that's also why a queue has at most 63 producers. Every region is a whole number of pages. Nothing is read-only in any view, because a consumer claims a message by writing to its ring's cell.
A cell's state names its message. Message i of a ring uses cell i mod cell_count, and the cell's state word holds the message's index plus one in its top 56 bits, the claiming consumer in bits 2 to 7, and a phase in bits 0 and 1: published, claimed or done. Indices count a ring's messages over its whole life, across every producer that has held its slot, so a state never comes back once it has been left: a compare-and-swap expecting message h's state can never succeed on another message's (no ABA problem). A cell holds only the library's bookkeeping; the payload lives in the producer's data ring, rounded up to 64 bytes, and never wraps.
pub const QueueCellState = packed struct(u64) {
phase: QueuePhase,
claimer: u6,
tag: u56,
};
pub const QueuePhase = enum(u2) { none = 0, published = 1, claimed = 2, done = 3 };queue/segment.zig:describe,create,attach,Rings;core/ring_set.zig:RingSet,Layout,published,claimed,done,validRing;wire_protocol.zig:QueueHeader,QueueConsumerSlot,QueueRingControl,QueueCell,QueueCellState,QueueIntent
6.3A send and a claim, step by step
The producer (Producer.send: reserve, a copy, publish) writes only its own ring:
- Room: the next cell and enough data-ring space for the payload must be free (
fits); if not, it reclaims, then waits for room. - Copy the payload, write the cell's payload fields, then the state
published(i)with release. - Publish
tail = i + 1(sequentially consistent), then checkready: if the ring's bit was clear (consumers had seen the ring empty), set it and wake one idle consumer. Finally, checkended.
A consumer (Consumer.recv → take) looks through the rings whose ready bit is set, starting after the ring it last took from, so consumers spread out across the rings:
- Load
tail, thenhead(validRing: they must be at most a ring of cells apart and below the index bound, or the ring is treated as corrupt and skipped), then the state of the cell athead. - If the buffer is shorter than the largest possible message, check the cell's
lenfirst: a message that doesn't fit returns TOO_LARGE with its length, and stays in the queue for any consumer. - Claim: record the intent in its own slot (
claim, release), then compare-and-swap the state frompublished(head)toclaimed(head, me). Exactly one consumer wins, and the winner advancesheadwith another compare-and-swap. A consumer that finds a cell already claimed or done helps out by movingheadpast it. Claims always followhead, andheadonly passes a message once it's claimed. - Copy the message (or hold it in place until
fipc_recv_release), then storedonewith release, and wake the producer if it is sleeping for room.
Reclaim. The producer frees the space of its consecutive done messages up to head, using its own record of payload positions (never the cells), and stops at the first message not yet done. So a message held in place holds back its producer's space, but not the other messages: consumers keep taking those until the producer is a full ring ahead of the held one.
queue/producer.zig:Producer;core/sender.zig:Sender.send,sendAcquire,sendCommit,reserve,publish,reclaim,roomqueue/consumer.zig:Consumer.recv,recvAcquire,release;core/taker.zig:Taker.take,takeFrom,afterClaim,finish
6.4Ready bits, parks and one wake at a time
Two handshakes, with every access sequentially consistent, make sure no work is ever stranded:
- The ready bit. A consumer that finds a ring empty, or takes its last message, clears the ring's bit and then looks at the ring again, setting the bit back if a message has arrived. Together with the producer's store of
tailand load ofready, that's a store-then-load pair on each side: either the consumer sees the new message, or the producer sees the bit clear and sets it. Either way, a ring with a message ends up with its bit set by someone who then claims the message or wakes a consumer. - The park. A consumer with nothing to take spins, then swaps its
sleepsflag to 1, sets its idle bit, and checksready, its cancel request, its phase andended; it only sleeps if none of them changed. A waker takes one idle bit (with an atomic AND), swaps that consumer'ssleepsto 0, and wakes it if the swap returned 1.
| OS | A consumer parks on | A waker wakes it with |
|---|---|---|
| Linux, macOS | its own slot's sleeps (a non-private futex, or os_sync_wait_on_address SHARED) | a wake of one waiter of that word: never a wake-all |
| Windows | its slot's named auto-reset event (-mq-consumer-<c>) | SetEvent on that slot's event |
A claim that leaves more messages behind wakes one more idle consumer (chaining); that's for parallelism only, not correctness. A producer waiting for room sleeps on room_sleeps in slices from 1 ms doubling to 10 ms, like the broadcast's publisher: a consumer's done is a release store and its check of the flag a relaxed load, so a producer can miss a done that races with its announcement. The slice caps that delay; the exact pair would cost a locked instruction per message.
core/taker.zig:clearReady,wait,announcePark,spin,interrupt;core/ring_set.zig:wakeOneIdle,wakeTaker,wakeSender,rescan,Wakes;core/sender.zig:announceRoomSleep- Litmus tests: "a publish against a consumer's clear of the ready bit" and "a consumer's park against a publish's wake"
6.5Joining, slots and draining
The owner admits joiners on its poller just as a broadcast owner does (§5.6: no step blocks, at most 8 joiners in setup, each dropped if no JOIN arrives within 2 s of its accept), as long as a slot of either role is free. It sends OFFER (the capacity, both maximums and the segment) and reads JOIN, which carries the joiner's role; this is the first the owner learns of it. It then claims a free slot for that role, round robin, with a new generation, resets the slot's words, counts the member and sends WELCOME with the slot. If no slot of that role is free, it drops the joiner, which retries within its own timeout. A new producer starts at its ring's tail, which must equal head.
A producer's slot is only reused once its ring has settled (ringSettled): every message the previous producer sent has been claimed (head = tail), and none is still held by a consumer (no consumer's claim points to a cell that is still claimed). Until then the slot is draining; the poller rechecks it before listening, every 5 ms while no slot is free. The next producer continues the ring's indices, so nothing its predecessor left behind can be mistaken for one of its own messages.
queue/server.zig:pollMembers,acceptJoiner,onJoinerReady;core/members.zig:roomFor,claimSlot,start,settleDrainingLocked;queue/client.zig:join,attemptJoin;queue/frames.zig;Rings.ringSettled
6.6Ends: the return ring, and the queue's end
- A member's end. Nothing travels on a member's connection after WELCOME, and the owner never closes a live member's connection while the queue runs, so when that connection ends, the member can no longer be writing to the segment. For a consumer, the owner reads its
claim; if that cell still holdsclaimed(index, c)(heldClaim), it returns the message. It copies the message into the return ring without waiting, publishes it there as a producer would, and only then swaps the original to done and movesheadpast it, so the producer can't reuse the original's space until the copy exists. If the return ring is full, the claim stays, and so does the consumer's slot, until a later tick of the poller finds room. A held message from the return ring itself moves straight into the owner's scratch FIFO (up tocapacitybytes, allocated as it fills), and later ticks publish it back from there. Together the return ring and the FIFO never hold more than the capacity, so no copy ever waits on a consumer that has ended. When a producer ends, its slot starts draining. A member that sends an unexpected frame, or whose connection fails some other way, might still be running, so its slot is retired for the life of the queue and its connection kept open. - The queue's end. Closing the queue (
fipc_queue_close,server.close) stops the poller, publishes whatever returns fit, storesended, closes the joiners' connections and the listening handle (freeing the name at once), and then every member's connection. If the owner's process dies instead, each member's watcher reads the end of its connection and storesendeditself. From then on producers return DISCONNECTED, and consumers take what's left, in passes whose loads are sequentially consistent, before returning DISCONNECTED too. A send that returned OK is always taken by a draining consumer: the producer storestailand then loadsended, the consumer loadsendedand thentail, so at least one sees the other. A send that returned DISCONNECTED right as the queue ended may still have been delivered. - A member's own failure (an unexpected frame from the owner) makes it return DISCONNECTED immediately, without setting
ended: the queue carries on for everyone else. The member's watcher keeps the connection open until the member is closed, so the owner only frees the slot once the member can no longer write.
The tests cover this with a checksummed stress test of producers and consumers on threads (including more threads than cores); litmus tests of the claim, the ready bits, the park, the end and cell reuse; producer, consumer and owner processes killed at each step of a join (in the slow tier); the chaos harness's queue-* scenarios, which kill and pause owners, and producers and consumers holding messages, and end every cycle by accounting for every message sent (none lost but the one a killed consumer held, none delivered twice); and the fuzz harness's forged owners, forged joiners and corrupt members.
queue/server.zig:pollMembers,onMemberReady,keepsSlot,close;core/members.zig:onMemberReady,endSlotLocked;core/ring_set.zig:heldClaim,finishHeld;queue/client.zig:readOwnerFrames,endMembership,leave- The litmus test "the end against a send: a send that returned OK is drained by the consumer that saw the end"
queue/returns.zig:Returns.recover,tick,Scratch; the litmus tests "a return, then a claim" and "a return's copy, then the original's done, then the producer's reuse"
7. The service
docs/protocol.md §11A service takes requests from up to 64 clients and hands each one to one of up to 64 servers, in any of the user's processes; the server that took a request replies to it, and the reply goes back to the client that sent it. A server waits for the requests of every client at once, in one fipc_recv, and the servers share the load. Underneath, a service is a work queue whose producers are the clients and whose consumers are the servers, and each request carries the room for its reply: a client's send reserves the reply's room in its own ring, just before the request, so a server never waits for a client. A client receives the answers in the order of its requests, so a request carries no id: its n-th answer is its n-th request's. Nothing is ever sent twice. A request whose server ends before replying is answered with FIPC_ABANDONED, and the client decides whether to send it again.
7.1Roles and objects
- The owner is the process that calls
fipc_service_create(server.create). It claims the name as a queue's owner does and builds theServiceup front: the whole segment, a ring for every client and a slot for every server, the owner's record of its members (core/members.zig: a roster per role, the draining client slots, one record per slot, the count of each role), and the poller task, which admits joiners from then on. TheServiceis the handle the call returns (fipc_service_t), and, as with a queue, the handle is the service: closing it ends the service for every member and frees the name. - The owner isn't a member. To call or to serve, it joins its own service with
fipc_service_join(FIPC_CLIENTorFIPC_SERVER), by name, from any thread, and its poller admits it like any other joiner. Since a reply never waits, one thread can create a service, join it as a client and as a server, and send, receive the request, reply and receive the reply in a row. - A client and a server are
Endpoints of view kindsservice_clientandservice_server, each with its own mapping, itsclient.State, a watcher, and its side of the data path on cache lines of its own: a client'scaller.Caller(its requests, aSenderof the ring set, and itsReplies), a server'sresponder.Responder(aTakerof the ring set, the request it holds, and its replying thread's state). No new data call exists: a client'sfipc_sendis a request and itsfipc_recvthe next reply; a server'sfipc_recvis the next request of any client and itsfipc_sendthe reply.fipc_max_pieceis the service'smax_message, the owner's choice, from 1 byte to a quarter of the capacity, for requests and replies alike. - Shared with the queue. The rings, the cells, the bitmaps, the claim, the park and the owner's member bookkeeping are the work queue's code, generic over a shape: a kind's wire types, roles, limits and wake names.
core/ring_set.zig'sRingSet,core/sender.zig,core/taker.zigandcore/members.zigeach have two instances, the queue's and the service's, and the service's differences are switched at compile time (replies, a taker'sdrains), so the queue's code is unchanged by them.
service/server.zig:Service,create,waitForMembers,cancel,close;service/client.zig:State,newMember,viewKind,callerOf,responderOf;core/calls.zig's switch, which sends a client's and a server's calls thereservice/caller.zig:Caller,Replies;service/responder.zig:Responder,Held,Replier
7.2The service's segment: a ring per client
The layout (segment.describe) is a queue's with the clients in the producers' place and no return ring: a header (the description, ended, ready with bit c for client c's ring, idle with bit s for a parked server: a 128-byte line each), one 128-byte slot per server (its park flag sleeps and its intent claim, a queue consumer's slot), then one region per client: its ring's control block, its cells (one per 64 bytes of capacity) and its data ring. Unlike a queue's, the cells are spread: request i's cell is i's low bits rotated left by 2, so consecutive requests' 32-byte cells are 128 bytes apart and a client publishing its next request never takes the line of the one a server is answering. The control block has one more line than a queue's: besides tail, head and room_sleeps, the client's receiving thread sleeps on reply_sleeps. One bit per client in one 64-bit ready word is why a service has at most 64 clients, and one per server in idle why it has at most 64 servers. Nothing is read-only in any view: a server claims in the cells and writes its reply into its client's data ring.
A request's room holds its reply. The client reserves R, the service's max_message rounded up to 64 bytes, and then the request's length rounded up to 64, in one piece that never wraps: [reply room][request]. The cell names the request's position, and the reply's room is the R bytes just before it. A cell is a queue's cell (its first 24 bytes are the same, so the claim is the same code) with two more words that the server writes before its done: the reply's length and the outcome, replied or abandoned.
pub const ServiceCell = extern struct {
state: Atomic(u64), // a QueueCellState: published, claimed, done
payload_position: Atomic(u64), // the request's; its reply's room is just before it
len: Atomic(u32), // the request's length
reply_len: Atomic(u32), // the reply's, written by its server before the done
outcome: Atomic(u32), // a ServiceOutcome: none, replied, abandoned
reserved: u32,
};The segment is round4K(512 + 128 × max_servers) + max_clients × (round4K(512 + capacity / 2) + capacity) bytes, created whole at the create: 12 KiB for a 4 KiB service of one client and one server, about 6.3 MiB for 64 KiB rings, 64 clients and 8 servers. The magic is FIPCSVC1, the frames are of kind 3, and the roles, 5 and 6, go on from the queue's, so no kind's joiner is taken for another's. Its trust model is a queue's (§6.2): a buggy member can disturb the others' requests and replies, but every word that indexes or sizes anything is checked where it is read. A server checks a request's position and length before it writes anything, so a corrupt client can make it write only inside that client's own data ring; a client reads a reply only at the position in its own record, never at one a cell names.
service/segment.zig:describe,valid,create,attach,Rings(the service's shape:replies, the wake suffixes);core/ring_set.zig:RingSet,Layout,replyRoom,validPiecewire_protocol.zig:ServiceInfo,ServiceHeader,ServiceServerSlot,ServiceRingControl,ServiceCell,ServiceOutcome,ServiceRole,service_magic
7.3A request and its reply, step by step
i of client c's ring has its own cell, spread from its neighbours' (drawn in request order here). Servers may answer a client's requests in any order (here i + 1 first); the client still receives them in order, since it waits for the done of its request next.- Send (
fipc_send, orfipc_send_acquireandfipc_send_commitfor a request written in place). The client's sending thread is the ring set's sender, a queue producer's code: it reservesRplus the request, waiting for room if it must (the only wait on the request's path), writes the request at the reservation's start plusR, and records the reservation's start in its own position record. Then the cell's fields (withreply_lenandoutcome0),published(i)with release,tail = i + 1sequentially consistent, the ready bit and a wake, and last the look atended. - Take (a server's
fipc_recvorfipc_recv_acquire). The server's receiving thread is the ring set's taker: the pass over the clients' rings, the look atlenbefore a claim into a short buffer, the intent, the compare-and-swap toclaimed(i, s)and the move ofheadare a queue consumer's (§6.3), so servers take each client's requests in the order it sent them. A claimed request whose position or length doesn't describe a request of the ring is a corrupt peer's: it is answered as abandoned, and the receive returns INVALID. Otherwise the server holds it, one at a time: a receive while it holds one returns INVALID and changes nothing, andfipc_recv_releasedoes nothing on a server. The held request is published with release and read with acquire, so a server may receive on one thread and reply on another. - Reply (a server's
fipc_send, orfipc_send_acquireandfipc_send_commit, which hand out the reply room itself). The reply, 1 tomax_messagebytes, goes into the room atpayload_position − R; thenanswerstoresreply_lenandoutcome= replied, thendone(i, s)sequentially consistent, then swaps the ring'sreply_sleepsto 0 and wakes the client if it read 1; last the server loadsended. After the done it touches nothing of the request. A reply never waits, and a reply to a client that has left returns OK: the server's connection is to the service, not to that client. A request held in place stays valid until the reply, since its room is the client's until the client consumed the answer. - Receive (a client's
fipc_recvorfipc_recv_acquire). The client's receiving thread keepsnext, its count of the answers it consumed, and waits for the done of requestnext, whichever server stored it. Replied, with a length of 1 tomax_message: the reply is at the start of the request's reservation, which its own position record gives. Abandoned: the receive returnsFIPC_ABANDONED, with a length of 0. Anything else is a corrupt server's answer: INVALID. Each consumes the answer by storingnext + 1with release; a reply held in place is consumed at its release or the next receive. A receive that times out, or a buffer too short (TOO_LARGE), consumes nothing: the next receive returns that answer.
Reclaim. The sending thread frees a request's room, its reply's included, only below the smaller of head and next, which it loads with acquire: so a request's room is reused only after the receiving thread has read the reply out of it. A client that sends without receiving waits for room its own replies hold: with one thread, that send times out. Several requests may be in flight at once, as many as the ring has room for, and the servers work on them in parallel.
service/caller.zig:Caller,Replies(recv,recvAcquire,release,wait,announceSleep);core/sender.zig:Sender.send,sendAcquire,sendCommit,reserve,publish,reclaim(bounded byconsumedwhere messages carry replies)service/responder.zig:Responder.recv,recvAcquire,send,sendAcquire,sendCommit,Held;core/taker.zig:Taker.take,takeFrom;core/ring_set.zig:answer,replyRoom
7.4Waits and wake-ups
- Ready bits, parks and one wake at a time are the work queue's (§6.4), with the servers in the consumers' place: a client that publishes onto a ring that looked empty sets its bit and wakes one idle server, and a server parks on its slot's
sleepsonly after it set its idle bit and saw no ready bit. - The reply's wake is exact, since it is a synchronous call's hot path. A server stores the done (sequentially consistent), then swaps the client's
reply_sleepsto 0 and wakes it if it read 1. The client, with no answer yet, swaps the flag to 1, then loads its request's state, its requests, its phase andended(all sequentially consistent), and sleeps a slice at most only if none changed. Either the client sees the done, or the server's swap sees its 1. Several servers may swap the flag, since they answer a client's requests in any order; a wake for a later request only makes the client look and sleep again. The owner's abandon wakes the client the same way. Each reply costs the server two locked instructions on x86-64, the done and the swap. - The room's wake is inexact, as a queue producer's. A client waiting for room swaps
room_sleepsto 1, reclaims again and sleeps in slices from 1 ms doubling to 10 ms. Only its own receiving thread frees room, so only it wakes the sending thread, after it consumed an answer, with a relaxed look at the flag; the slice bounds a missed wake. Room waits are rare, and the exact pair would cost a locked instruction per reply. - Own interrupts (a cancel, the end) are stored first, then the member swaps its own flags to 0 (a client's two, a server's one) and wakes each that read 1.
| OS | Sleeps on | Woken with |
|---|---|---|
| Linux, macOS | the word itself: a server's sleeps, a client's reply_sleeps and room_sleeps (a non-private futex, or os_sync_wait_on_address SHARED) | a wake of one waiter of that word: each word has one sleeper |
| Windows | named auto-reset events: a server's -sv-server-<s>, a client's -sv-reply-<c> (its receiving thread) and -sv-room-<c> (its sending thread) | SetEvent: two events per client, since its two threads may sleep at once and one event could wake the wrong one |
core/ring_set.zig:answer,wakeReply,wakeOneIdle,Wakes;service/caller.zig:Replies.wait,announceSleep;service/segment.zig:client_reply_wake_suffix,client_room_wake_suffix,server_wake_suffixservice/litmus_test.zig: "a reply's done against its client's sleep: no reply's wake is lost"
7.5Joining, slots and draining
The owner admits joiners on its poller exactly as a queue's owner does (§6.5): no step blocks, at most 8 joiners are in setup, each is dropped if its JOIN hasn't come 2 s after its accept, and the role arrives in JOIN, which claims a free slot of that role round robin, with a new generation and its words reset (a server slot's sleeps, claim and idle bit; a client slot's two flags). OFFER carries the capacity, max_message, both maximums and the segment. A new client starts at its ring's tail, which must equal head, and its count of answers starts there too; a server starts at once.
A client's slot is reused only once its ring has settled (ringSettled, the queue's): every request its last client sent has been claimed or dropped (head = tail), and no server still holds one of them. Until then the slot is draining, and the poller rechecks it before it listens, every 5 ms while no slot is free. A server's slot is free as soon as its server's end has been handled.
service/server.zig:pollMembers,acceptJoiner,onJoinerReady;core/members.zig:roomFor,claimSlot,start,settleDrainingLocked;service/client.zig:join,attemptJoin;service/frames.zig:encodeOffer,decodeOffer,checkJoin,checkWelcome;Rings.ringSettled
7.6Ends: abandon, drop, and the service's end
- A server's end. As in a queue, the end of a member's connection means that member writes the segment no more. For a server, the owner reads its
claim; if that cell still holdsclaimed(i, s)(heldClaim), it abandons the request (abandonHeld):reply_len0 andoutcome= abandoned, then the compare-and-swap fromclaimed(i, s)todone(i, s), sequentially consistent, then the client's wake. The client's receive of that answer returnsFIPC_ABANDONED, and its next receive goes on to its next request. A server that stored its done just before it ended keeps its reply, since the compare-and-swap then fails; a reply half written is never shown, since the done comes after it. Whatever the server held, the owner then wakes the client its intent names (a server that ended between its done and its wake left it asleep), clears the server's idle bit and frees its slot. There is no return ring, and nothing is sent again: a request may or may not have been carried out by a server that ended, and only the client can tell whether repeating it is safe. - A server's close abandons the request it still holds itself, as the owner would, unless the service has ended.
- A client's end. Nobody waits for its answers any more, so the owner drops its requests no server took (
dropUnclaimed): each frompublishedtodone, by the servers' own compare-and-swaps, so it races their claims safely. The requests servers already hold are answered as usual, into the ring, and their replies return OK. The slot drains until they are (§7.5). - Retired slots. A member whose connection fails otherwise, or which sends a frame, may still be running: its slot is retired, as in a queue. A server retired for a frame stays watched, and its request is abandoned at its connection's end, since its client receives no later answer before that one; the owner can't abandon it at the retirement, since the server may still be writing its reply. Nothing of a retired client is dropped.
- The service's end. Closing the service (
fipc_service_close,server.close) stops the poller, storesended, closes the joiners' connections and the listening handle (freeing the name at once), and then every member's connection; if the owner's process dies instead, each member's watcher storesendeditself. From then on clients' sends and servers' receives return DISCONNECTED: servers don't drain (their taker'sdrainsis off), since no owner would be left to abandon a crashed server's request. A client receives the answers done before the end, in order, then DISCONNECTED at its first request without one, and that DISCONNECTED is final. A reply that returned OK is received by a client that receives: the server stores its done and then loadsended, the client loadsendedand then the state, all sequentially consistent, so at least one sees the other's store. A reply that returned DISCONNECTED just as the service ended may still have reached its client.
The tests cover this with a stress test of clients and servers on threads whose requests carry their client, their sequence number and a checksum (every client must get each reply once, in its requests' order); litmus tests of the reply's wake, the end's pair and the abandon; client, server and owner processes killed or crashed at each step of a join, inside a reply before and after its done, and holding a request (in the slow tier); the chaos harness's service-* scenarios, which kill and pause owners, clients and servers and end every cycle by accounting for every request (each answered once, by its reply or, only for a killed server's held request, as abandoned); and the fuzz harness's forged owners, forged joiners, and corrupt clients and servers.
service/server.zig:onMemberReady,keepsSlot,close;core/ring_set.zig:heldClaim,abandonHeld,wakeIntended,dropUnclaimed,ringSettled;core/members.zig:onMemberReady,endSlotLockedservice/client.zig:readOwnerFrames,endMembership,leave;service/responder.zig:Responder.leave- The litmus tests "the end against a reply: a reply that returned OK is received by the client that saw the end" and "an abandon, then the client's receive: a done abandon is never read as a reply"
8. Memory orderings: why each atomic has its ordering
x86-64 and aarch64The atomics are reasoned about in the language's memory model (the C/C++11 model, which Zig's atomics follow), not in any one CPU's. The exception is the broadcast's two one-way fences, which the language doesn't offer: they're inline assembly, with each CPU's own semantics (below). This matters because x86-64 is TSO (total store order): it keeps loads in order with loads and stores with stores, and only lets a later load overtake an earlier store. aarch64 reorders far more, and code that is only wrong on aarch64 passes every test on x86-64. So the broadcast's, the queue's and the service's litmus tests race each ordering against a weaker variant, and only count where that weaker variant actually fails on the machine running them. The store-buffering cases fail on x86-64 too; the seqlock, the frontier and the queue's and the service's message passing only fail on aarch64.
A quick vocabulary: relaxed (.monotonic in Zig) is atomic but orders nothing; a release store makes everything before it visible to an acquire load that reads the stored value; sequentially consistent (.seq_cst) additionally puts all such accesses into one total order, which is what store-then-load patterns need. fence.zig adds two one-way fences: on x86-64 both are just compiler barriers; on aarch64 they're dmb ishst and dmb ishld, cheaper than a full dmb ish.
| Access | Ordering | Why |
|---|---|---|
ring head/tail store; load | release; acquire | The bytes of a frame before its publication; the room before its reuse. |
| ring sleep flag | swap, acq_rel | All changes of one flag are totally ordered; a plain store could pass the waiter's next load (store buffering). |
Hot view: fields; view_published | monotonic; release / acquire | A data call sees no session or a complete one. |
cell seq busy store + store fence | monotonic, storeFence | A reader that saw any new field, then a load fence, sees seq busy or newer: the torn read is detected. |
cell seq final store; reader's first load | release; acquire | The payload and fields before "complete". |
frontier store + store fence; reader's load fence + load | monotonic with fences | The frontier is visible before any byte written under it; the reader's frontier load comes after its copy's loads. |
next store; armed_any load | seq_cst | Store-then-load against the subscriber's arm-then-look: one of the two must see the other. Also the join's double read. |
armed bits, armed_any, wake_gen | seq_cst | The no-lost-wake argument needs one total order over all of them. |
gated cursor on each receive; look at writer_sleeps | release; relaxed | The publisher may reuse the room only after the reads; a missed wake costs at most a 10 ms slice instead of a locked instruction per message. |
gated cursor before a sleep; writer_sleeps | seq_cst | The exact store-then-load pair, against the publisher's swap-then-look. |
sweep: the segment's gated words, then each cursor; the room's sleep: swap of writer_sleeps, then the laggard's cursor and bit | seq_cst | In one total order with the start's gated bit and loads of next (§5.6), and the publisher's half of its sleep's store-then-load pair. |
Channel.joined: the start's increment; fipc_broadcast_wait_for_members' load | release; acquire | A subscriber the count includes has its start, loaded from next before the increment, at or before the caller's next send. |
the start, the owner's (gated): first cursor; gated bit; next; final cursor | monotonic; seq_cst; seq_cst; release | The OR releases the first store to any sweep that sees the bit (§5.6). |
guard: in_segment, phase (both sides); the leave's in_segment = 0 | seq_cst; release | Store-then-load on both sides (§5.8); the call's accesses of the segment come before the watcher sees it outside. |
ended | seq_cst store before the wake | A subscriber reads wake_gen, then arms, then looks at the mark: a mark it misses comes before the bump that ends its sleep. |
Roster held and gated bits | release (owner) / acquire (publisher) | The publisher sees a slot's wake and generation once it sees the slot held. |
queue cell state: published(i); the claim's compare-and-swap | release; acq_rel | The payload and its fields before the state; the claimer reads them after its claim (§6.3). |
a consumer's claim (intent) before its claim | release | The owner that finds a consumer ended reads its intent (acquire) and acts only on a cell still claimed by it. |
queue tail store; ready load, clear, set; a consumer's loads of tail and head after its clear | seq_cst | Two store-then-load sides of the ready handshake: either the consumer sees the message, or the producer sees the bit clear (§6.4). |
a consumer's park: sleeps swap, idle bit, look at ready; a waker's take of the idle bit and swap of sleeps | seq_cst | Either the consumer sees the ready bit, or the waker sees its idle bit and its swap finds the 1: no lost wake. |
queue ended; a producer's load after its tail; a draining consumer's loads of tail | seq_cst | A send that returned OK is taken by a consumer that drains (§6.6). |
a consumer's done; the producer's reclaim loads | release; acquire | The consumer's reads of the payload come before the producer's reuse of its room. |
a reply's reply_len and outcome, then its done; the client's load of the state | monotonic, then seq_cst; acquire | The reply's bytes and fields before the done, which the client's receive reads them after (§7.3). |
a server's done, then its swap of reply_sleeps; the client's swap to 1, then its load of the state | seq_cst | Store-then-load on both sides: either the client sees the done, or the server's swap finds its 1 and wakes it. No reply's wake is lost (§7.4). |
a server's done, then its load of ended; the client's load of ended, then of the state | seq_cst | A reply that returned OK is received by a client that receives after the service's end (§7.6). |
an abandon: reply_len 0 and outcome, then the compare-and-swap from claimed to done | monotonic, then seq_cst | Message passing: the client's acquire of the done reads "abandoned", never a reply's fields (§7.6). |
a client's count of consumed answers (next); its sending thread's reclaim | release; acquire | The receiving thread's reads of a reply come before the sending thread reuses the request's room. |
| a server's held request: the receiving thread's publish, the replying thread's load and clear | release; acquire; release | A server may receive on one thread and reply on another. |
core/fence.zig:loadFence,storeFencebroadcast/litmus_test.zig,queue/litmus_test.zig,service/litmus_test.zigand the litmus test instream/ring_wait.zig- Both test tiers also run under ThreadSanitizer on Linux and macOS
9. Invariants and testing
what always holdsThe connection
- Each ring index and each ring byte has one writer; the sleep flags change only by swaps.
- A data call sees no session or a complete one.
- A kept index never says more than the shared one.
- Nothing outside a ring is read or written, whatever the peer writes.
- A receiver never sees part of a message.
- A listener serves one client at a time; a connection is set up once.
- An
acceptthat times out drops no client; nothing is left in flight. - A segment is unmapped only when no call of this side can touch it.
- No handle survives a close, and no named object survives a crash (except on macOS, where a crashed owner leaves its name's socket file and lock file behind until the name is next created).
The broadcast
- The publisher writes a cell or a payload byte only where every gated cursor it waits for has passed.
- The publisher waits for and wakes only slots it holds, and writes only where it chose.
- A lossy receive returns a message whole and unchanged, or OVERRUN.
- No wake-up is lost, neither a subscriber's nor the publisher's.
- A slot goes to a new subscriber only once its previous one can no longer write to it.
- No library call touches a revoked view.
- A bad value in the segment fails that subscriber's call, never an access outside the segment.
The work queue
- A message is claimed by one consumer at most.
- No message is lost while the queue runs: one whose consumer ended before acknowledging it is delivered again. Closing the queue drops any returns that don't fit at that moment, and the owner's process dying loses those in its scratch FIFO.
- A send that returned OK is taken by a consumer that drains after the queue's end.
- No ring with a message is left without its ready bit, and no park misses its wake.
- A slot goes to a new member only once its previous member can no longer write the segment, and a producer's slot only once its ring has settled.
- A bad value in the segment fails that call, never an access outside the segment.
The service
- A request is claimed by one server at most.
- While the service runs, every request a live client sent is answered exactly once: by its reply, or as abandoned if its server ended first.
- A client's n-th answer is its n-th request's.
- No reply's wake is lost, and a reply that returned OK is received by a client that receives.
- A server writes only its request's reply room, inside its client's data ring, and never waits for a client.
- A client reuses a request's room only after it consumed the answer.
- A client slot goes to the next client only once its ring has settled.
How it is tested
- A conformance suite (
tests/zig) calls the library only through its exported C API: single-process tests on every change, and multi-process tests and races in a slower tier, on Linux, Windows and macOS. Both tiers run again under ThreadSanitizer on Linux and macOS. - Litmus tests race each memory-ordering argument against a deliberately weakened variant, and only count where the weak variant really fails on the machine (§8).
- Crash points: test peers crash at every step of a handshake and of a join, in every role (R4).
- A fuzz harness (
tests/fuzz) plays a corrupt peer against the control connection and the rings (R5). - A chaos harness kills, pauses and restarts publishers, subscribers, producers, consumers, clients, servers and owners in a loop, and checks for leaks, lost messages and duplicates, and that every request is answered once, by its reply or as abandoned (R4, R6).
- A soak test churns connections and ping-pongs messages for half an hour, and checks that resources stay flat.
- A cross-language matrix runs every language's example server against every language's client, and the same for broadcasts, queues and services (R7).
10. Trade-offs and known limits
what the design gives upEach requirement was met at some cost. These are the main decisions, what else could have been done, and what each one gives up:
| Decision | Instead of | Why | Cost |
|---|---|---|---|
| A dead peer is reported by its control connection closing | heartbeats and timeouts | Immediate on exit or crash, nothing to tune, no background traffic (R4). | A hung peer is never detected, and a crash that a debugger or crash handler holds is reported only once the process really ends. |
| Messages travel through shared memory | the socket that is already there | No system call or kernel copy per message (R1). | The peer can write anything into the segment, so every read is checked (R5), and waking a sleeper needs its own careful protocol (§4.6). |
| A waiting side spins, then sleeps | always sleeping, or always spinning | A busy pair never enters the kernel; an idle one uses no CPU. | Up to 4,096 pause hints of CPU per wait before sleeping, and a receiver that did fall asleep takes tens of microseconds to wake (Performance). |
| Setup runs on the caller's thread | a background accept thread | Errors and timeouts belong to the call that caused them, and a stream starts no hidden threads (R3). | accept and connect block their thread for the whole handshake, so a client and its server must run on different threads. |
| One client at a time per stream | a multi-client server | Each connection gets its own segment and stays single-producer, single-consumer, the fastest case. | Serving several clients at once takes several names, or a broadcast, a work queue or a service. |
| Peers are trusted not to be malicious | treating the peer as hostile | Checks are cheap; real isolation would put the kernel back on the message path. | A buggy peer can lose, delay or corrupt the delivery of messages, though never crash or hang this process. In a broadcast, a queue or a service, any member can disturb the others. |
| Gated subscribers hold the publisher back | unbounded buffering | Nothing is lost, in fixed memory. | One slow gated subscriber slows the publisher for everyone until max_stall_ms unsubscribes it; a whole process that is paused can't be unsubscribed. |
| A queue redelivers only when a consumer's process ends | acknowledgement timeouts | No timeouts to tune, consistent with R4. | A consumer that hangs while alive keeps its message, and work acknowledged after it is done may run twice. |
| A service's replies come back in the order of each client's requests | request ids, and replies in the order they complete | No id and no new call: a client's send is a request and its receive the next reply. | Head-of-line blocking per client: a slow request holds back the replies to that client's later ones, which may already be done. |
| A request reserves its reply's room when it is sent, in its client's own ring | a reply ring that servers share, or one per client and server | A server never waits for a slow client, and the memory grows with the clients alone. | Each request in flight holds max_message, rounded up to 64 bytes, more of its client's ring until its reply is received. |
A request whose server ends is answered FIPC_ABANDONED | sending it again to another server | At most once: the client decides whether a request with side effects may be retried. | The client must handle the abandon; a server that hangs while alive keeps its request, and its client waits within its own timeouts. |
| At most 64 clients and 64 servers per service | a wider ready map | One ready bit per client ring in one word, as in the queue: one load per pass, one Dekker pair per park. | More clients take more services, or a client per thread pool rather than per thread. |
Watchers run on Zig's std.Io.Threaded | hand-written threads per OS | One concurrency model on every OS. | Size: it is about 217 KB of the stripped Linux library's code. |
| The library stays loaded (Linux, macOS) | unloading on dlclose | Its fork handlers can't be unregistered (§4.9). | dlclose never unloads it. |
Known limits
The table gives the main costs. This is every limit the design knows of:
- A crash that a crash handler or a debugger holds is reported only when the process ends; a hung peer isn't detected.
- Peers are one user; on Linux they share a network namespace (abstract sockets are per namespace).
- Under an evented
Ioa ring wait blocks its carrier thread for up to a slice, andacceptandconnectblock theirs for the whole setup (their waits are the OS's, on the calling thread); a broadcast's, a queue's or a service's poller blocks one for its life (epoll_pwait,kevent, an alertable wait). - Linux, macOS: the library is never unloaded (it keeps itself loaded: its fork handlers stay registered). A forked child keeps its inherited mappings until it closes them, execs or exits, and a host that forks continuously could starve a listener (each fork during an accept drops that client).
- A listener, connection, publisher, queue or service that is never closed keeps its name and its peers' connections alive.
- Every subscriber's process can write the second region of a broadcast's segment (the wake words, the bitmaps, every slot's cursor): a buggy subscriber can make the publisher wait for nothing (within its sends' timeouts), delay or spuriously end other subscribers' sleeps, let the publisher overwrite a gated subscriber's messages early, or set a sleeping gated subscriber's cursor back so that the publisher sees it stalled and unsubscribes it. It can't make the library access memory outside the segment, nor make the publisher write anywhere but where it chose.
- A gated subscriber whose subscription ends without the publisher's close (its publisher's process ended, or the publisher dropped it) no longer has a message it held in place protected; and between the owner retiring its slot and its watcher reading the end, a subscriber still running reads in place what the publisher no longer protects.
- A retired slot comes back only with a new broadcast: a broadcast whose slots are all retired admits nobody.
- A gated subscriber whose whole process is paused can't be unsubscribed; a joiner after the publisher's close waits out its timeout (or for a new broadcast on the name).
- A work queue redelivers only on a consumer's process ending: a consumer that hangs while its process runs keeps its message (there are no timeouts), and the messages waiting in the owner's scratch FIFO end with the owner's process. A consumer that acknowledges after its work (in place, released after it) may see its message's work done twice.
- Closing a work queue's handle ends the queue for every member: a binding whose queue object a garbage collector may close must keep it referenced while the queue is in use.
- A consumer that holds a message in place, or whose process is paused holding one, holds that message's producer back once the producer is a ring ahead of it; the owner can't take the message back from a live consumer.
- A retired queue slot comes back only with a new queue; a producer slot stays draining while a consumer holds one of its messages.
- Every member's process can write the whole of a work queue's segment. A buggy member can drop or skip messages, wake members early or late, or end the queue; a cell left with a state that isn't its message's (a forged state, or a message skipped past) stops its producer's reclaim there for good, so that producer's sends time out from a ring later on (logged once). It can't make the library access memory outside the segment, nor wait past a call's timeout.
- A service takes at most 64 clients and 64 servers: one ready bit per client ring in one word, and one idle bit per server.
- A service never sends a request again: an abandoned request may or may not have been carried out (a server that ends inside
fipc_recv, after its claim, abandons a request the application never saw), and the client decides whether to send it again. A server that hangs while its process runs keeps its request (there are no timeouts): its client's receives time out from then on, since its later answers come after that one, and the request's room stays held. - A server whose slot was retired for a failed read, or whose connection the owner couldn't watch, and which then ends leaves the request it held unanswered: the owner never sees that end. Its client waits for that answer within its own timeouts, as for a hung server.
- A client receives its answers in the order of its requests: one slow request holds back the receipt of the answers to the requests it sent after it. A client that sends without receiving fills its ring with room its own replies hold, and with one thread its sends then time out.
- Closing a service's handle ends the service for every member: a binding whose service object a garbage collector may close must keep it referenced while the service is in use.
- A retired service slot comes back only with a new service; a client slot stays draining while a server holds one of its requests.
- Every member's process can write the whole of a service's segment. A buggy member can drop requests, answer them early or wrongly (a forged outcome or reply length makes that receive INVALID, and the answer counts as received), wake members early or late, or end the service. It can't make the library access memory outside the segment, nor make a server write anywhere but its request's reply room, nor wait past a call's timeout.
11. Code map: the source tree, its layers and its types
src/zig, every fileReading the code
The library is about 8,000 lines of Zig in src/zig, not counting comments, blank lines or tests. It builds into one shared library that exports the 28 C functions declared in include/fipc.h. Most files open with a long //! doc comment explaining why the code below it is correct. Read those first.
A reading order in ten sittings:
- The contract:
include/fipc.h, thenc_api.zig(each export checks its arguments and calls one function),wire_protocol.zig, and the top ofendpoint.zigdown toEndpoint. - The handshake:
stream/listener.zig,core/control.zig,wire.zig,names.zig, then a stream connection'sstream/server.zig,client.zig,frames.zig(§4.2). - The watcher, cancel and close:
control.runWatcher,endpoint.teardown(§4.8). - The segment and one OS:
core/shared_memory.zig,stream/segment.zig,platform.zig,platform/linux/linux.zig(§4.3). - The other OSes:
platform/windows/andplatform/macos/, andplatform/contract_test.zig, the tests every OS passes. - The process:
core/process_state.zig(§4.9). - The data path:
core/calls.zig's data calls,core/wait.zigandcore/copy.zig, thenstream/ring.zig,ring_wait.zig,data_path.zig(§4.4 to §4.7). - The broadcast:
broadcast/segment.zig,writer.zigandreader.zig(their top comments first),roster.zig,core/fence.zig,core/admission.zig,broadcast/server.zigandclient.zig(§5). - The work queue:
queue/segment.zig,core/ring_set.zig,core/sender.zigandcore/taker.zig(their top comments first),queue/producer.zig,consumer.zig,server.zigwithcore/members.zig,client.zigandframes.zig(§6). - The service:
service/segment.zig,caller.zigandresponder.zig(their top comments first), thencore/ring_set.zig'sanswer,abandonHeldanddropUnclaimed,service/server.zig,client.zigandframes.zig(§7).
Two Zig conventions are worth knowing. An extern struct has C layout, fields in declaration order; every shared-memory and wire type is one, and wire_protocol.zig checks each offset at compile time. A noinline function is a slow path kept out of line on purpose so the per-message code around it stays small (reserveSlow, sleepUntil, skipToMessage, takePieces).
Every file
Folders, then files, then the main types in each. Each file name links to the source; each § links to the part of this page that explains it. shared marks a type whose bytes live in shared memory or on the control connection: its layout is the protocol, and changing it changes the wire. Every other type is process-local.
src/zig/
├── c_api.zig the C exports: check arguments, call one function, map errors §11
│ Result (fipc_result_t), checked against fipc.h with each export
│ Result.ofDataError, ofLifecycleError: errors to result codes
├── root.zig the Zig module "fastipc": Listener, Queue, Service, Conn
├── wire_protocol.zig wire layouts, checked at compile time §4.3
│ ControlHeader, ControlStream*,
│ ControlBroadcast* shared (wire)
│ SegmentHeader, RingIndices,
│ RingWriter, RingReader shared
│ FrameHeader shared
│ BroadcastInfo, BroadcastHeader, BroadcastCell,
│ BroadcastShared, BroadcastSlot shared
│ ControlQueue* shared (wire)
│ QueueInfo, QueueHeader, QueueConsumerSlot,
│ QueueRingControl, QueueCell shared
│ ControlService* shared (wire)
│ ServiceInfo, ServiceHeader, ServiceServerSlot,
│ ServiceRingControl, ServiceCell shared
│ the versions and magics, the roles
├── platform.zig the OS contract, bound at compile time §4.6
├── platform/ each OS's side of the contract
│ ├── linux/
│ │ └── linux.zig abstract sockets, memfd, futex
│ ├── windows/
│ │ ├── windows.zig sections, named events, the cancel signal
│ │ ├── pipe.zig named pipes: claim, connect, accept, frames
│ │ ├── poller.zig the poller: ReadFileEx reads, completion routines
│ │ └── win32.zig the raw Windows API that std lacks
│ ├── macos/
│ │ ├── macos.zig POSIX shm, kqueue, os_sync
│ │ ├── rendezvous.zig socket files and their lock files: claim, connect
│ │ ├── darwin.zig the raw macOS API that std lacks
│ │ └── test_sys.zig the fork tests' system calls on macOS (tests only)
│ ├── posix.zig what Linux and macOS share: frames, SCM_RIGHTS, poll, mmap
│ └── contract_test.zig the contract's tests, run on every OS (tests only)
├── core/ what every kind of connection shares
│ ├── endpoint.zig Endpoint: Hot, RecvLine, SendLine; ViewKind, Phase, Requests §4.5
│ │ create, start, cancel, close, teardown
│ ├── owner.zig Owner: what the owners of broadcasts, queues, services share; §5.1
│ │ init, startPoller, stopPoller, deinit; CountWaiters, MemberEnd
│ ├── admission.zig Admission, Joiner: an owner's joiners (every kind but a stream) §5.6
│ │ owner's poller; listenWhile, accept, offer, take, handOver, drop
│ ├── control.zig frame I/O, Backoff, retrying, connectOnce, runWatcher §4.8
│ │ claimName, acceptClient, dropClient, nextListening
│ ├── calls.zig the calls that depend on ViewKind: one switch §4.7
│ ├── wire.zig the 64-byte control frame: magic, the header §4.1
│ ├── names.zig valid names, rendezvous addresses, Object (Windows names) §4.1
│ ├── shared_memory.zig
│ │ Session; createUnderRandomId, openOffered §4.3
│ ├── wait.zig Deadline, spin_iters, stream_pace, the slices, msLeft §4.6
│ ├── copy.zig copy, copyLong: a piece into or out of shared memory §4.4
│ ├── fence.zig loadFence, storeFence §8
│ ├── payload_ring.zig
│ │ place, Reservation: a sender's payloads in its data ring
│ ├── position_record.zig
│ │ PositionRecord: a sender's own record of its payloads
│ ├── roster.zig Roster: an owner's slots; claim, hold, release, retire §5.6
│ ├── ring_set.zig RingSet: a queue's or a service's rings, cells, bitmaps; Layout,
│ │ Wakes; answer, abandonHeld, dropUnclaimed (replies) §6.2
│ ├── sender.zig Sender: send, publish, reclaim, room (a producer, a client) §6.3
│ ├── taker.zig Taker: take, clearReady, park (a consumer, a server) §6.4
│ ├── members.zig Members: a queue's or service's two roles; claimSlot, start §6.5
│ ├── process_state.zig
│ │ Ref (the shared Io), Handles and Slot, the fork registry §4.9
│ ├── log.zig "[LEVEL] file:line: message" on stderr; hold under the handle lock
│ └── test_hooks.zig the tests' hooks: crash steps, holds, strays (tests only)
├── stream/ SPSC: a connection, a ring each way; no locks, no allocation
│ ├── listener.zig Listener, Pending; claimName, cancel, close, dropPending §4.2
│ ├── server.zig create, accept: SEGMENT, READY, the hand-over §4.2
│ ├── client.zig connect: handshake, attempt, awaitSegment §4.2
│ ├── frames.zig SEGMENT and READY: encode and check §4.1
│ ├── segment.zig the header and the two rings: size, create, attach §4.3
│ ├── data_path.zig send, recv, the zero-copy calls; Piece, Space, FirstPiece §4.4
│ ├── ring.zig Ring (a view, built per call), frameLen §4.4
│ ├── ring_wait.zig until, sleepUntil, announceSleep, publishHead, publishTail §4.6
│ └── wake_diagnostics.zig the ring waits' lost-wake reports (tests only) §4.6
├── broadcast/ pub/sub: the broadcast
│ ├── server.zig create; Channel, Member; pollMembers, onStall, closePublisher §5.1
│ ├── client.zig join; endSubscription, unsubscribe, leave §5.8
│ ├── frames.zig OFFER, JOIN, WELCOME, UNSUBSCRIBE(D): encode and check §5.6
│ ├── segment.zig describe, Layout; create, attach; the slot bitmaps §5.2
│ ├── writer.zig Writer, Scan, Stall; writeCell, startSlot §5.3
│ ├── reader.zig Reader; readCell, seqlockCopy §5.4
│ └── roster.zig Roster: the owner's record of its slots §5.1
├── queue/ MPMC: the work queue
│ ├── server.zig create; Queue, Member; pollMembers, waitForMembers, close §6.1
│ ├── client.zig join; State; readOwnerFrames, endMembership, leave §6.6
│ ├── frames.zig OFFER, JOIN, WELCOME: encode and check §6.5
│ ├── segment.zig describe; create, attach; Rings, Layout (ring_set.zig) §6.2
│ ├── producer.zig Producer: the ring set's Sender (sender.zig) §6.3
│ ├── consumer.zig Consumer: recv, recvAcquire, release; a Taker §6.4
│ └── returns.zig Returns: recover, tick; Scratch, the owner's FIFO §6.6
└── service/ many clients, a pool of servers, with replies
├── server.zig create; Service, Member; pollMembers, keepsSlot, close §7.1
├── client.zig join; State; readOwnerFrames, endMembership, leave §7.6
├── frames.zig OFFER, JOIN, WELCOME: encode and check §7.5
├── segment.zig describe; create, attach; Rings, Layout (ring_set.zig) §7.2
├── caller.zig Caller: a client's requests (a Sender) and Replies §7.3
└── responder.zig Responder: a server's Taker, Held, Replier; send, leave §7.3
Tests beside the code: core/endpoint_test.zig, stream/data_path_test.zig, stream/handshake_test.zig,
broadcast/writer_reader_test.zig, broadcast/litmus_test.zig, queue/producer_consumer_test.zig, queue/litmus_test.zig,
service/caller_responder_test.zig, service/litmus_test.zig (on core/litmus.zig's harness), and test blocks in most files.Who imports whom
Arrows point at what a file imports. The two roots import each kind's server and client, and core/calls.zig for the data calls.
core/ mentions the kinds in exactly one place: the ViewKind enum, switched on once in core/calls.zig. The listener belongs to streams alone (stream/listener.zig). Accepting a client (control.acceptClient) and admitting a joiner (core/admission.zig) don't care about the kind; a stream's server and each owner's poller call their steps.
The folders import each other in cycles, on purpose, and the top comments of endpoint.zig, control.zig, calls.zig and each kind's files say why. The endpoint starts the watcher, reaches the kinds through calls.zig, and tells a listener when its accepted connection closes. The kinds' handshakes use control.zig's steps and work on the endpoint. The ring waits read the endpoint's hot line, and the endpoint's interrupt runs their wake rule. A stream's data path imports no segment code at all: each call builds its ring from the endpoint's view.
queue/ and service/ lean on the same pieces as broadcast/: the poller and per-slot wakes from platform.zig, admission from core/admission.zig and slot bookkeeping from core/roster.zig. The two share more between them: the ring set, its sender and taker, and the owner's record of its members (core/ring_set.zig, sender.zig, taker.zig, members.zig), each generic over the kind's wire types. All four kinds share core/copy.zig for copying and core/wait.zig for deadlines, spinning and sleep slices. The platform layer isn't a strict bottom layer either: platform.zig and the OS files import core/names.zig (rendezvous addresses, Windows object names), the Linux and macOS files import core/process_state.zig (to count forks around an accept), and the OS files log through core/log.zig, which in turn writes through platform.zig.
Why the code is grouped this way
| Boundary | What it isolates |
|---|---|
c_api.zig, root.zig | The two public faces. c_api.zig holds the only export fns and checks each against its prototype in fipc.h at compile time. root.zig offers the same calls in Zig terms, with error sets and Io.Timeout, on the same process-wide Io. Both are thin layers over the same modules. |
wire_protocol.zig | Every byte layout another process sees, as extern structs whose sizes and offsets are checked at compile time in every build. It imports nothing else from the library, so a layout can't come to depend on an implementation detail. |
core/ | Code that at least two kinds share, and nothing else. Most of it sets connections up or tears them down, on the caller's thread or the watcher and never on the per-message path: names, the control frame, the client's handshake steps, a stream's resumable accept loop, joiner admission for broadcasts, queues and services, cancel and close. It also holds the one switch on a connection's kind (calls.zig) and the data-path pieces several kinds use: deadlines and sliced sleeps (wait.zig), the long copy (copy.zig), the seqlock fences (fence.zig), and the ring set that the work queue and the service are both built on (ring_set.zig, sender.zig, taker.zig, members.zig, generic over a kind's wire types). An endpoint (core/endpoint.zig) is one process's end of a stream, or its place in a broadcast, a work queue or a service: fipc_conn_t in C, Conn in Zig. |
stream/ | SPSC: a connection's server and client, its frames and segment, and the per-message path, which takes no locks and allocates nothing. Its only blocking wait outside Io, the ring wait, is confined to ring_wait.zig. |
broadcast/ | Pub/sub: the owner (server.zig), subscribers (client.zig), frames, segment and data path (writer.zig, reader.zig, roster.zig). The data calls in core/calls.zig switch on view_kind once, after the session check, so supporting four kinds costs each call one well-predicted branch. |
queue/ | MPMC: the owner (server.zig), producers and consumers (client.zig), frames, segment and data path (producer.zig, consumer.zig). It shares the broadcast's owner machinery in core/: the poller, admission, the slot roster and, on Windows, the per-slot wakes. |
service/ | Requests and replies: the owner (server.zig), clients and servers (client.zig), frames, segment and data path (caller.zig, a client's; responder.zig, a server's). Its rings, claims, parks and members are the work queue's, from core/, instantiated for the service's wire types: what is its own is the reply's room, the answers and their wake, and what an end leaves to the owner (abandon, drop). |
core/shared_memory.zig, each kind's segment.zig | Segments. shared_memory.zig creates one under a random id (committing its memory up front where the OS allows: Linux, Windows) and maps one that was offered. Each kind's segment.zig defines its layout and size, and the checks a client or joiner runs before touching anything. |
platform.zig and platform/ | The OS. Nothing outside platform/ checks which OS it runs on, except where behavior really differs and no contract can hide it. Each declaration binds straight to the target OS's own function with the contract's signature, so a port that doesn't match won't compile. A few flags name the real differences: has_fork, passes_segment_handle, wakes_each_slot. |
core/process_state.zig | State that is per process rather than per object: the C API's shared Io and its reference count, every object's OS handles, the handle lock, and fork handling. |
What owns what
| Object | Has | Lives in |
|---|---|---|
stream/listener.zig Listener (fipc_listener_t, a stream's) | its Handles (listening handle, cancel signal), the setup in progress (Pending: an Endpoint and its Session while READY is awaited), a Backoff | process |
endpoint.Endpoint (fipc_conn_t) | three 128-byte lines (Hot, RecvLine, SendLine), its Handles (the control connection), its watcher task, a mutex; through Hot.view_session its Session | process |
shared_memory.Session | a platform.Mapping of the segment (on Windows with its named events), the capacity, the session id | process (it maps shared memory) |
| the segment of a connection | wire_protocol.SegmentHeader with two RingIndices (each a RingWriter line and a RingReader line), then two rings of frames (FrameHeader + piece) | shared |
ring.Ring | pointers to one direction's indices and data; built from Hot on each call, never stored | process (a view) |
server.Channel (a broadcast, owned by its publisher's Endpoint) | its Session, a segment.Layout (pointers into the segment), the Roster, the publisher's position record payload_positions, the Writer (on lines of its own), one Member per slot, its Owner (core/owner.zig: the lock, the Admission with the listening handle and 8 Joiner records, its poller) | process (the owner's) |
server.Member | one slot's record: while a subscriber holds the slot, its control connection (Handles), what the broadcast's poller read of it (platform.Watched), its generation | process (the owner's) |
a subscriber's Endpoint | its Session (its own mapping of the broadcast) and, through Hot.view_object, its reader.Reader | process (the subscriber's) |
| the broadcast's segment | BroadcastHeader (with BroadcastInfo), the cells (BroadcastCell), the data ring, BroadcastShared, one BroadcastSlot per subscriber | shared |
server.Queue (fipc_queue_t) | its Session, a segment.Layout, a Roster per role, the draining producer slots, one Member per slot (joined or retired), the count of joined members per role, its Owner (a broadcast's: the lock, the Admission, its poller) | process (the owner's) |
a producer's or consumer's Endpoint | its Session (its own mapping of the queue) and, through Hot.view_object, its client.Member: its client.State, and its Producer or Consumer on cache lines of its own | process (the member's) |
| the queue's segment | QueueHeader (with QueueInfo), one QueueConsumerSlot per consumer, then per ring (each producer's, then the return ring) a QueueRingControl, its cells (QueueCell) and its data ring | shared |
server.Service (fipc_service_t) | its Session, a segment.Layout, its Members (a Roster per role, the draining client slots, one Member per slot, the count per role), the slots' wakes (Windows), its Owner (the lock, the Admission, its poller) | process (the owner's) |
a client's or server's Endpoint | its Session (its own mapping of the service) and, through Hot.view_object, its member: its client.State, and its Caller (a Sender and its Replies) or Responder (a Taker, the Held request, a Replier) on cache lines of its own | process (the member's) |
| the service's segment | ServiceHeader (with ServiceInfo), one ServiceServerSlot per server, then per client a ServiceRingControl, its cells (ServiceCell) and its data ring | shared |
12. Prior art and further reading
similar ideas, not sources of the namesThe code doesn't cite these works, and its names are its own, but each of them describes an idea close to some part of the design. Each entry says which.
- Leslie Lamport, "Specifying Concurrent Program Modules", ACM TOPLAS, 1983. Often cited as the origin of the lock-free single-producer, single-consumer circular buffer: the same idea as each ring, where each index has one writer.
- John Giacomoni, Tipp Moseley, Manish Vachharajani, "FastForward for efficient pipeline parallelism", PPoPP 2008. An SPSC queue tuned against cache-line traffic between the two cores, the concern behind the hot lines and the padding.
- Patrick P. C. Lee, Tian Bu, Girish Chandranmenon, "A lock-free, cache-efficient multi-core synchronization mechanism for line-rate network traffic monitoring" (MCRingBuffer), IPDPS 2010. Each side keeps a local copy of the other's index: similar to
head_seenandtail_seen. - Junchang Wang, Kai Zhang, Xinan Tang, Bei Hua, "B-Queue: Efficient and Practical Queuing for Fast Core-to-Core Communication", International Journal of Parallel Programming, 2012. Batching to cut index traffic: similar to a writer that waits for an eighth of the ring.
- Martin Thompson, Dave Farley, Michael Barker, Patricia Gee, Andrew Stewart, "LMAX Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads". A ring shared by many consumers, each with its own sequence, which the producer tracks so as not to wrap past them: probably the closest relative of the gated subscribers' cursors and the writer's sweep.
- Agrona's
BroadcastReceiver(the Aeron project). A lossy broadcast buffer: the transmitter announces a "tail intent" before it writes, receivers validate a copy against it after the fact, count laps, and rejoin at the "latest" record. The same idea as the frontier check andnext_mark. - Dmitry Vyukov, "Bounded MPMC queue". Each cell carries a sequence number that tells a producer or a consumer whether the cell is its turn, and a compare-and-swap claims a position: related to a queue cell's state, which names its message's full index.
- Aeron's design overview: log buffers used circularly, divided into terms, with multicast to many consumers. Related in spirit to one segment that every subscriber maps.
- The Linux kernel, "Sequence counters and sequential locks". Readers retry if a writer changed the data while they read: the cell protocol is a seqlock.
- Hans-J. Boehm, "Can seqlocks get along with programming language memory models?", MSPC 2012. Why a seqlock's data reads and fences need care in the C/C++ model: the problem
fence.zigandseqlockCopydeal with. - Hubertus Franke, Rusty Russell, Matthew Kirkwood, "Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux", Ottawa Linux Symposium 2002. The futex: the kernel is involved only when someone must sleep.
- Ulrich Drepper, "Futexes Are Tricky", 2011. How to use
FUTEX_WAIT's compare-and-sleep without losing wake-ups, the same problem the sleep flags solve. - The Linux manual,
futex(2)(shared versus private futexes) andio_uring(7): rings shared between two parties, with head and tail indices published by acquire and release. - Mark Batty, Scott Owens, Susmit Sarkar, Peter Sewell, Tjark Weber, "Mathematizing C++ Concurrency", POPL 2011. The formal C/C++11 memory model, the model in which the ring's and the broadcast's arguments are made (more at the Cambridge C/C++ concurrency page).
- Peter Sewell, Susmit Sarkar, Scott Owens, Francesco Zappa Nardelli, Magnus O. Myreen, "x86-TSO", Communications of the ACM, 2010. Why store buffering is the one reordering x86 allows.
- Luc Maranget, Susmit Sarkar, Peter Sewell, "A Tutorial Introduction to the ARM and POWER Relaxed Memory Models", 2012. Litmus tests, message passing and store buffering ("Dekker's examples") on ARM: the shapes of the library's litmus tests.
- Maurice Herlihy, Nir Shavit, Victor Luchangco, Michael Spear, The Art of Multiprocessor Programming, 2nd edition, 2020. The textbook for lock-free queues, mutual exclusion and memory consistency.
- Jeff Preshing, "An Introduction to Lock-Free Programming", "Acquire and Release Semantics" and "Weak vs. Strong Memory Models". The friendliest introduction to the orderings in §7.