FastIPC FastIPC v1.0.0
module fastipc · src/zig/root.zig · Zig 0.17.0

Zig

The library is written in Zig, and its package exports the module fastipc: Listener, Queue, Service and Conn over your own std.Io, with Zig error sets in place of result codes and std.Io.Timeout in place of milliseconds. It is the same implementation as the C API, compiled into your program, so there is no shared library to ship.

Zigzig fetch --save git+https://github.com/fastipc/fastipc

Install and build

Zig 0.17.0 · x86-64-v3 · ARM64 Linux · Apple Silicon

zig fetch --save adds the package to your build.zig.zon as fipc; for a checkout of your own, list it by path instead: .fipc = .{ .path = "../fastipc" }. Your build.zig then imports the dependency's module fastipc into each program that uses it.

The module is the library's own source (small enough to read), built with your target and optimize mode. It links libc and the system libraries it needs, which the package's build adds. It needs Zig 0.17.0 and runs where the library does: x86-64-v3 CPUs, Linux with glibc 2.34+ (x86-64 and ARM64), Windows 11 and Server 2022+, and macOS 14.4+ on Apple Silicon (platform support).

build.zig
const dep = b.dependency("fipc", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("fastipc", dep.module("fastipc"));
build.zig.zon, from a checkout
.dependencies = .{
    .fipc = .{ .path = "../fastipc" },
},

Server and client

two processes, one name

Each file is a program of its own, an executable in your build.zig; these two are the repository's examples/zig, a project that builds them. Start the two in either order: fastipc.streamConnect waits up to its timeout for a server to create the stream and accept. The client sends the message ping, and the server answers in upper case: the exchange every example on this site makes, so either program works with the other half in any language (every pair of languages is tested).

server.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    // rings of 1 MiB each way
    const listener = try fastipc.streamCreate(
        init.gpa,
        "my_channel",
        1 << 20,
    );
    defer listener.close();
    // waits for a client
    const conn = try listener.accept(.none);
    defer conn.close();

    var buf: [256]u8 = undefined;
    // .none: no timeout
    const len = try conn.recv(&buf, .none);
    const reply = std.ascii.upperString(&buf, buf[0..len]);
    try conn.send(reply, .none);
}
client.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    const conn = try fastipc.streamConnect(
        init.gpa,
        "my_channel",
        five_s,
    );
    defer conn.close();
    try conn.send("ping", .none);
    var buf: [256]u8 = undefined;
    const len = try conn.recv(&buf, .none);
    std.debug.print("{s}\n", .{buf[0..len]}); // PING
}

The rules

the C API's, in Zig terms
std.Io.Timeout

Timeouts

Always the last parameter. .none waits as long as it takes, a zero .duration doesn't wait, and a .deadline is a point in time. A call that runs out returns error.Timeout having changed nothing.

name · capacity

Names and rings

A name is 1 to 245 characters of [A-Za-z0-9_.-], not starting with . or -, local to the user (Linux, macOS) or the desktop session (Windows). The name's creator picks the ring size: for a stream, a power of two from 1 KiB to 2 GiB.

threads

One sender, one receiver

On a connection, one thread at a time sends and one receives, and the two may run at once; a service client's call does both. cancel works from any thread; no call may overlap close. To stop a waiting thread: cancel, join it, then close.

any size

Messages

A message holds at least one byte and arrives in order (a work queue promises no order; a service's replies arrive in the order of their requests). send and recv take any size; a long message travels in pieces and is delivered whole or not at all.

error.TooLarge

Buffers too small

A recv into a buffer that is too small takes nothing: the message stays queued. recvAlloc takes it at any size, into memory it allocates for exactly its length.

error.Disconnected

The peer's end

Final for that connection: sends return it at once, receives after every completed message, and ended() turns true. Close the connection, then accept or connect a new one.

Messages

bytes of any size, no header

send takes a message of any size and sends it in pieces of up to maxPiece() bytes when it is longer; its timeout bounds the wait for room for the first piece, and a started message completes unless a cancel or the peer's end stops it, which drops it whole. recv fills your buffer and returns the length; recvAlloc returns a new slice that you free. This server echoes every message back until its client leaves, which ends its next receive with error.Disconnected.

echo_server.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    // rings of 1 MiB each way: a power of two, 1 KiB to 2 GiB
    const listener = try fastipc.streamCreate(
        init.gpa,
        "demo",
        1 << 20,
    );
    defer listener.close();
    // waits for a client
    const conn = try listener.accept(.none);
    defer conn.close();
    while (true) {
        // a message of any size, in memory from gpa
        const message = conn.recvAlloc(
            init.gpa,
            .none,
        ) catch |err| switch (err) {
            error.Disconnected => break, // the client left
            else => return err,
        };
        defer init.gpa.free(message);
        try conn.send(message, .none); // echo it
    }
    std.debug.print("client gone\n", .{});
}
echo_client.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    const conn = try fastipc.streamConnect(
        init.gpa,
        "demo",
        five_s,
    );
    defer conn.close();
    var buf: [64]u8 = undefined;
    for ([_][]const u8{ "hello", "shared", "memory" }) |word| {
        try conn.send(word, .none);
        const len = try conn.recv(&buf, five_s);
        std.debug.print("{s}\n", .{buf[0..len]});
    }
}

Errors and timeouts

error sets for fipc_result_t

Each error has the name of the C result code it stands for (the codes). The four creates (streamCreate, broadcastCreate, queueCreate, serviceCreate) return fastipc.CreateError, accept returns fastipc.AcceptError, streamConnect and the joins return fastipc.Error, a queue's and a service's waitForMembers return fastipc.WaitError, and the data calls (and a publisher's waitForMembers) return fastipc.DataError; recvAlloc and call add std.mem.Allocator.Error.

ErrorC codeReturned byMeaning
error.TimeoutFIPC_TIMEOUTevery call that waitsThe timeout ran out; with a zero duration, there was nothing to do without waiting.
error.DisconnectedFIPC_DISCONNECTEDthe data callsThe peer closed the connection or its process ended.
error.CancelledFIPC_CANCELLEDevery call that waitsThe handle's cancel was called, and the call would have had to wait. A data call whose task an Io.Threaded cancels returns it too.
error.TooLargeFIPC_TOO_LARGErecv, acquire, recvAcquireThe message doesn't fit the buffer, or one piece for zero-copy and on a broadcast, a queue or a service. A message being received stays queued, for recvAlloc to take at any size.
error.InvalidFIPC_INVALIDany callA bad argument, a call out of order (an accept while the last client's connection is open), a peer of another version or user, corrupt ring content.
error.NoMemoryFIPC_NO_MEMORYthe creates, accept, streamConnect, the joinsMemory or another OS resource ran out while setting a connection, a broadcast, a queue or a service up.
error.AddrInUseFIPC_ADDR_IN_USEthe createsAnother stream, broadcast, queue or service holds the name.
error.OverrunFIPC_OVERRUNrecv, recvAllocA lossy broadcast subscriber fell a lap behind and lost messages; lostMessages says how many, and the next receive goes on from a recent message.
error.UnsubscribedFIPC_UNSUBSCRIBEDrecv, recvAlloc, recvAcquireA gated broadcast subscriber held the publisher back for the broadcast's max_stall_ms and was dropped from the broadcast: every later receive returns it, so close the connection.
error.AbandonedFIPC_ABANDONEDrecv, recvAlloc, recvAcquire, callA service's client: the server that took the request ended before it replied. The request counts as answered, and the next receive returns the next reply; it may or may not have been carried out.
timeouts
const Timeout = std.Io.Timeout;
const forever: Timeout = .none;
const no_wait: Timeout = .{ .duration = .{
    .raw = .zero,
    .clock = .awake,
} };
const five_s: Timeout = .{ .duration = .{
    .raw = .fromSeconds(5),
    .clock = .awake,
} };
const this_frame: Timeout = .{ .deadline = .fromNow(io, .{
    .raw = .fromMilliseconds(16),
    .clock = .awake,
}) };
wait at most 100 ms
const hundred_ms: std.Io.Timeout = .{ .duration = .{
    .raw = .fromMilliseconds(100),
    .clock = .awake,
} };
var buf: [4096]u8 = undefined;
if (conn.recv(&buf, hundred_ms)) |len| {
    handle(buf[0..len]);
} else |err| switch (err) {
    error.Timeout => {}, // nothing yet
    else => return err, // error.Disconnected: the peer is gone
}

The data calls (send, recv, recvAlloc, acquire, recvAcquire, call) wait in whole milliseconds and round a timeout up to the next one.

Zero-copy

messages of one piece: up to conn.maxPiece() bytes

acquire returns room in the ring as a []u8; write the message there and commit the bytes you wrote, at least one. recvAcquire returns the next message where it lies, as a []const u8 that stays valid until release, the next receive or close. maxPiece() is the ring's capacity less 64 bytes. A longer message goes through send, and recvAcquire returns error.TooLarge for a message of several pieces, which stays queued for recv.

send in place
const out = try conn.acquire(64, .none); // room in the ring
const text = try std.mem.print(out, "tick {d}", .{tick});
try conn.commit(text.len); // publishes text.len bytes
receive in place
// the next message, read-only, in the ring
const message = try conn.recvAcquire(.none);
handle(message);
conn.release(); // frees its room

Threads

cancel, join, close

accept and streamConnect set a connection up on the thread that calls them, so a client and its server run on different threads or processes. broadcastJoin, queueJoin and serviceJoin set a member up on the thread that calls them too, and the owner's process admits it on a task of its own, so an owner may join its own broadcast, queue or service from any thread, the one that created it included. Then every connection runs a task on the library's own threads, which watches the peer: the library needs no Io of yours.

Listener, Conn, Queue and Service are handles, copied by value. To stop a thread that waits, call cancel() from any thread: every call of that handle that would wait, then or later, returns error.Cancelled, and calls that needn't wait still work. Then join the thread and close. The waits of accept, streamConnect and the joins are the OS's, not an Io's: cancel() ends an accept, a timeout bounds a connect or a join. When an Io.Threaded cancels a task that waits in a data call (the Io that std.process.Init hands to main is one), the call returns error.Cancelled and the cancelation stays pending for the task's next cancelation point.

stop a reader thread
const Reader = struct {
    fn run(c: fastipc.Conn) void {
        var buf: [4096]u8 = undefined;
        // until error.Cancelled (or error.Disconnected)
        while (c.recv(&buf, .none)) |len| {
            handle(buf[0..len]);
        } else |_| {}
    }
};
const reader = try std.Thread.spawn(.{}, Reader.run, .{conn});
// ...
conn.cancel(); // wakes the reader: error.Cancelled
reader.join();
conn.close();

Broadcast

one publisher, many subscribers

A broadcast sends every message of one publisher to each of its subscribers, in any processes of the user; each message is stored once, in memory every subscriber maps. Messages are of one piece, up to a quarter of the capacity. fastipc.broadcastCreate returns the publisher's connection; the publisher's process admits the subscribers on a task of its own, so no thread of yours waits for them. The publisher waits for the first one (publisher.waitForMembers) before it sends tick 1 to tick 3; publisher.close() ends the broadcast and frees the name. The subscriber joins gated (fastipc.broadcastJoin with .gated), so it misses nothing (the publisher waits for it), and receives until error.Disconnected, which comes after the last message. A lossy subscriber (.lossy) never holds the publisher back: one that falls a lap behind gets error.Overrun, and conn.lostMessages() says how many it lost. A gated one that holds the publisher back for the broadcast's max_stall_ms is unsubscribed (error.Unsubscribed).

publisher.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    // 64 KiB of messages, up to 8 subscribers;
    // one that holds the publisher back 5 s is dropped
    const publisher = try fastipc.broadcastCreate(
        init.gpa,
        "my_broadcast",
        1 << 16,
        8,
        5000,
    );
    defer publisher.close(); // ends the broadcast

    // waits for the first subscriber
    _ = try publisher.waitForMembers(1, .none);
    for (1..4) |i| {
        var buf: [16]u8 = undefined;
        const tick = try std.mem.print(&buf, "tick {d}", .{i});
        try publisher.send(tick, .none);
    }
}
subscriber.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    // gated: misses nothing (the publisher waits for it)
    const subscriber = try fastipc.broadcastJoin(
        init.gpa,
        "my_broadcast",
        .gated,
        five_s,
    );
    defer subscriber.close();
    var buf: [256]u8 = undefined;
    while (true) {
        const len = subscriber.recv(&buf, .none) catch |err| switch (err) {
            error.Disconnected => return, // the publisher closed
            else => return err,
        };
        std.debug.print("{s}\n", .{buf[0..len]}); // tick 1, ...
    }
}

Work queue

many producers, each message to one consumer

A work queue takes messages from up to 63 producers and hands each to one of up to 64 consumers, in any processes of the user. Each producer has a ring of its own; a consumer takes a message of any producer, and the queue promises no order. Messages are of one piece, up to a quarter of the capacity, and wait in the queue until a consumer takes them. Each message goes to one consumer, and again, to another, only if that consumer's process ends before acknowledging it (delivery, below). Here the producer creates the queue (fastipc.queueCreate: a Queue) and joins it as its producer with fastipc.queueJoin, as any process does: the owner's process admits every joiner on a task of its own, so the thread that created the queue may join it. It sends job 1 to job 3, waits for the consumer (queue.waitForMembers) and closes the queue, which ends it: the consumer receives what is left, then error.Disconnected. Other processes join with fastipc.queueJoin (.producer, .consumer); a member's close never ends the queue.

producer.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    // 4 KiB of messages per producer, 1 producer and 1 consumer:
    // this process owns the queue and is its producer
    const queue = try fastipc.queueCreate(
        init.gpa,
        "my_queue",
        4096,
        1,
        1,
    );
    defer queue.close(); // ends the queue
    const producer = try fastipc.queueJoin(
        init.gpa,
        "my_queue",
        .producer,
        five_s,
    );
    defer producer.close();
    for (1..4) |i| {
        var buf: [16]u8 = undefined;
        const job = try std.mem.print(&buf, "job {d}", .{i});
        try producer.send(job, .none); // waits in the queue for a consumer
    }
    _ = try queue.waitForMembers(.consumer, 1, .none); // waits for the consumer to join
}
consumer.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    const consumer = try fastipc.queueJoin(
        init.gpa,
        "my_queue",
        .consumer,
        five_s,
    );
    defer consumer.close();
    var buf: [256]u8 = undefined;
    while (true) {
        const len = consumer.recv(&buf, .none) catch |err| switch (err) {
            error.Disconnected => return, // the queue ended
            else => return err,
        };
        std.debug.print("{s}\n", .{buf[0..len]}); // job 1, ...
    }
}

Delivery. Each message goes to one consumer. It goes again, to another consumer, only if the process of the consumer that took it ends before acknowledging it, and when a consumer acknowledges is its own choice, not the queue's. A message received in place is acknowledged at its release (or the consumer's next receive, or its close).

How the consumer receivesWhen it acknowledgesIf its process endsWhat the application gets
recv or recvAlloc (copying)inside the call, before it returnsinside the call: the message goes to another consumer, and this one never saw it; after the call returned: it doesn't go againat most once: no duplicates
recvAcquire, then release at once, then the workat the release, before the workas aboveat most once: no duplicates
recvAcquire, the work, then releaseat the release, after the workbefore the release: the message goes to another consumerat least once: a duplicate is possible, so the work must be idempotent

Service

many clients, a pool of servers, with replies

A service takes requests from up to 64 clients and hands each to one of up to 64 servers, in any processes of the user; the server that took a request replies to it, and a client receives its replies in the order of its requests. Here the server creates the service (fastipc.serviceCreate: a Service) and joins it as its server with fastipc.serviceJoin (.server), answers three requests with their uppercase and closes the service, which ends it; the client joins (.client) and calls ping 1 to ping 3: call sends a request and returns its reply, in memory from the allocator, and the client prints each. A request whose server ended before it replied gets error.Abandoned.

service_server.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    // 4 KiB per client, requests and replies of up to 256 bytes, 1 client
    // and 1 server: this process owns the service and is its server
    const service = try fastipc.serviceCreate(
        init.gpa,
        "my_service",
        4096,
        256,
        1,
        1,
    );
    defer service.close(); // ends the service
    const server = try fastipc.serviceJoin(
        init.gpa,
        "my_service",
        .server,
        five_s,
    );
    defer server.close();
    var buf: [256]u8 = undefined;
    for (0..3) |_| { // three requests, then the end
        const len = try server.recv(&buf, .none); // the next request of any client
        const reply = std.ascii.upperString(&buf, buf[0..len]);
        try server.send(reply, .none); // goes to its client, never waits
    }
}
service_client.zig
const std = @import("std");
const fastipc = @import("fastipc");

pub fn main(init: std.process.Init) !void {
    const five_s: std.Io.Timeout = .{ .duration = .{
        .raw = .fromSeconds(5),
        .clock = .awake,
    } };
    const client = try fastipc.serviceJoin(
        init.gpa,
        "my_service",
        .client,
        five_s,
    );
    defer client.close();
    var buf: [16]u8 = undefined;
    for (1..4) |i| {
        const request = try std.mem.print(&buf, "ping {d}", .{i});
        // sends the request and returns its reply, which the caller
        // frees; error.Abandoned if the server ended before it replied
        const reply = try client.call(init.gpa, request, .none);
        defer init.gpa.free(reply);
        std.debug.print("{s}\n", .{reply}); // PING 1, ...
    }
}

The calls. A service's connections are Conns, and what each call does depends on the role. Requests and replies are of one piece, 1 to max_message bytes, the owner's choice at serviceCreate (1 to a quarter of the capacity); maxPiece() returns it on both roles.

CallOn a clientOn a server
send, acquire · commitSends a request. Waits up to its timeout for room in the client's ring.Replies to the request it holds; never waits. error.Invalid with no request held, error.TooLarge over maxPiece().
recv, recvAllocThe reply to its oldest request whose reply it hasn't received, waiting for it if it hasn't come; error.Abandoned if that request's server ended before it replied.The next request of any client, copied; the server then holds it until it replies.
callSends a request and returns its reply, in memory from gpa that you free, within one timeout; error.Abandoned if its server ended before it replied.error.Invalid, as on any connection other than a service's client.
recvAcquire · releaseThe next reply in place, until release, the next receive or close.The next request in place, valid until the reply's send or commit; release does nothing.
closeLeaves the service: requests no server took are dropped, and the replies to those already taken are lost.Leaves the service; a request it still holds is answered error.Abandoned.
CallWhat it does
streamCreate(gpa, name, capacity)Server: claims the name for a stream and listens on it with rings of capacity bytes, and returns its Listener. Doesn't wait. error.AddrInUse if another stream, broadcast, queue or service holds the name.
listener.accept(timeout)Waits for a client, sets its connection up on the calling thread and returns its Conn. One client at a time: close it before the next accept. A zero timeout polls.
listener.cancel()Every accept that waits, now or later, returns error.Cancelled, dropping a client whose setup began. Any thread.
listener.close()Stops listening and frees the listener; the connections it accepted stay open.
streamConnect(gpa, name, timeout)Client: connects to the stream on the name, waiting up to the timeout for a server to create it and accept.
broadcastCreate(gpa, name, capacity, max_subscribers, max_stall_ms)Publisher: claims the name for a broadcast and returns the publisher's Conn, whose sends go to every subscriber; its process admits the subscribers on a task of its own. publisher.close() ends the broadcast and frees the name. Doesn't wait.
broadcastJoin(gpa, name, role, timeout)Subscriber: joins the broadcast on the name as .lossy or .gated (misses nothing; SubscriberRole), waiting up to the timeout for the publisher to create it, to have a free slot and to admit it; it receives the messages sent from its admission on.
publisher.waitForMembers(at_least, timeout)A publisher's count of its joined subscribers, waiting until at least at_least are; 0 returns the count at once, also after an error.Timeout or error.Cancelled. Any thread.
queueCreate(gpa, name, capacity, max_producers, max_consumers)Owner: claims the name for a work queue and returns its Queue; its process admits the members on a task of its own. Doesn't wait.
queueJoin(gpa, name, role, timeout)Producer or consumer: joins the work queue on the name as .producer or .consumer (QueueRole), waiting up to the timeout for the owner to create it, for a free place and to admit it. The owner joins its own queue this way too, from any thread.
queue.waitForMembers(role, at_least, timeout)The owner's count of the joined members of role (null: both), waiting until at least at_least are; error.Disconnected once the queue has ended. Any thread.
queue.cancel()Every waitForMembers that waits, now or later, returns error.Cancelled; the queue goes on. Any thread.
queue.close()Ends the queue (producers get error.Disconnected; consumers receive what is left, then error.Disconnected) and frees it; the members' connections stay open until their close.
serviceCreate(gpa, name, capacity, max_message, max_clients, max_servers)Owner: claims the name for a service and returns its Service; its process admits the members on a task of its own. Doesn't wait.
serviceJoin(gpa, name, role, timeout)Client or server: joins the service on the name as .client or .server (ServiceRole), waiting up to the timeout for the owner to create it, for a free place and to admit it. The owner joins its own service this way too, from any thread.
service.waitForMembers(role, at_least, timeout)The owner's count of the joined members of role (null: both), waiting until at least at_least are; error.Disconnected once the service has ended. Any thread.
service.cancel()Every waitForMembers that waits, now or later, returns error.Cancelled; the service goes on. Any thread.
service.close()Ends the service (clients' sends and servers' receives get error.Disconnected; clients receive the replies already sent, then error.Disconnected) and frees it; the members' connections stay open until their close.
conn.send(message, timeout)Sends one message of any size, in pieces if needed.
conn.recv(buf, timeout)Receives the next message into buf and returns its length; error.TooLarge if it doesn't fit (it stays queued).
conn.recvAlloc(gpa, timeout)Receives the next message into memory from gpa, which you free.
conn.call(gpa, request, timeout)A service's client: sends the request and returns its reply, in memory from gpa, which you free; error.Abandoned if its server ended before it replied. It never returns another request's reply (above).
conn.acquire(len, timeout) · conn.commit(len)Zero-copy send: room for len bytes in the ring · sends the first len bytes written there as one message.
conn.recvAcquire(timeout) · conn.release()Zero-copy receive: the next message in place · frees its room.
conn.lostMessages()A lossy broadcast subscriber's count of the messages its last error.Overrun lost: at least 1; 0 before the first, and on any other connection.
conn.maxPiece()The longest message the zero-copy calls take: the capacity less 64 bytes; a broadcast's or a queue's, a quarter of its capacity, its longest message; a service's, its max_message.
conn.cancel()Every call of the connection that would wait, now or later, returns error.Cancelled. Any thread.
conn.ended()Whether the peer ended the session, the publisher closed (a subscriber's), or the queue or the service ended (a member's); its calls report error.Disconnected once nothing is left to receive.
conn.close()Ends the connection (the peer's calls report error.Disconnected), unmaps its rings and frees it. No other thread may be inside a call on it.