Utility: serve - answer TCP connections until you quit
Status: Built. Verified end to end in QEMU (scripts/tcp_serve_test.py), checked on both sides -
the guest's own report and the bytes the test machine received over a socket outside the guest. That
test also runs serve twice on the same port, which is the regression for the listener leak below.
And hardware-verified on the HP T630 (2026-09-24), which is the run that matters most. Three
inbound connections from a separate machine on the LAN, each accepted, echoed and closed, every byte
returned unchanged. QEMU structurally CANNOT test this: SLIRP's only peer is the gateway, so an
unsolicited inbound connection from a real third party had never happened before that run. The whole
point of serve is answering a stranger, and until then only the guest's own side had been proved.
serve <port> [for]
Listen on <port> and KEEP ANSWERING: each connection is accepted, printed, echoed back and closed,
then it waits for the next one.
Waits until you press q. A duration bounds it instead: 30s, 5m, 2h, 1d, or a plain
number of seconds.
serve 8080 wait until q
serve 8080 5m give up after five minutes
serve 8080 90 or after ninety seconds
The first version of this capped the wait at 30 seconds, which is shorter than walking to another machine and typing - a server that stops listening because a timer ran out was not listening when somebody called. A duration is now something you ask for, not something imposed on you.
Why this is the interesting one
This is the machine acting as a host rather than a client, and it is the first command that does.
Everything else in the networking surface dials out: ping, net dns, sock and tcp all start the
conversation. serve answers one.
That needed the passive half of the TCP open - State::SynReceived, a listener that is not a
connection, and a SYN-ACK owed and sent by the poll step - none of which existed until it was written
(docs/tcp-design.md). It also needs the poll step itself: a connection accepted here makes progress
with no client asking, which is exactly what net-stack could not do before.
It keeps serving
The first version handled exactly one connection and exited, which made every use a coordination exercise: start it, race to connect before it gave up, start it again. It now serves until you stop it.
Still not a daemon, and the distinction is worth keeping: it runs in the foreground, holds the
prompt, and ends when you press q. A server that outlives its shell is a service with a contract of
its own (§26.2).
Arguments
<port> | 1 to 65535. The port to answer on |
[for] | optional. How long to wait: 30s, 5m, 2h, 1d, or a plain number of seconds. Omitted, it waits until you press q |
Output
gsh> serve 8080
listening on 192.168.4.37:8080 - answering connections
[q] quit
still listening - 10s, 0 served
accepted a connection (1)
received 11 byte(s): knock knock
echoed 11 byte(s) back
closed - waiting for the next connection
[q] quit is the shell's, once, 2 s in, as for every command that waits; it is not repeated on the
status lines.
With a duration the reminder reads still listening - 10s of 300s, 0 served. When it ends it says
serve: 300s elapsed, N connection(s) served (a duration ran out) or serve: stopped after N connection(s).
The address is asked of net-stack rather than remembered, so it is the one the stack holds now and
cannot drift from a changed lease. net-stack logs the other side of the same story - an inbound SYN
by address and port, the handshake completing, and a connection refused for want of a slot - so "the
SYN never arrived" and "the SYN arrived and we never answered" are not the same silence.
Bytes outside printable ASCII render as ., so a peer cannot spray control codes at the terminal.
Bounds
Waits for as long as you asked - by default until q, Q or ESC - and at most 10 seconds for an
accepted peer to send something. A duration whose NUMBER is over a year of seconds is refused as a
typo rather than honoured. The check is made on the seconds, after the unit is applied, so 400d is
refused too.
While it waits it says so every ten seconds, because two minutes of a mute prompt is indistinguishable from a wedged one.
At most MAX_LISTEN ports are listened on across the whole system, and an accepted connection takes
one of the connection slots - so a machine already holding its maximum connections refuses new ones
by dropping the SYN (saying so in its log) and the peer retries.
The port is RELEASED, on every exit
Closing the listener is an explicit operation, and serve performs it whether it finished, timed
out, or you pressed q.
This is not automatic and its absence was a real leak. Dropping the client's capability does not
tell net-stack anything - the kernel revokes the holder's authority, but net-stack's listener table
is its own state and nothing walks back to it from a dropped cap. Found on a Raspberry Pi 2: the
second serve 8080 was refused, and stayed refused until the service restarted. With MAX_LISTEN at
2, that leak is two runs deep. Connections are released the same way - net-stack reaps one that has
finished and revokes it, so the holder's next call gets CapRevoked from the kernel.
It does not always land, and that is recorded rather than hidden (backlog/28). The release is a
round trip, and measured over a dozen QEMU runs it failed a couple of times - leaving the port
registered and the next serve on it refused. serve asks once and says
serve: the port was NOT released (...) - the next serve on it will be refused if that fails, so
the condition is visible; the underlying asymmetry
is that the kernel does not tell a service when a capability is dropped, so a port cannot release
itself and a client that dies will always leak one.
What it is made of
Three capabilities, each minted by net-stack and held by the shell (§7.10, the same delegated
resource mechanism as a file and a UDP socket):
- The listener.
net-stackmints it when the port is granted. Closing it stops new connections and leaves connections already accepted from it running. - The connection.
acceptmints a second capability and grants it to the caller. From that point the connection is a thing the client HOLDS - reading needsREAD, sending and closing needWRITE, and a stale handle getsCapRevokedfrom the kernel rather than a wrong answer from net-stack. - A send cap to
net-stack, acquired by name.
A peer's MAC is taken from the frame its SYN arrived in, so an inbound connection needs no ARP at all: the segment reached us from that address, which is the one fact that never has to be asked for.
Failure
| what happened | what you see |
|---|---|
| the stack has no address yet | net-stack would not listen on that port, and net-stack's log says which of the three reasons it was |
| the port is already listened on, or every listener slot is in use | the same line; net-stack's log distinguishes them |
| nobody connected in time | serve: <n>s elapsed, 0 connection(s) served, when a duration was given |
you pressed q while it waited | serve: stopped after N connection(s), and the port is released |
you pressed q while the port was still being opened | serve: aborted; nothing was granted, so nothing is released |
| the peer connected and sent nothing | the peer connected but sent nothing |
Conventions
Obeys most of utilities/0_conventions.md: the argument is a word rather than a flag, raw facts
without editorialising, and q escapes every blocking wait. serve help prints its
help_block and serve version its version (rules 5 and 6); until 2026-10-10 serve was missing from
the shell's UTILS list, so serve version was read as a port and refused. Its argument is a port,
never a path, so it is in the shell's NO_PATH_CMDS (rule 9).