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 endsNobody 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 closedTry it
- Raise
Clientsto 5 and predict all three totals before running. (The relayed count isClients × LinesPerClient; the neighbour count multiplies byClients − 1. You should get 20, 20 and 80.) - Delete the
if joined = 1split and join every pump withinto. 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, andintojoins 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 / 2and 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 withEpipe, which the relay answers withcontinue.
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.