The echo server answered each client with its own words, so its sessions never had to meet: one task per connection, each complete in itself. A chat is the opposite problem. Every line one client speaks has to reach every other client, which means the conversations must cross somewhere — and where they cross is the whole design of a chat server.

In Mica they cross in a stream. Not a new networking concept: the same channel the streams unit ships, the same fan-in you may have used to join producers in one process. The network library’s thesis is that it does not invent concepts — it lets the ones you know cross process boundaries — and this example is that thesis at work:

 client ──► Connection ──► Pump ──┐
 client ──► Connection ──► Pump ──┼──► inbox ──► the relay loop ──► every Connection
 client ──► Connection ──► Pump ──┘

One reading task per connection. One writing loop for the whole room. One stream between them, and that stream is the only place the conversations meet.

The example is examples/ChatServer.

A connection travels into a generator

The reading half of each session is a generator that takes the connection as a value:

generator Pump(c : Connection) : stream of int64;
var
    text : stringbuffer;
    message : int64;
    more : bool;
begin
    more := ReadLine(address c, address text) on fail use False;

    while more do
    begin
        if Val(ToString(text), address message) then
            emit message;

        Clear(address text);
        more := ReadLine(address c, address text) on fail use False;
    end;
end;

Pump(room[joined]) hands the generator its own copy of the connection — the same value semantics a task parameter has, and new in this release for generators and non-ordinal values alike. No two activations ever share a live handle by accident, for the same reason no two tasks could in the echo server.

Notice what the pump does not contain: no shutdown flag, no poison message, no registry of who is still connected. It reads until its peer hangs up — ReadLine answers False — and then it simply ends. That ending is load- bearing, and we will come back to it.

The seats, and the fan-in

while joined <= Clients do
begin
    room[joined] := Accept(l) on fail use room[joined];

    if joined = 1 then
        inbox := schedule Pump(room[joined]) buffer 16
    else
        schedule Pump(room[joined]) into inbox;

    joined := joined + 1;
end;

The first bind opens the channel; every further schedule … into joins another producer to it. This is the fan-in shape from the streams unit, unchanged — except that each producer’s upstream happens to be a socket.

The room array keeps a copy of every accepted connection. The pumps have their own copies for reading; the room’s copies exist for writing. A conversation has two directions, and this split gives each direction exactly one owner.

The relay loop

for word in inbox do
begin
    seat := 1;

    while seat <= Clients do
    begin
        WriteLine(address room[seat], Str(word)) on fail continue;
        seat := seat + 1;
    end;

    relayed := relayed + 1;
end;

This loop is the entire server logic of the chat. It drains the inbox and writes each message to every seat — including the speaker’s, so every client hears the whole room and can verify its own words came back.

One loop writing to all seats means one writer per connection, so no two messages can ever splice on the wire. The scalable structure and the safe structure are again the same structure: readers park in their own tasks, the writer is a plain loop, and nothing here is a callback.

A failed write is answered with continue — a seat whose client vanished mid-broadcast should not take the room down with it. The failure vocabulary is the one you already know: Epipe, the peer is gone.

The message is a value

Each client speaks a number: the speaker in the hundreds digit, the line number in the ones.

WriteLine(address c, Str(tag * 100 + line)) on fail continue;

and the receiving client reads the meaning back out with arithmetic:

if message / 100 = tag then
    mine := mine + 1
else
    neighbours := neighbours + 1;

That is deliberately primitive — and deliberately not a protocol. There is no parser here, no message format specification, no length prefix: a value goes in, the value comes out, and the wire carries text lines only because text is this layer’s transfer surface. The next layer of the network library makes the stream itself typed — stream of T over a connection, so a record travels as a record — and this example is the shape that surface will land in.

Shutdown is nobody’s job

Follow the endings. A client hears the whole room’s worth of messages and closes its connection. Its pump’s ReadLine answers False, and the pump ends. When the last pump ends, the channel has no producers left, so it closes — and the relay’s for loop simply ends, the way any loop over an exhausted stream ends.

 client closes ──► pump ends ──► last pump ends ──► channel closes ──► relay ends

Nobody counted the producers. Nobody broadcast a shutdown message. Nobody holds a flag. The room’s shutdown is its members leaving, expressed entirely by the lifetime rules the streams unit already gave you.

Many carriers

make -C examples/ChatServer run MICA_EXTRA_FLAGS="--tasking multicore,4"

The pumps now genuinely run at the same instant, on different cores, and the output is byte-identical to the single-carrier run: totals, not transcripts, because arrival order across speakers is not anybody’s to promise — but the counts are. Every client hears its own lines and every neighbour’s, exactly once each, and the two shared tally cells are exact because the statements that touch them are synchronized.

Chat server
listening on an operating-system chosen port
messages relayed  12
own lines heard   12
neighbour lines   24
room closed

Try it

  • Raise Clients to 5 and predict all three totals before running. (The relayed count is Clients × LinesPerClient; the neighbour count multiplies by Clients − 1. You should get 20, 20 and 80.)
  • Delete the if joined = 1 split and join every pump with into. The program stops at startup with “a producer was scheduled into something that is not a task-fed stream” — the first producer is what opens a channel, and into joins one that already exists. Today that answer arrives at run time; the next release makes it a compile-time diagnostic, because whether a stream is task-fed is knowable from its bind.
  • Make one client leave early: give it expected := expected / 2 and watch the totals shrink by exactly the lines it never read — while nothing hangs, because the room waits for departures, not for a message count, and a vanished seat fails its writes with Epipe, which the relay answers with continue.

What is not here yet

A production room would not keep its seats in a fixed array — it would need joins and leaves while the room runs, which wants one job stream feeding a pool of workers. That shape — fan-out, N tasks consuming one stream — is ruled in and lands in the next release beside the typed stream surface. This example’s room is fixed-size on purpose: it shows the crossing point, not the membership protocol.