Troupe:multi-role finite state machine

Multi-role finite state machine, used for multithreaded programs or distributed programs.

The behaviors of multiple characters are orchestrated into a state machine. Imagine a theater with many characters performing a play. The behaviors of all characters are arranged as a whole in the script. When a certain message is sent, everyone will complete their performance according to the script.

Some similar libraries:

  1. Home - Choral Language Website
  2. GitHub - gshen42/HasChor: Functional choreographic programming in Haskell

This project was initially called “polysession,” and I only intended it for developing multi-role communication protocols.

However, I later discovered its uses extend far beyond communication protocols. For example, you can use it to create game scripts and write multi-threaded programs.

When there is only one participating role, troupe is equivalent to polystate. Or, you can consider polystate as a special case of troupe.

2 Likes

The troupe allows you to construct the overall control flow of your program through declarative composition and generates the overall control flow diagram through the compiler.

For example, the above definition will generate the following control chart:

The effect during runtime:
GIF 2025-11-16 09-27-54

5 Likes

I don’t understand it at all, but from your descriptions it seems like something sublime

The stacked coroutines have been merged into master.

A troupe is a framework describing multi-role communication. Using evented, we’ll use one million pairs of pingpong protocols, representing two million fibers.

Each pair of fibers in the pingpong protocol will send messages to each other 30 times.

zig build pingpong --release=fast

It took about 10 seconds to complete on my PC.

ps: I set the value of A here to 1 * 1024.

It doesn’t have networking yet, but for anything needing concurrency for local IO, it seems to be there and working reasonably okay.

Unless you’re debugging IoUring.zig itself, I’d recommend adding the following to your root module as it currently has some quite verbose debug logging.

const std_options: std.Options = .{
    .log_scope_levels = &.{ .{ .scope = .@"io-uring", .level = .info } },
};

Not the author of it, just someone who has been following development closely and been toying around with it since the PR was made a little while ago.

1 Like

I updated the README.md so that more people can recognize the value of this library.

An example written using troupe: a file transfer protocol with block hash checking.

The protocol defined: troupe/examples/protocols/sendfile.zig at main · sdzx-1/troupe · GitHub

The main entry point: troupe/examples/sendfile.zig at main · sdzx-1/troupe · GitHub

Alice sends a file to Bob over a TCP connection. Data is streamed in 4 KB chunks. After every 20 MB of data, or when the file ends, the sender sends a hash of the transmitted data; the receiver independently computes the hash and reports whether it matches, enabling early detection of corruption.

This example demonstrates real-world protocol design with Troupe:

  • Self-looping state for streaming: Send.send: Data([]const u8, @This()) — the Send state references itself, forming a cycle in the state graph that supports arbitrary-length data transfer. This is the pattern for any streaming protocol.

  • State template as protocol subroutine: CheckHash(A, B) is not a single fixed state but a parameterized state template. It accepts two type parameters — the success continuation A and the failure continuation B — and is instantiated twice with different continuations: CheckHash(@This(), Failed) for periodic checkpoints (continue sending on success), and CheckHash(Successed, Failed) for the final chunk (exit on success).

  • Receiver-driven integrity verification: The sender commits to a hash; the receiver independently computes the hash and reports the result. The CheckHash state reverses sender/receiver roles: the verification result flows from receiver back to sender.

  • Multi-state exit semantics: CheckHash has two branches (Successed / Failed), each connecting to a different continuation path. This satisfies the branch notification rule: since both roles are internal, receiver.len must be 1.

  • TCP StreamChannel: Unlike the in-memory channel used in other examples, sendfile runs over real TCP sockets, demonstrating that the channel abstraction is transparent to protocol logic. The same protocol definition works with any channel implementation.

sendfile

A closer look at the Send state — the most insightful part of this protocol is its type definition:

pub const Send = union(enum) {
    send  : Data([]const u8                          , @This()),
    check : Data(u64                                 , CheckHash(@This(), Failed)),
    final : Data(struct {str: []const u8, hash: u64,}, CheckHash(Successed, Failed)),
};

Three branches, three different continuations — the entire transmission strategy is encoded in these three lines:

  • .send → @This(): Transmit a chunk, then loop back to the same state. The self-reference creates an implicit while loop in the state graph, enabling streaming without a dedicated loop construct.

  • .check → CheckHash(@This(), Failed): At batch boundaries, pause transmission to verify integrity. The continuation @This() (i.e., Send) is the success path — pass verification and resume streaming. Failed is the abort path.

  • .final → CheckHash(Successed, Failed): End of file. Send the last chunk with its cumulative hash. Both paths lead to termination — CheckHash here uses Successed and Failed as distinct exit routes rather than a return to Send.

This is the same CheckHash template invoked with different continuations. The verification logic is written once; only the “where to go next” differs between the two call sites. The process function that decides which branch to take is equally compact — it reads a chunk from the file, then routes based on send_size >= batch_size and whether the read reached end-of-file:

pub fn process(parent_ctx: *@field(context, @tagName(sender))) !@This() {
    const ctx = sender_ctxFromParent(parent_ctx);
    if (ctx.send_size >= batch_size) {
        ctx.send_size = 0;
        const curr_hash = ctx.hasher.final();
        ctx.hasher = std.hash.XxHash3.init(0);
        return .{ .check = .{ .data = curr_hash } };
    }

    const n = try ctx.reader.readSliceShort(&ctx.send_buff);

    if (n < ctx.send_buff.len) {
        ctx.hasher.update(ctx.send_buff[0..n]);
        ctx.send_size += ctx.send_buff.len;
        return .{ .final = .{ .data = .{ .str = ctx.send_buff[0..n], .hash = ctx.hasher.final() } } };
    } else {
        ctx.hasher.update(&ctx.send_buff);
        ctx.send_size += ctx.send_buff.len;
        return .{ .send = .{ .data = &ctx.send_buff } };
    }
}

The Send state demonstrates a principle that recurs throughout well-designed Troupe protocols: the type signature tells the structural story; the handler function fills in the runtime details. Reading the three union fields, you already know the entire flow — streaming, checkpointing, termination. The process function is just the concrete filling of that skeleton.

4 Likes