FastIPC FastIPC v1.0.0
src/zig · docs/protocol.md

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 version

FastIPC 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 exists

Background

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

IdRequirementWhat it means
R1Fast on the message pathWhile both sides are busy, sending and receiving involve no system call, no lock and no allocation. Small messages move at memory speed.
R2Any message sizeA message of any length goes through a fixed-size buffer and arrives whole, with no allocation per message.
R3Easy to set upConnect by name, in one call, with a timeout. No broker, daemon or configuration file. Failures come back as error codes.
R4Prompt notice of a dead peerWhen 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.
R5Robust against a faulty peerWhatever the other process writes into shared memory, this process doesn't crash, hang, or touch memory outside the segment.
R6More than one patternBesides 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.
R7Any language, every supported OSOne 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+.
R8Lightweight and easy to embedOne 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 solution

Two 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.
Process A (e.g. the server) application threads fipc_send, fipc_recv watcher task waits for it to close Process B (e.g. the client) application threads fipc_send, fipc_recv watcher task waits for it to close shared-memory segment ring A → B: A writes, B reads ring B → A: B writes, A reads plain loads and stores, mapped by both processes: no system call per message the kernel is involved only to put a waiting side to sleep and wake it control connection: local socket or named pipe 1. carries the handshake that sets up the segment 2. then stays silent 3. its close tells the survivor the peer is gone the kernel: when a process exits, for any reason, it closes the process's sockets and pipes
A stream connection. A broadcast, a work queue or a service has the same two halves: one segment that every member maps, and one control connection from each member to the owner.

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

KindWho talksDeliveryInside the segment
Stream (§4)one server and one clientevery message to the other side, in order, both waystwo rings, one per direction
Broadcast (§5)one publisher, up to 1,024 subscribersevery 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 consumerseach message to one consumer, and again to another if that consumer's process ends before acknowledging it; no orderinga ring per producer, plus a return ring for redelivery
Service (§7)up to 64 clients and 64 serverseach 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 againa 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.HowDetails
R1 FastRings 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 sizeA 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 setupA 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 peerThe 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 peerEvery 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 PatternsThe 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 OSesA 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 LightweightAbout 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–§7

A 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.

Where in the code
  • core/names.zig: isValid, linuxRendezvous, windowsRendezvous, macosRendezvous, sessionObject
  • stream/frames.zig: encodeSegment, checkSegment, encodeReady, isReady; the layouts are wire_protocol.ControlStreamSegment and wire_protocol.ControlStreamReady; the frame's size, magic and header are core/wire.zig
  • platform.claim, connect, acceptWithin, sendFrame, recvSegment in each OS's files (Windows: windows/pipe.zig; macOS: macos/rendezvous.zig for the claim and the connect); what Linux and macOS share, sendFrame and recvSegment with its SCM_RIGHTS walk (takeDescriptors, receiveFailure), in platform/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:

  1. 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 now pending.
  2. Wait for READY (awaitReady → platform.readWithin), collecting bytes into pending. 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.

Where in the code
  • stream/listener.zig: Listener, Pending, claimName, cancel, close, dropPending
  • stream/server.zig: create, accept, Step, acceptClient, newSetup, awaitReady, handOver
  • stream/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.

THE SEGMENT (one per connection) header, 640 B s2c ring: capacity bytes the server writes, the client reads c2s ring: capacity bytes the client writes, the server reads 0 640 640 + capacity align_up(640 + 2·capacity, 4096) the header in detail 0 magic 8 capacity · 12 header_size 16 session_id · 32 reserved s2c writer half 128 head 136 reader_sleeps s2c reader half 256 tail 264 writer_sleeps c2s writer half 384 head 392 reader_sleeps c2s reader half 512 tail 520 writer_sleeps 128 256 384 512 Each 128-byte half holds one index and the sleep flag of the side that waits for it. The index has one writer; the flag is the only shared word with two (both change it only by atomic swaps). Orange: written by the server's side. Blue: by the client's. 128 bytes: Apple Silicon's cache line, and x86-64's prefetched pair of 64-byte lines.
Offsets from 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.

Where in the code
  • stream/segment.zig: size, header, data, create, writeHeader, attach, matches; core/shared_memory.zig: Session, createUnderRandomId, openOffered
  • wire_protocol.zig: SegmentHeader, RingIndices, RingWriter, RingReader, segment_magic
  • platform.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.

one ring: capacity bytes, offsets 0 to capacity next frame, after PAD START|ENDone piece START, total_len = nfirst piece ENDlast piece PAD tail (reader) head (writer) 0 capacity the frame header, wire_protocol.FrameHeader (16 B) 0 flags 4 frag_len 8 total_len (START only) flags: START 1 · END 2 · PAD 4 frame length = align_up(16 + frag_len, 16)

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.

src/zig/stream/data_path.zig · Space.plan
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.

Where in the code
  • stream/ring.zig: Ring (forSend, forRecv, offset, fits, readHeader, writeHeader), frameLen
  • core/copy.zig: copy, copyLong
  • stream/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:

LineWritten byHolds
Hotthe lifecycle, rarelythe 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
RecvLinethe receiving threadthe acquired zero-copy message, head_seen, send_head_seen
SendLinethe sending threadthe 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.

Where in the code
  • core/endpoint.zig: Hot, RecvLine, SendLine, Endpoint, View, publishView, dropView
  • stream/ring_wait.zig: until (the inline first look with head_seen), writer_spin_share; data_path.reserve with tail_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:

PUBLISHER (publishHead / publishTail) 1. store the index head or tail, release 2. swap(flag, 0) acq_rel 3. it was 1? wake the waiter ringWake: FUTEX_WAKE · os_sync wake · SetEvent WAITER (sleepUntil, announceSleep) 0. spin up to 4096 hints 1. swap(flag, 1) acq_rel 2. load the index again and cancel, and the phase 3. still nothing? sleep ringWait(flag == 1), ≤ 100 ms the two swaps of one flag are totally ordered Either the publisher's swap comes second and reads the waiter's 1 (it wakes it), or it comes first and the waiter's second look sees the new index. The kernel closes the last gap: FUTEX_WAIT(flag, 1) won't sleep on a 0.
src/zig/stream/ring_wait.zig · the publisher's half
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.

OSSleepWake
LinuxFUTEX_WAIT on the flag, non-private (the word is shared between processes)FUTEX_WAKE of one waiter
macOSos_sync_wait_on_address with the SHARED flag (macOS 14.4+)os_sync_wake_by_address_any
WindowsWaitForSingleObject 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.

Where in the code
  • 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, backInIo
  • platform.ringWait, platform.ringWake in linux.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.

Where in the code
  • stream/data_path.zig: sendAcquire, sendCommit, recvAcquire, releaseHeld; core/calls.zig: sendCommit, commitOther, recvRelease, releaseOther
  • c_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.

Where in the code
  • core/control.zig: runWatcher, readUntilEnd (a connection's read), readFrame; calls.readControl, calls.applyPeerEnd; broadcast/client.zig readPublisherFrames (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.

Where in the code
  • core/process_state.zig: acquire, release, Handles, Slot, lockHandles, registerForkHandlers, keepLoaded, prepareFork, afterForkInChild, reportInherited, forkCount
  • platform.keepImageLoaded (dladdr, then dlopen with NOLOAD and NODELETE)

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, and Space.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_len needs 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.
Where in the code
  • 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 §9

A 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 whole Channel up front, so nothing grows as subscribers join: the segment with every slot, the Roster, 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 Endpoint of view kind broadcast_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 Endpoint of view kind broadcast_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's Owner (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.Poller is level-triggered epoll on Linux and kqueue on macOS. On Windows it uses ReadFileEx reads whose completion routines run during the poller thread's alertable waits; not a completion port, because Io runs its own routines on the same pipes.
  • The member count (Channel.joined, read by fipc_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) load view_kind once and switch on it: a stream call goes to stream/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's fipc_max_piece is a quarter of the capacity.
Where in the code

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.

REGION A: only the publisher writes (read-only in a subscriber's view) REGION B: subscribers write too header512 B cells, 32 B each:capacity/64 of them data ring: capacity bytes payloads, 64-byte multiples, never wrapping shared1 KiB slots: 128 B eachslot s: cursor 0 512 data_offset shared_offset slots_offset = shared_offset + 1024 size data_offset = align_up(512 + capacity/2, 64 KiB) · shared_offset = align_up(data_offset + capacity, 64 KiB) size = align_up(slots_offset + 128 · max_subscribers, 4 KiB) the header, BroadcastHeader 0 description (BroadcastInfo) 128 frontier · 136 next_mark 256 next 384 ended a cell, BroadcastCell (32 B) 0 seq: index + 1, busy bit 63 8 payload_position 16 len · 20 flags the shared block, BroadcastShared 0 armed_any · 8 wake_gen 128 writer_sleeps 256 armed: 16 × u64 384 gated: 16 × u64 512 reserved a slot, BroadcastSlot 0 cursor (gated) 8 reserved, 120 B one 128-byte line per subscriber: its cursor shares a line with nobody Header and shared block: one box per 128-byte line (the reserved 512 B are four). A slot is one line. A zeroed segment is an empty broadcast.
A 4 KiB broadcast with one subscriber takes 135,168 bytes (132 KiB): for small broadcasts the 64 KiB alignment dominates. The owner writes the description once, before any OFFER, and a joiner checks every field of it against OFFER and against the layout it computes itself (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.

Where in the code
  • broadcast/segment.zig: describe, valid, create, attach
  • wire_protocol.zig: BroadcastInfo, BroadcastHeader, BroadcastCell, BroadcastShared, BroadcastSlot, broadcast_busy, broadcast_chunks
  • segment.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:

  1. 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.
  2. Room: check the next index against cell_limit and the payload's end against data_limit. Past either one, wait for gated subscribers (room, §5.5). With no gated subscribers, both limits simply advance a lap at a time.
  3. 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) and next_mark (this message's index), then a store fence. The publisher only ever writes data below a frontier it has already announced.
  4. Copy the payload into the data ring.
  5. 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. Store seq = (i+1) | busy, a store fence, the fields, then seq = i+1 with release.
  6. Record the position in the owner's own payload_positions.
  7. Publish next = i+1 (sequentially consistent), then load armed_any. Only if some subscriber has armed for a wake-up does the publisher do anything more (wakeSubscribers, §5.7).
src/zig/broadcast/writer.zig · writeCell
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.

Where in the code
  • 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:

  1. Load the cell's seq (acquire) and classify it (cellState): equal to p+1 means 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).
  2. readCell: load payload_position and len, a load fence, then seq again. If seq changed, the publisher started rewriting the cell in the meantime.
  3. Check the fields (validPiece: 1 to max_piece bytes, inside the ring), then copy (seqlockCopy).
  4. A load fence, then load frontier. Accept the copy only if frontier − payload_position ≤ capacity.
data positions (monotonic), one tick = a sixteenth of the ring payload payload_position the publisher may write here without touching this payload's bytes payload_position + capacity same ring bytes frontier: whole frontier: OVERRUN The publisher stores the frontier, then a store fence, then writes below it. The reader copies, then a load fence, then loads the frontier. A byte of the payload is overwritten only by a write at payload_position + capacity or later, which comes after a frontier past that.
src/zig/broadcast/reader.zig · Reader.copyChecked
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).

Where in the code
  • broadcast/reader.zig: Reader.recvLossy, copyChecked, overrun, resync; cellState, readCell, seqlockCopy, max_index
  • broadcast/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.

message indices cursor A (laggard) cursor B free_index = min(cursors, next) next cell_limit = free_index + cell_count room for new cells A send past cell_limit or data_limit computes need, sweeps the gated cursors, and waits for the first one below need.

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.

Where in the code
  • 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:

  1. 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 an EVFILT_READ; Windows issues an overlapped ConnectNamedPipe whose event joins the poller's alertable wait, and cancels and awaits it.
  2. 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'.
  3. 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). Then startMember runs 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.
  4. Drop a joiner that disconnects, sends anything other than its JOIN, or sends nothing for 2 s after its accept (dropJoiner frees 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):

src/zig/broadcast/writer.zig · 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.

Where in the code
  • 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.

OSA subscriber sleeps onThe publisher wakes with
Linux, macOSwake_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
Windowsits 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.

Where in the code
  • 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.setWakes
  • platform.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): store in_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) runs Reader.drop: store the new phase, then load in_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.

src/zig/broadcast/reader.zig · Reader.enterSegment
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).

Where in the code
  • broadcast/reader.zig: Reader.enterSegment, leaveSegment, drop, outside, recvChecked, channelEnded; broadcast/writer.zig: Writer.endChannel
  • broadcast/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_ms and 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 §10

A 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 the Queue up 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. The Queue is 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 kinds queue_producer and queue_consumer, each with its own mapping, its client.Member (its client.State, and a producer.Producer or a consumer.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 same platform.Poller as 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's Owner (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.
Where in the code

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.

src/zig/wire_protocol.zig · the cell states
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 };
Where in the code

6.3A send and a claim, step by step

The producer (Producer.send: reserve, a copy, publish) writes only its own ring:

  1. Room: the next cell and enough data-ring space for the payload must be free (fits); if not, it reclaims, then waits for room.
  2. Copy the payload, write the cell's payload fields, then the state published(i) with release.
  3. Publish tail = i + 1 (sequentially consistent), then check ready: if the ring's bit was clear (consumers had seen the ring empty), set it and wake one idle consumer. Finally, check ended.

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:

  1. Load tail, then head (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 at head.
  2. If the buffer is shorter than the largest possible message, check the cell's len first: a message that doesn't fit returns TOO_LARGE with its length, and stays in the queue for any consumer.
  3. Claim: record the intent in its own slot (claim, release), then compare-and-swap the state from published(head) to claimed(head, me). Exactly one consumer wins, and the winner advances head with another compare-and-swap. A consumer that finds a cell already claimed or done helps out by moving head past it. Claims always follow head, and head only passes a message once it's claimed.
  4. Copy the message (or hold it in place until fipc_recv_release), then store done with 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.

Where in the code

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 tail and load of ready, 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 sleeps flag to 1, sets its idle bit, and checks ready, its cancel request, its phase and ended; it only sleeps if none of them changed. A waker takes one idle bit (with an atomic AND), swaps that consumer's sleeps to 0, and wakes it if the swap returned 1.
OSA consumer parks onA waker wakes it with
Linux, macOSits 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
Windowsits 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.

Where in the code
  • 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.

Where in the code

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 holds claimed(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 moves head past 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 to capacity bytes, 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, stores ended, 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 stores ended itself. 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 stores tail and then loads ended, the consumer loads ended and then tail, 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.

Where in the code
  • 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 §11

A 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 the Service up 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. The Service is 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_CLIENT or FIPC_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 kinds service_client and service_server, each with its own mapping, its client.State, a watcher, and its side of the data path on cache lines of its own: a client's caller.Caller (its requests, a Sender of the ring set, and its Replies), a server's responder.Responder (a Taker of the ring set, the request it holds, and its replying thread's state). No new data call exists: a client's fipc_send is a request and its fipc_recv the next reply; a server's fipc_recv is the next request of any client and its fipc_send the reply. fipc_max_piece is the service's max_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's RingSet, core/sender.zig, core/taker.zig and core/members.zig each have two instances, the queue's and the service's, and the service's differences are switched at compile time (replies, a taker's drains), so the queue's code is unchanged by them.
Where in the code

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.

src/zig/wire_protocol.zig · ServiceCell
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.

Where in the code
  • service/segment.zig: describe, valid, create, attach, Rings (the service's shape: replies, the wake suffixes); core/ring_set.zig: RingSet, Layout, replyRoom, validPiece
  • wire_protocol.zig: ServiceInfo, ServiceHeader, ServiceServerSlot, ServiceRingControl, ServiceCell, ServiceOutcome, ServiceRole, service_magic

7.3A request and its reply, step by step

CLIENT c'S REGION (one per client, at clients_offset + c · client_stride) ring control512 B cells, 32 B each:capacity/64 of them data ring: capacity bytes the requests and their replies' room, never wrapping +0 +512 data_offset client_stride the control block, ServiceRingControl 0 tail: the client publishes 128 head: servers move it 256 room_sleeps 384 reply_sleeps the data ring: one reservation per request reply room, R bytesrequest i's, empty request iclaimed reply to i+1done before i's requesti+1 free: reclaimed oncetheir answers are consumed at: the client's own position record payload_position = at + R, in the cell R = max_message rounded up to 64. A request takes R + its length rounded up to 64, reserved at its send: a server's reply never waits for room. THE CLIENT SENDS (its sending thread) 1. reserve R + the request, rounded up to 64 only here may it wait for room; write the request at at + R 2. publish: the cell, then published(i), then tail = i + 1 reply_len 0, outcome 0; release, then seq_cst; its ready bit A SERVER TAKES AND REPLIES 3. claim, as a queue's consumer: intent, CAS, head published(i) to claimed(i, s); check len and position; hold it 4. reply into payload_position − R, then the done reply_len, outcome 1, done(i, s) seq_cst; swap reply_sleeps THE CLIENT RECEIVES 5. wait for the answer to request next, in order spin; swap reply_sleeps to 1, look at the state, sleep 6. done(next): read the outcome and reply_len the reply at its own record's at; next + 1 (release) Blue: written by the client. Orange: by a server. A request's room is free again once its answer is consumed.
Request 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.
  1. Send (fipc_send, or fipc_send_acquire and fipc_send_commit for a request written in place). The client's sending thread is the ring set's sender, a queue producer's code: it reserves R plus the request, waiting for room if it must (the only wait on the request's path), writes the request at the reservation's start plus R, and records the reservation's start in its own position record. Then the cell's fields (with reply_len and outcome 0), published(i) with release, tail = i + 1 sequentially consistent, the ready bit and a wake, and last the look at ended.
  2. Take (a server's fipc_recv or fipc_recv_acquire). The server's receiving thread is the ring set's taker: the pass over the clients' rings, the look at len before a claim into a short buffer, the intent, the compare-and-swap to claimed(i, s) and the move of head are 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, and fipc_recv_release does 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.
  3. Reply (a server's fipc_send, or fipc_send_acquire and fipc_send_commit, which hand out the reply room itself). The reply, 1 to max_message bytes, goes into the room at payload_position − R; then answer stores reply_len and outcome = replied, then done(i, s) sequentially consistent, then swaps the ring's reply_sleeps to 0 and wakes the client if it read 1; last the server loads ended. 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.
  4. Receive (a client's fipc_recv or fipc_recv_acquire). The client's receiving thread keeps next, its count of the answers it consumed, and waits for the done of request next, whichever server stored it. Replied, with a length of 1 to max_message: the reply is at the start of the request's reservation, which its own position record gives. Abandoned: the receive returns FIPC_ABANDONED, with a length of 0. Anything else is a corrupt server's answer: INVALID. Each consumes the answer by storing next + 1 with 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.

Where in the code

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 sleeps only 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_sleeps to 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 and ended (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_sleeps to 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.
OSSleeps onWoken with
Linux, macOSthe 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
Windowsnamed 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
Where in the code

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.

Where in the code

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 holds claimed(i, s) (heldClaim), it abandons the request (abandonHeld): reply_len 0 and outcome = abandoned, then the compare-and-swap from claimed(i, s) to done(i, s), sequentially consistent, then the client's wake. The client's receive of that answer returns FIPC_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 from published to done, 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, stores ended, 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 stores ended itself. From then on clients' sends and servers' receives return DISCONNECTED: servers don't drain (their taker's drains is 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 loads ended, the client loads ended and 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.

Where in the code
  • service/server.zig: onMemberReady, keepsSlot, close; core/ring_set.zig: heldClaim, abandonHeld, wakeIntended, dropUnclaimed, ringSettled; core/members.zig: onMemberReady, endSlotLocked
  • service/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 aarch64

The 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.

AccessOrderingWhy
ring head/tail store; loadrelease; acquireThe bytes of a frame before its publication; the room before its reuse.
ring sleep flagswap, acq_relAll changes of one flag are totally ordered; a plain store could pass the waiter's next load (store buffering).
Hot view: fields; view_publishedmonotonic; release / acquireA data call sees no session or a complete one.
cell seq busy store + store fencemonotonic, storeFenceA 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 loadrelease; acquireThe payload and fields before "complete".
frontier store + store fence; reader's load fence + loadmonotonic with fencesThe frontier is visible before any byte written under it; the reader's frontier load comes after its copy's loads.
next store; armed_any loadseq_cstStore-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_genseq_cstThe no-lost-wake argument needs one total order over all of them.
gated cursor on each receive; look at writer_sleepsrelease; relaxedThe 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_sleepsseq_cstThe 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 bitseq_cstIn 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' loadrelease; acquireA 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 cursormonotonic; seq_cst; seq_cst; releaseThe 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 = 0seq_cst; releaseStore-then-load on both sides (§5.8); the call's accesses of the segment come before the watcher sees it outside.
endedseq_cst store before the wakeA 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 bitsrelease (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-swaprelease; acq_relThe payload and its fields before the state; the claimer reads them after its claim (§6.3).
a consumer's claim (intent) before its claimreleaseThe 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 clearseq_cstTwo 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 sleepsseq_cstEither 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 tailseq_cstA send that returned OK is taken by a consumer that drains (§6.6).
a consumer's done; the producer's reclaim loadsrelease; acquireThe 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 statemonotonic, then seq_cst; acquireThe 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 stateseq_cstStore-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 stateseq_cstA 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 donemonotonic, then seq_cstMessage 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 reclaimrelease; acquireThe 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 clearrelease; acquire; releaseA server may receive on one thread and reply on another.
Where in the code

9. Invariants and testing

what always holds

The 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 accept that 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 up

Each requirement was met at some cost. These are the main decisions, what else could have been done, and what each one gives up:

DecisionInstead ofWhyCost
A dead peer is reported by its control connection closingheartbeats and timeoutsImmediate 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 memorythe socket that is already thereNo 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 sleepsalways sleeping, or always spinningA 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 threada background accept threadErrors 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 streama multi-client serverEach 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 malicioustreating the peer as hostileChecks 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 backunbounded bufferingNothing 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 endsacknowledgement timeoutsNo 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 requestsrequest ids, and replies in the order they completeNo 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 ringa reply ring that servers share, or one per client and serverA 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_ABANDONEDsending it again to another serverAt 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 servicea wider ready mapOne 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.Threadedhand-written threads per OSOne 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 dlcloseIts 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 Io a ring wait blocks its carrier thread for up to a slice, and accept and connect block 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 file

Reading 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:

  1. The contract: include/fipc.h, then c_api.zig (each export checks its arguments and calls one function), wire_protocol.zig, and the top of endpoint.zig down to Endpoint.
  2. The handshake: stream/listener.zig, core/control.zig, wire.zig, names.zig, then a stream connection's stream/server.zig, client.zig, frames.zig (§4.2).
  3. The watcher, cancel and close: control.runWatcher, endpoint.teardown (§4.8).
  4. The segment and one OS: core/shared_memory.zig, stream/segment.zig, platform.zig, platform/linux/linux.zig (§4.3).
  5. The other OSes: platform/windows/ and platform/macos/, and platform/contract_test.zig, the tests every OS passes.
  6. The process: core/process_state.zig (§4.9).
  7. The data path: core/calls.zig's data calls, core/wait.zig and core/copy.zig, then stream/ring.zig, ring_wait.zig, data_path.zig (§4.4 to §4.7).
  8. The broadcast: broadcast/segment.zig, writer.zig and reader.zig (their top comments first), roster.zig, core/fence.zig, core/admission.zig, broadcast/server.zig and client.zig (§5).
  9. The work queue: queue/segment.zig, core/ring_set.zig, core/sender.zig and core/taker.zig (their top comments first), queue/producer.zig, consumer.zig, server.zig with core/members.zig, client.zig and frames.zig (§6).
  10. The service: service/segment.zig, caller.zig and responder.zig (their top comments first), then core/ring_set.zig's answer, abandonHeld and dropUnclaimed, service/server.zig, client.zig and frames.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
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

c_api.zig the shared library's root: 28 C exports root.zig the Zig module "fastipc" core/ endpoint · admission control · calls · wait ring_set · sender · taker what two kinds share stream/ listener · server · client frames · segment · ring data_path · ring_wait SPSC: one ring each way broadcast/ server · client · frames segment · writer · reader roster one writer, many readers queue/ server · client · frames segment · producer consumer · returns MPMC: a ring per producer service/ server · client · frames segment · caller responder a ring per client, replies deliberate import cycles (Zig analyzes them lazily) core/shared_memory.zig create a segment under a random id, map what was offered platform.zig: the OS contract types and functions, bound at compile time (a direct call) platform/linux/linux.zig platform/windows/windows.zig (win32) platform/macos/macos.zig (darwin) beside every layer: wire_protocol.zig (layouts, imports nothing of the library) · core/process_state.zig · core/log.zig

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

BoundaryWhat it isolates
c_api.zig, root.zigThe 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.zigEvery 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.zigSegments. 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.zigState 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

ObjectHasLives 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 Backoffprocess
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 Sessionprocess
shared_memory.Sessiona platform.Mapping of the segment (on Windows with its named events), the capacity, the session idprocess (it maps shared memory)
the segment of a connectionwire_protocol.SegmentHeader with two RingIndices (each a RingWriter line and a RingReader line), then two rings of frames (FrameHeader + piece)shared
ring.Ringpointers to one direction's indices and data; built from Hot on each call, never storedprocess (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.Memberone slot's record: while a subscriber holds the slot, its control connection (Handles), what the broadcast's poller read of it (platform.Watched), its generationprocess (the owner's)
a subscriber's Endpointits Session (its own mapping of the broadcast) and, through Hot.view_object, its reader.Readerprocess (the subscriber's)
the broadcast's segmentBroadcastHeader (with BroadcastInfo), the cells (BroadcastCell), the data ring, BroadcastShared, one BroadcastSlot per subscribershared
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 Endpointits 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 ownprocess (the member's)
the queue's segmentQueueHeader (with QueueInfo), one QueueConsumerSlot per consumer, then per ring (each producer's, then the return ring) a QueueRingControl, its cells (QueueCell) and its data ringshared
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 Endpointits 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 ownprocess (the member's)
the service's segmentServiceHeader (with ServiceInfo), one ServiceServerSlot per server, then per client a ServiceRingControl, its cells (ServiceCell) and its data ringshared

12. Prior art and further reading

similar ideas, not sources of the names

The 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.