Skip to the content.

Home › Language Reference › TCP / UDP

TCP / UDP

Aether provides built-in TCP server and client support via tcp_listen and tcp_connect.
Both are event-driven: a single background I/O thread drives all connections via epoll/kqueue (mio), and events are dispatched to your Aether callbacks on the main thread.


Server

tcp_listen(addr) / tcp_listen(addr, opts)

Binds a TCP listener and returns a tcp_server object.

let server = tcp_listen("0.0.0.0:8080")
let server = tcp_listen("0.0.0.0:8080", { "delimiter": "\n" })

opts — optional dict:

Key Type Effect
"delimiter" string Split incoming bytes on this string; on_message fires once per complete frame

Server lifecycle events

Register callbacks before calling server.accept().

Method Callback signature Fires when
on_listen(fn() { }) Server is bound and ready
on_connect(fn(conn) { }) conn = tcp_connection A client connects
on_message(fn(conn, data) { }) data = array of ints Data arrives from conn
on_disconnect(fn(conn) { }) conn = tcp_connection Client closes the connection
on_error(fn(err) { }) err = string An I/O error occurs
on_timeout(fn() { }) No activity within the timeout window

server.accept()

Starts the event loop. Blocks until server.close() is called or Ctrl+C is pressed.

server.close()

Initiates graceful shutdown: stops accepting new connections, closes all active connections, exits the event loop.

Echo server example

let server = tcp_listen("127.0.0.1:9000")

server.on_listen(fn() {
    print("listening on 9000")
})

server.on_connect(fn(conn) {
    conn.write("Welcome!\n")
})

// data is an array of byte ints [104, 101, 108, 108, 111, ...]
// conn.write accepts a string or a byte array
server.on_message(fn(conn, data) {
    conn.write(data)
})

server.on_disconnect(fn(conn) {
    print("client left")
})

server.accept()

Connection object (conn)

Passed to on_connect, on_message, and on_disconnect callbacks.

Method Description
conn.write(data) Send data to the client. Accepts a string or an array of ints (bytes).
conn.close() Close this connection. Fires on_disconnect.

Client

tcp_connect(addr)

Creates a client object targeting addr. No connection is made until client.start() is called.

let c = tcp_connect("127.0.0.1:9000")

Client lifecycle events

Method Callback signature Fires when
on_connect(fn() { }) Connection established
on_message(fn(data) { }) data = array of ints Data arrives from server
on_disconnect(fn() { }) Server closes the connection
on_error(fn(err) { }) err = string An I/O error occurs
on_timeout(fn() { }) No activity within the timeout window

client.write(data)

Send data to the server. Accepts a string or an array of ints (bytes).

client.start()

Opens the connection and starts the event loop. Blocks until client.close() is called or the server disconnects.

client.close()

Closes the connection and exits the event loop.

Client example

let sent = [0]
let msgs = ["hello\n", "world\n"]

let c = tcp_connect("127.0.0.1:9000")

c.on_connect(fn() {
    c.write(msgs[sent[0]])
    sent[0] = sent[0] + 1
})

c.on_message(fn(data) {
    if (sent[0] < len(msgs)) {
        c.write(msgs[sent[0]])
        sent[0] = sent[0] + 1
    } else {
        c.close()
    }
})

c.start()

Mutable state in callbacks

Aether closures capture variables by copy, not by shared reference.
Reassigning a primitive inside a callback does not persist across calls:

let count = 0
server.on_message(fn(conn, data) {
    count = count + 1   // ← modifies a per-call copy; count stays 0 externally
})

Use an array or dict instead — these carry reference semantics through Rc:

let count = [0]
server.on_message(fn(conn, data) {
    count[0] = count[0] + 1   // ← mutates the shared array; persists across calls
    if (count[0] >= 10) {
        server.close()
    }
})

The same pattern applies to any shared list (e.g. connected clients in a chat server).


Signal handling (Ctrl+C)

server.accept() / client.start() register a SIGINT handler automatically.

Press Behaviour
First Ctrl+C Graceful shutdown: stop accepting, drain existing connections up to AETHER_GRACEFUL_SHUTDOWN_TIMEOUT_SECS (default 5 s)
Second Ctrl+C Force exit
AETHER_GRACEFUL_SHUTDOWN_TIMEOUT_SECS=30 aether server.ae

Architecture

Aether script (main thread)           I/O thread (mio Poll)
────────────────────────────          ──────────────────────
server.accept()                       run_server_io_loop()
                                     
   recv_timeout(10ms) ◄─ event_tx ──┤  accept / read / write
                                       streams: HashMap<id, TcpStream>
   dispatch callback                 
     conn.write(data)                
       cmd_tx.send(Write) ──────────►│  write_all(data)
       waker.wake()      ──────────►│  (wakes immediately)
                                     
   server.close()                    
     cmd_tx.send(Shutdown) ─────────►│  close all  drop event_tx
     waker.wake()          ─────────►│
   recv_timeout  Disconnected       
   loop exits                        

All TcpStream handles live exclusively on the I/O thread.
conn.write() and server.close() send commands through an mpsc channel; a mio::Waker wakes the I/O thread immediately rather than waiting for the next poll tick (≤ 50 ms).


Memory per idle connection

Resource Cost
TcpConnectionState (Aether heap) ~390 B
Read buffer (I/O thread heap) 0–4 KB (grows to frame size)
Kernel socket buffers 8–256 KB (OS-controlled)
Total ~8–260 KB

The I/O thread is shared across all connections (unlike the old one-thread-per-connection model that cost ~8 MB per connection). Practical limit is ~100 k connections per GB of RAM (kernel buffers dominate).



UDP

udp_bind creates a UDP socket. UDP is connectionless — each datagram arrives with the sender’s address; send_to addresses each reply explicitly.

udp_bind(addr)

let sock = udp_bind("0.0.0.0:9000")

Events

Method Callback signature Fires when
on_message(fn(data, addr) { }) data = array of ints, addr = "host:port" string A datagram arrives

sock.send_to(data, addr)

Send data (string or byte array) to addr ("host:port" string).

sock.listen()

Starts the event loop. Blocks until sock.close() or Ctrl+C.

sock.close()

Stops the event loop.

UDP echo server example

fn main() {
    let sock = udp_bind("127.0.0.1:9000")

    sock.on_message(fn(data, addr) {
        sock.send_to(data, addr)
    })

    sock.listen()
}

Async inside callbacks

Both TCP and UDP callbacks support await and .then() — the dispatch loop ticks the async event queue on every iteration:

server.on_message(fn(conn, data) {
    set_workers(1)
    let resp = await http_get("https://api.example.com")
    conn.write(resp)
    server.close()
})

See also


← Async/Await    Configuration →