Tcp is byte transport and nothing more. Tcp.connect and Tcp.listen return
opaque resource handles; read_into and write_all move bytes between the
kernel and a guest []byte with no staging copy. Framing, encoding, retries,
and protocol semantics are yours.
Every operation except close() suspends and returns Result[T, TcpError].
A client exchange
fn ping(addr: string, msg: string): int ! TcpError {
conn := Tcp.connect(addr)?
written := conn.write_all(msg.bytes())?
mut buf: []byte = List.with_capacity[byte](1024)
n := conn.read_into(buf, 1024)?
conn.close()
println("sent ${written}, received ${n}")
return n
}
fn main(): void {
match ping("127.0.0.1:9000", "ping\n") {
Ok(n) => println("${n} bytes back")
Err(ConnectionRefused { addr }) => println("refused by ${addr}")
Err(Other { code }) => println("errno ${code}")
Err(e) => println("connection failed")
}
}Addresses are host:port text. Tcp.connect suspends until the socket is
connected.
Reading
read_into(buf, max) reserves max spare bytes on buf, lets the kernel fill
that tail in place, and returns the count actually read. The count can be
smaller than max even on a healthy connection; 0 means the peer closed.
max <= 0 returns Ok(0) without touching the socket.
It appends — it never clears the buffer — so track the new region yourself:
fn drain(conn: TcpConnection): int {
mut buf: []byte = []
mut total := 0
for {
before := buf.len()
n := match conn.read_into(buf, 4096) {
Ok(c) => c
Err(e) => break
}
if n == 0 {
println("peer closed after ${total} bytes")
break
}
println("received ${n} bytes at offset ${before}")
total = total + n
}
conn.close()
return total
}
fn main(): void {
match Tcp.connect("127.0.0.1:9000") {
Ok(conn) => println("${drain(conn)} bytes total")
Err(e) => println("connect failed")
}
}A clean EOF (Ok(0)) and a reset (Err) are different events. EOF means the
peer finished; a reset means the exchange was interrupted and any in-flight
request is of unknown status.
Writing
write_all(slice) takes a borrowed [..]byte and loops host-side until every
byte is sent, returning the count. An empty slice returns Ok(0) with no I/O.
fn send_all(addr: string, lines: []string): int ! TcpError {
conn := Tcp.connect(addr)?
mut sent := 0
for line in lines {
sent = sent + conn.write_all(line.bytes())?
}
conn.close()
return sent
}
fn main(): void {
mut lines: []string = List.new[string]()
lines.add("HELLO\n")
lines.add("BYE\n")
match send_all("127.0.0.1:9000", lines) {
Ok(n) => println("wrote ${n} bytes")
Err(e) => println("write failed")
}
}string.bytes() yields the borrowed [..]byte that write_all expects — keep
the owning string alive for the duration of the call.
Servers
Tcp.listen(addr) binds; accept() suspends until a client arrives. Spawn the
handler so a slow peer cannot stall the accept loop.
fn serve(conn: TcpConnection): int {
mut buf: []byte = List.with_capacity[byte](4096)
mut total := 0
for {
n := match conn.read_into(buf, 4096) {
Ok(c) => c
Err(e) => break
}
if n == 0 {
break
}
total = total + n
}
conn.close()
return total
}
fn main(): void {
listener := match Tcp.listen("127.0.0.1:9000") {
Ok(l) => l
Err(AddressInUse { addr }) => {
println("${addr} is already bound")
return
}
Err(e) => {
println("bind failed")
return
}
}
for i in 0..8 {
match listener.accept() {
Ok(conn) => {
worker := spawn { serve(conn) }
println("connection ${i} dispatched")
}
Err(e) => {
println("accept failed; stopping")
break
}
}
}
listener.close()
}The listener stays owned by the accepting scope; each connection is handed to the task responsible for its lifetime. Do not copy an opaque handle into long-lived data and treat each copy as an independent socket.
Framing
A protocol loop must preserve bytes that arrive before and after a message boundary. Here is a two-byte big-endian length prefix with a size cap:
const MAX_FRAME: int = 65536
fn frame_len(buf: []byte, start: int): int {
hi := int(buf[start] ?? 0u8)
lo := int(buf[start + 1] ?? 0u8)
return hi * 256 + lo
}
fn read_frames(conn: TcpConnection): int {
mut buf: []byte = List.with_capacity[byte](4096)
mut consumed := 0
mut frames := 0
for {
available := buf.len() - consumed
if available >= 2 {
size := frame_len(buf, consumed)
if size > MAX_FRAME {
println("frame of ${size} exceeds the cap")
break
}
if available >= 2 + size {
frames = frames + 1
consumed = consumed + 2 + size
continue
}
}
n := match conn.read_into(buf, 4096) {
Ok(c) => c
Err(e) => break
}
if n == 0 {
if buf.len() > consumed {
println("EOF with a partial frame — truncated, not complete")
}
break
}
}
conn.close()
return frames
}
fn main(): void {
match Tcp.connect("127.0.0.1:9000") {
Ok(conn) => println("${read_frames(conn)} frames read")
Err(e) => println("connect failed")
}
}The three invariants worth copying: parse only when a complete frame is present, check the declared size against a cap before trusting it, and treat EOF with a partial frame as truncation rather than clean completion.
Length prefixes need a fixed byte order and overflow checks; delimiters need an escape rule and a maximum search length; text protocols need an explicit encoding. The TCP API deliberately guesses none of that.
Errors
TcpError names address conflicts (AddressInUse, AddressNotAvailable,
InvalidAddress), connection failures (ConnectionRefused, ConnectionReset,
ConnectionAborted, NotConnected), Timeout { durationMs },
PermissionDenied { message }, InvalidHandle, and Other { code }.
The safe wrappers currently map every negative host errno to
Other { code }. The named variants document the vocabulary the host may
eventually produce, so match them, but write an arm for Other and expect it
to be the one that fires today.
Lifetime
TcpListener and TcpConnection implement Drop, so normal scope exit, ?
propagation, and cancellation all release the host socket. close() is
synchronous, idempotent, and only needed to release early inside a long-lived
scope. A closed handle must not be used again.
Overload
A listening server needs bounds at several levels:
| Bound | Protects |
|---|---|
| Concurrent connection count | tasks, handles, memory |
| Per-read maximum | guest allocation growth |
| Frame or request size | parser memory and CPU |
| Idle / read / write deadline | resources held by stalled peers |
| Per-peer request rate | downstream work |
Spawning one unbounded task per accepted connection just moves overload from the accept loop into memory and scheduling. Acquire capacity before dispatching, and make cancellation close the listener plus every owned connection.
Effects
Connect, listen, accept, read, and write all carry Suspend — including
Tcp.listen, whose bind completes inline but uses the suspending call ABI.
Only close is synchronous, which is what lets it run from a Drop body.
A suspending read keeps the guest byte allocation alive across the host operation; raw pointers stay an implementation detail of the safe wrappers.
For cancellation behavior see Cancellation; for structured child tasks see Spawning.