Utility: tcp - one TCP transaction

Status: Built. Hardware-verified on five machines across four instruction sets and four ethernet controllers (Raspberry Pi 2, Raspberry Pi 4, StarFive VisionFive 2, HP T630, Dell Wyse 5070), and in QEMU against a decoded packet capture. See docs/tcp-design.md.

tcp <ip> <port> [text]

Open a TCP connection to <ip>:<port>, send text if given, print whatever comes back, and close.

Why one transaction and not a session

net-stack drives the connection from inside the request that asked for it, so tcp is one complete exchange: connect, send, read, close.

This used to be BLOCKED, and it is not any more - it is simply unbuilt, which is a different thing and worth stating precisely. The blocker was that net-stack received client requests and nic-driver replies on one untagged endpoint, so any unsolicited driver traffic consumed client messages; a background connection was therefore impossible rather than merely absent (docs/net-tags-design.md §1). That is resolved: the service now sifts what arrives on every driver conversation (phase 2), keeps a displaced client request in a bounded stash rather than dropping it (phase 3, §9), carries a correlation tag on the client hop so a late reply is discarded instead of misread (§8), and runs a bounded poll step when no client is asking.

Both halves of the API that this needed now exist. A long-lived connection is a thing a client can HOLD - a TCP connection as a delegated resource capability, the way a file and a UDP socket are (gs::net::Net::connect, which godspeed uses for its TLS connection to the registry) - and listen/accept let the machine serve rather than only fetch (serve, utilities/49_serve.md). tcp itself stays one transaction.

What the protocol actually implements

The three-way handshake, sequence numbers and cumulative acknowledgement, the retransmit queue with RTO estimation per RFC 6298 and Karn's algorithm, in-order delivery with a bounded out-of-order queue, window advertisement and update, and the full close sequence through TIME_WAIT.

Plus, since the first hardware run:

maximum segment sizeadvertised on every SYN and honoured from the peer's. Before this the stack advertised nothing, so every peer had to assume the RFC 1122 default of 536 bytes per segment
congestion controlRFC 5681: slow start, congestion avoidance, fast retransmit on three duplicate acknowledgements, and NewReno fast recovery. Sending is bounded by the smaller of the peer's window and the congestion window - the receiver's limit and the network's are different questions
persist timera shut receive window is probed, so a lost window update cannot deadlock two correct implementations

A startup self-test proves the parts the network cannot reach. Congestion control reacts to loss, fast retransmit to reordering, the persist timer to a closed window - none of which a healthy LAN or the QEMU backend produces. So net-stack drives its own state machine against a synthesised peer when it starts, using the same encoder and parser that serve the wire, and logs net-stack: tcp selftest PASS - 31 checks. A guard nobody has seen fire is not evidence that it works.

Arguments

<ip>an IPv4 address in dotted form, for example 10.0.2.2. Not a hostname - resolve it with net dns first, so that a name that will not resolve is a separate, visible failure from a peer that will not answer
<port>1 to 65535
[text]optional. Remaining words are joined with single spaces and sent as the request body

Output

tcp: 13 byte(s) back
hello, world.

Bytes outside printable ASCII render as ., so a binary reply cannot spray control codes at the terminal. An empty reply is reported as connected to nothing rather than as silence, because net-stack answered - the reason it produced no data is in its log, and the two cases are different faults.

Failure

Every failure is named rather than folded into "it did not work":

what happenednet-stack logs
the peer refused, or reset an established connectionpeer reset the connection
six retransmissions went unacknowledgedno acknowledgement after 6 retransmissions
no answer to the opening SYNno answer to our SYN
the connection worked but returned nothing before the budgetno data and no fault (budget expired)
the network is not configured yettcp asked for before the stack is configured

Bounds

One transaction is capped at 8 seconds and at a fixed number of protocol steps; a connection holds a 2 KiB send arena and a 256 KiB receive arena (8 KiB if the large one could not be had), and at most 2 connections exist at once; when they are all taken, one whose client has not used it for 15 s is released for a new one. The advertised receive window is the free space in that receive arena, scaled (RFC 7323), so the peer is told exactly what this machine can hold (docs/tcp-design.md).

(This said "at most four connections" until 2026-09-15. MAX_CONNS was reduced from 4 to 2 when service_main's stack frame reached 37% of the 256 KiB user stack on arm32, and the spec was not brought with it - the kind of drift scripts/facts_check.py exists to catch.)

Escaping it

q, Q or ESC aborts the wait and returns to the prompt (tcp: aborted), and [q] quit is advertised once the wait lingers past about two seconds (gs::net::Net::with_notice). A transaction that gets no answer at all gives up after 20 seconds (gs::net::TCP_SECS) with tcp: net-stack did not answer within 20s - see its log.

This was not always true, and its absence cost a power cycle. The request used a bare request_with_reply, which parks the shell inside the syscall where it cannot read the keyboard - so on a Dell Wyse a tcp net-stack never answered froze the prompt with no way out (backlog/fixed/29).

What q stops is the SHELL'S wait, not net-stack's transaction: net-stack keeps working its own 8 s budget out. That is rule 11's concern and it is named here rather than glossed. It does not wedge the next command - the abandoned reply is discarded by its correlation tag when it lands - but a tcp issued immediately after an abort can wait behind the one you abandoned.

Conventions

Obeys most of utilities/0_conventions.md: arguments are words rather than flags, and the command reports raw facts without editorialising. tcp help prints its help_block and tcp version its version (rules 5 and 6); until 2026-10-10 tcp was missing from the shell's UTILS list, so tcp version printed the usage instead. Its arguments are an address and a port, never a path, so it is in the shell's NO_PATH_CMDS (rule 9). Rule 10: the wait is q-escapable.