Chapter 6 · Part IV

A TCP Chat Server

Chapter 5's tasks never touched the same data at the same time. This project's tasks have to — several clients, one shared list of who's connected — and the contract that matters most here is about who is allowed to touch that list at all.

Several clients connect to one server over plain TCP; whatever one client types, everyone else sees. The interesting design problem is not the networking — net/Tcp_Socket and net/Server_Socket handle the bytes — it is concurrency safety: with one task per connected client all running at once, something has to own the list of who is currently connected, and it has to be exactly one thing.

Problem

Two decisions get made before any networking code, the same discipline as every project so far, applied to a wire instead of an argv or a file. First, the protocol: one line per message, formatted "NAME: text\n", with no framing beyond the newline itself — Tcp_Socket's read_line/send_line already give a line boundary for free, so the protocol is "trust the transport's line boundary," not anything more elaborate:

class Chat_Protocol
create
  make() do end
feature
  format_message(sender: String, text: String): String do
    result := sender + ": " + text
  end
end

It is deliberately its own tiny module, chat_protocol.nex, interned by both the server and the client — the format lives in exactly one place both sides load, rather than being duplicated by convention across two files that could quietly drift apart.

Second, and more consequential: the concurrency design. With one task reading each client's socket, and all of them needing to broadcast to a shared list of connections, the design settles on a single rule before any of it is built — one owner, no locks. Exactly one task ever touches the list of connected clients. Every other task only ever sends it an event over a channel and trusts that owner to serialize access. This is a contract about concurrency itself, not about a value — a promise the code's own shape enforces rather than a comment asking every future contributor to remember not to reach into shared state directly.

Design as Contract

The events a client-handling task can raise are a closed union — exactly what a connection lifecycle can produce, and nothing the hub has to guess at:

union Hub_Event
  Client_Joined(sock: Tcp_Socket)
  Broadcast(sender: Tcp_Socket, text: String)
  Client_Left(sock: Tcp_Socket)
  Shutdown
end

Chat_Hub is the single owner the design promised — its run method holds the only variable in this entire program that names the list of connected clients, and every other piece of code that needs to affect that list does so exclusively by sending an event and letting the hub react:

class Chat_Hub
create
  make() do end
feature
  -- The single owner of `clients`. Runs until it receives Shutdown.
  run(events: Channel[Hub_Event]) do
    let clients: Array[Tcp_Socket] := []
    ...
    let ev: Hub_Event := events.receive()
    match ev of
      when Client_Joined(sock) then clients.add(sock)
      when Client_Left(sock) then ...
      when Broadcast(sender, text) then ...
      when Shutdown then running := false
    end
    ...
  end
end

Read that signature again: clients is a local variable inside run, not a field on Chat_Hub, not visible anywhere else in the program, and never passed to another task. That is what "single owner" means as code rather than as a design intention — there is no mechanism by which two tasks could touch it at once, because only one task's stack frame ever names it.

Build

Source

The complete, current code for this project is at examples/contracts_at_work/06_chat on GitHub.

Every connected client gets one handler task, spawned from accept_clients as connections arrive:

let maybe_client: ?Tcp_Socket := server.accept(30000)
if maybe_client /= nil then
  let client: Tcp_Socket := maybe_client
  let name_line: ?String := client.read_line()
  let name: String := "anon"
  if name_line /= nil then
    name := name_line
  end
  let h: Task := spawn do
    this.handle_client(client, name, events)
  end
  handlers.add(h)
end

handle_client is the loop each connected client actually runs inside — read a line, forward it to the hub as a Broadcast, repeat until the client disconnects:

handle_client(sock: Tcp_Socket, name: String, events: Channel[Hub_Event]) do
  events.send(create Client_Joined.make(sock))
  ...
  let line: ?String := sock.read_line()
  if line = nil then
    running := false
  else
    events.send(create Broadcast.make(sock, protocol.format_message(name, line)))
  end
  ...
  events.send(create Client_Left.make(sock))
  sock.close()
end

Graceful disconnect falls directly out of that shape: read_line() returning nil means end of stream, which the loop treats as an ordinary exit condition rather than an error — it tells the hub Client_Left and closes its own socket, and the hub drops that client from the broadcast list the same way it would for any other event. No connection is ever left dangling in the list because there is no path through this method that skips telling the hub it is gone.

Broadcasting itself is the hub's own loop, run once per Broadcast event, skipping only the sender:

when Broadcast(sender, text) then
  let i: Integer := 0
  from i := 0 until i = clients.length do
    let member: Tcp_Socket := clients.get(i)
    if member /= sender then
      member.send_line(text)
    end
    i := i + 1
  end

Test

The check suite spins up a real server on an ephemeral port — port 0, which asks the OS to pick a free one — and connects two real, in-process Tcp_Socket clients against it, rather than mocking any part of the networking:

let server: Server_Socket := create Server_Socket.make(0)
server.open()
...
let alice: Tcp_Socket := create Tcp_Socket.make("localhost", server.port)
alice.connect()
alice.send_line("alice")
...
alice.send_line("hello from alice")
let bob_received: ?String := bob.read_line()
c.check("bob receives alice's message", "alice: hello from alice", bob_received)

This is a genuine integration test, not a unit test with the network faked out — real sockets, a real accept loop, a real hub task processing real channel events, all inside one process on one port that only this test run owns. Three checks (two handlers spawned, and a message correctly received in each direction) are enough to prove the whole pipeline — protocol, accept, hub, broadcast — works end to end, because nothing along that path could produce the right output for the wrong reason.

Takeaways

Shared mutable state doesn't need a lock if exactly one task is ever allowed to touch it. Every other task can affect that state only by sending an event and trusting the owner to react — a rule enforced by the code's own shape, since no other task's stack frame ever names the state at all, rather than a comment asking every future contributor to remember not to reach in directly. And not every rough edge you find belongs to the thing you're building on top of: a client that closes its own socket while its own reader task might still be blocked on it is worth handling on its own terms — treating a closed-out-from-under-it read exactly like an ordinary disconnect — rather than assumed away.