Most languages make you choose. Write the server the readable way — accept a connection, handle it, accept the next — and it serves one client at a time. Write it the scalable way and the readable version is gone: callbacks, promises, an event loop, a state machine where a straight line used to be.
Mica does not have that trade, because the thing that made it necessary is not in the language. A wait here suspends one task, not the program. So the readable spelling is the scalable one:
concurrent
repeat
accepted := Accept(listener) on fail continue;
schedule Session(accepted);
until False;
end;That is a server. Every connection gets a task, every task waits at its own socket, and the carrier runs whoever is ready.
The example is
examples/EchoServer.
Listening
imp
Listener, Connection, ListenTcp, Port, Accept, DialTcp, Close, CloseListener : net;
WriteLine, ReadLine : net;
OsError : posix;
listener := ListenTcp(0) on fail leave;
port := Port(listener) on fail leave;ListenTcp answers a Listener bound on every interface of the machine.
Passing port zero asks the operating system to choose a free port, and
Port reads back which one it chose — the idiom that keeps a program from ever
colliding with something else on a fixed number.
Both calls are fallible, and their reasons are the ones you already know from
files: Eaddrinuse when another program holds the port, Eacces for a
privileged one. There is no separate network error vocabulary to learn.
CloseListener gives the descriptor back. Like Close on a file it is
nil-safe and repeatable, so it is the partner of defer on every path out:
defer CloseListener(address listener);Accepting, and what parks
accepted := Accept(listener) on fail leave;While no connection is waiting, this call parks: it suspends the calling task and nothing else. Other tasks keep running, and the arriving connection wakes this one. The same is true of every transfer below — the dial, the reads, the writes.
That single property is what removes the trade at the top of this page. You are not paying for a thread per connection (a task’s stack is not an OS thread), and you are not writing a callback to avoid paying for one.
A scope’s deadline reaches a parked task exactly as it reaches any other, so an unbounded accept loop still ends when its scope does:
concurrent
ScopeDeadline(5000); { the whole server, five seconds }
repeat
accepted := Accept(listener) on fail continue;
schedule Session(accepted);
until False;
end;When the deadline fires, the parked Accept is abandoned and the block moves
on. You do not check a flag; the loop simply ends.
A connection is a value
task Session(c : Connection);The accepted connection travels into its session by value — the task gets
its own copy, deep-copied when you schedule it, exactly as any other value
would be. Two consequences, both good:
- the accept loop can reuse its own variable on the very next turn, because the session is not looking at it;
- no two tasks can ever name one connection by accident.
This is not a network feature. It is Mica’s ordinary copy semantics, applied to a record that happens to hold a socket.
Reading lines
task Session(c : Connection);
var
text : stringbuffer;
more : bool;
begin
more := ReadLine(address c, address text) on fail use False;
while more do
begin
WriteLine(address c, ToString(text)) on fail continue;
Clear(address text);
more := ReadLine(address c, address text) on fail use False;
end;
Close(address c);
end;ReadLine appends into a stringbuffer you own — one builder serves a whole
conversation, so a busy session allocates nothing in its steady state — and it
answers False once the peer has hung up with nothing left. That is why the
session loop needs no other end condition.
A socket delivers bytes, not lines, and the unit takes the whole of that problem off your desk:
| what arrives | what you read |
|---|---|
| a line split across several receives | one line |
| a code point split across two receives | one character |
CRLF line endings, even split | one line, no trailing return |
| a lone carriage return | content, kept |
| a last line with no terminator | one line |
Text crosses the wire as UTF-8 whichever encoding your program was compiled
for, so a --platform ...,utf-32 program and a --platform ...,utf-8 program
read each other exactly.
WriteLine puts the text and its terminator on the wire as one transfer —
so two tasks writing to the same connection can never splice their lines
together.
Bytes, when you have a protocol of your own
WriteBytes(address c, address frame[0], 12) on fail leave;
taken := ReadFully(address c, address frame[0], 12) on fail leave;WriteBytes finishes what it starts: a stream socket may take fewer bytes than
you offered, and completing the transfer is the library’s job, not yours — which
is why nothing in this surface hands you a partial-write count to check.
For reading you choose your semantics:
ReadBytesanswers with whatever has arrived, up to the count you asked for;ReadFullyassembles the whole frame, and answers short only when the peer closed first — which is how you tell an ended stream from a truncated frame.
Addresses and names
c := DialTcp("127.0.0.1", port) on fail leave; { a literal }
peer := Resolve("example.org") on fail leave; { a name }
c := DialTcpAt(peer, 443) on fail leave;DialTcp takes a dotted-quad address; a spelling that is not one fails
Einval. Names go through Resolve, which is a separate call on purpose:
it is the only entry in the unit that waits without parking, because POSIX
name resolution has no non-blocking form. Keeping it visible means a program
that dials a literal address never pays for a facility it does not use, and a
program that resolves knows exactly where it can stall.
ParseAddress and AddressText convert between the two spellings, and
Loopback() answers this machine’s own address.
Failures are the ones you already know
Everything environmental arrives on the failure channel as an OsError:
Econnrefused | nothing is listening on that port |
Etimedout | nothing answered at all |
Eaddrinuse | another program holds the port |
Epipe | the peer is gone |
Econnreset | the peer vanished instead of closing |
Ebadf | you already closed this connection |
That last one is worth a sentence. Reading from a closed file traps, because a file handle is your program’s private possession and using a closed one is a program error. A connection is different: its other end can end it at any moment, so a network program is already written to answer failures on every transfer — and a reason it can handle beats a trap it cannot.
IsOpen and IsListening answer softly and never fail, and a Connection you never
dialed reads as closed rather than as descriptor zero.
Many carriers
Nothing above changes when you compile the same source with
mica --tasking multicore,4 ...The accept loop still parks, the sessions still run, and the counts still come
out exact — a shared counter is exact because the statement that touches it is
synchronized, which is the same rule that governs every other shared cell in
the language. What multicore adds is that sessions genuinely run at the same
instant, on different cores.
What is not here yet
IPv6, UDP, and Unix-domain sockets are not in this first cut, and neither is
TLS. Descriptor(c) and ListenerDescriptor(l) hand you the raw socket for
anything the curated surface does not carry yet, so nothing is a dead end.