Standard library Dodo 0.1.4

Networking

Portable IP and DNS codecs, owned nonblocking sockets, explicit execution and resolver providers.

On this page

Networking has three separate steps: identify an address, open a transport, and exchange bytes or datagrams. std/net provides portable address values and operation policies; std/net/native opens operating-system sockets. Start with a loopback address and native.connect_with so you can learn the connection contract without DNS or an external service.

Task Package/API What it does not do
Parse or print an IP address std/net No socket or DNS lookup.
Resolve a hostname native.resolve Does not connect; the system lookup may block.
Connect with a deadline native.connect_with Does not encrypt or frame application messages.
Read/write under a policy std/net/operations Does not allocate or start a scheduler.
Exchange separate packets native.UdpSocket No ordering, delivery, or stream semantics.
Encode/decode DNS packets std/net/dns No ready-made DNS resolver, cache, or retries.
Fetch an HTTP URL Hosted HTTP Select HTTPS explicitly for TLS.

TCP is a byte stream: one write need not correspond to one peer read. Define a message boundary (a length, delimiter, or protocol such as HTTP) before building a request/response exchange. UDP preserves each message boundary, but delivery and ordering belong to your application protocol.

Quickstart

Start this Python 3 peer in terminal 1; it accepts exactly one local connection:

python3 -u -c "import socket; s = socket.socket(); s.bind(('127.0.0.1', 8081)); s.listen(1); print('ready', flush=True); c, _ = s.accept(); c.close(); s.close(); print('accepted')"

Wait for ready, then use terminal 2 in your example directory. If bind reports an occupied port, stop your previous example or change 8081 in both programs. The listener is bound only to loopback.

Save this as network_start.dodo:

package network_start
import "std/net"
import "std/net/native"

fn connect() -> void!net.Error {
    address := net.parse_socket(b"127.0.0.1:8081")?
    clock := native.MonotonicClock {}
    now := clock.now_ms()
    if now > (~0u64) - 5000 { return err(net.failure(net.ErrorKind.TimedOut)) }
    operation := net.Operation.until(now + 5000)
    cancel := net.NeverCancel {}
    stream := native.connect_with(&address, &operation, &mut clock, &cancel)?
    peer := stream.peer_address()?
    assert_eq(peer.port, 8081u16)
    stream.close()?
    return ok()
}
fn main() -> i32 {
    match connect() {
        ok() => { return 0 },
        err(reason) => {
            if reason.kind == net.ErrorKind.ConnectionRefused { return 2 }
            return 1
        },
    }
}
dodo run network_start.dodo

Expected output: none from Dodo; exit 0 means TCP connected to port 8081 and closed cleanly. The peer prints accepted and exits. Restart it for another run. The address and operation policy use fixed-size values; no caller byte workspace or allocator is needed for this connection.

Exit 2 means connection refused: start the peer and check the port. Other failures exit 1: inspect kind and native code; check an invalid address, firewall or deadline before retrying. Never spin on WouldBlock; wait for the appropriate readiness event under a deadline. The client uses the convenient bounded connector; individual nonblocking steps remain available below. For a complete request/response task, continue to HTTP.

std/net/dns supplies portable DNS packets and resolver policies; use native.resolve_numeric for numeric hosts without invoking system DNS. native.resolve explicitly uses the hosted resolver and caller workspace; it can block independently of a connection deadline.

API and contracts

std/net contains address values, parsers, formatting, errors and operation policy. It imports portable byte-I/O error support and works on freestanding targets. std/net/dns contains DNS message handling. std/net/operations composes structural stream, clock, cancellation and readiness capabilities without selecting a platform. Importing any of these packages does not open a socket or require an allocator, thread, filesystem, TLS backend or scheduler.

std/net/native selects the Linux or Windows socket adapter. Its public API is identical on Linux x86-64 GNU and Windows x64. Other hosted ABIs currently produce a compiler error. std/net/linux and std/net/windows may be imported explicitly only for their matching target. There is no allocating network convenience package in this release.

Addresses and storage

Address parsing and formatting can be tested without a network. This example normalizes IPv6 spelling and checks the port, using a fixed output array:

package address_example
import "core/bytes"
import "std/net"

@test
fn normalizes_a_socket_address() {
    address := net.parse_socket(b"[2001:0DB8:0:0:0:0:0:1]:443")!
    assert_eq(address.port, 443u16)
    output := [0u8; 64]
    count := net.format_socket(&address, &mut output)!
    assert(bytes.equal(&output[..count], b"[2001:db8::1]:443"))
}

IpAddress contains family: Family.V4 | Family.V6 and [16]u8 network-order octets. IpAddress.v4(a, b, c, d) uses the first four octets and clears the remaining twelve; IpAddress.v6(octets) uses all sixteen. clone() makes an explicit owned copy and equals(&other) compares values. SocketAddress.new(ip, port) adds a u16 port and initializes its numeric scope_id to zero.

parse_v4, parse_v6 and parse_ip take &[u8]. IPv4 accepts exactly four canonical decimal octets, rejecting leading zeroes, signs, whitespace, and legacy abbreviated/octal/integer forms. IPv6 accepts eight hexadecimal groups, one optional :: compression and optional final dotted IPv4. Parsing follows the address forms in RFC 4291 section 2.2.

parse_socket accepts 127.0.0.1:80, [::1]:443 and [fe80::1%3]:80. IPv6 must be bracketed. Ports are mandatory decimal values from 0 through 65535; numeric zone indices range from 0 through 4294967295. Zone names require a separately selected platform interface provider. A zone is not accepted by parse_ip. These socket strings are not URI authorities: URI escaping is an HTTP concern.

format_ip(&ip, output) and format_socket(&address, output) return a byte count. They write no NUL and leave output unchanged on BufferTooSmall. IPv6 output uses lowercase hexadecimal, removes leading zeroes and compresses the first longest run of at least two zero groups, following the normalization rules in RFC 5952 section 4. Embedded IPv4 input may be formatted as equivalent hexadecimal groups. A 64-byte output buffer suffices for every supported address including scope and port. Formatting uses a fixed 64-byte local workspace; parsing uses fixed small arrays. Neither allocates.

Public address fields allow explicit construction; callers must preserve the IPv4 trailing-zero invariant. IPv4 adapters reject a nonzero scope ID. IPv6 listeners explicitly use IPV6_V6ONLY, so IPv4 and IPv6 listeners have separate binds. Port zero asks the OS to choose a port; local_address() reports it.

TCP ownership, progress and readiness

import "std/net"
import "std/net/native"

fn connect_local() -> native.TcpConnection!net.Error {
    address := net.parse_socket(b"127.0.0.1:8080")?
    clock := native.MonotonicClock {}
    cancel := net.NeverCancel {}
    now := clock.now_ms()
    if now > (~0u64) - 2000 { return err(net.failure(net.ErrorKind.TimedOut)) }
    operation := net.Operation.until(now + 2000)
    return native.connect_with(&address, &operation, &mut clock, &cancel)
}

TcpConnection.connect(&address) creates an owned socket and starts a nonblocking connection attempt. poll_connected() returns false while pending, true after success or a typed error. It must succeed before stream I/O; an uncompleted connection reports WouldBlock. A failed connect remains failed and cannot be retried or mistaken for a later successful connection; close it and create a new connection. peer_address() and local_address() return owned portable values.

TcpListener.bind(&address, backlog) requires a positive backlog, clamped to the platform signed integer range; the OS may further constrain it. accept() returns none when no connection is available, some(TcpConnection) on success or an error. There is no hidden worker thread. Accepted sockets are nonblocking.

PollSet.new() batches readiness checks for up to POLL_CAPACITY (256) sockets using one native poll call. Register a listener with listener.register(&mut set) or a connection with connection.register(&mut set, interest); each returns an index for set.ready(index). set.wait(timeout_ms) updates readiness and returns the number of ready registrations; an empty set returns zero immediately. Readiness includes hangup/error notifications: the next socket operation reports the result. set.clear() removes registrations for the next batch. Registrations borrow native handle values without transferring ownership; keep every registered owner open until the wait finishes. TcpConnection.closed() supplies an empty, safe-to-drop owner for caller-owned connection slots.

Every socket owns exactly one native descriptor/handle, moves as an aggregate, and releases it on close() or drop. close() is idempotent. A close error still invalidates the owner, and Linux interrupted close is never retried against a possibly reused descriptor. Destruction ignores close errors; call close() explicitly to observe them. Explicit raw handle access is unsafe and borrowed: raw_handle() never transfers ownership and does not authorize closing the handle or retaining an OS operation beyond the checked owner’s lifetime.

read(&mut[u8]) -> usize!io.Error and write(&[u8]) -> usize!io.Error perform one nonblocking OS attempt. Partial transfers are successful. A nonempty read of zero means EOF; empty slices return zero without accessing the socket. Pending I/O is io.ErrorKind.WouldBlock. poll_read and poll_write instead report io.PollState.Pending with zero progress. Read EOF is io.PollState.Eof. Platform errors retain their numeric code, and transferred counts refer to this call’s initialized/accepted prefix. TCP syscall lengths are capped at INT_MAX, so larger slices naturally make partial progress. Linux sends suppress SIGPIPE.

shutdown(net.Shutdown.Write) half-closes transmission while retaining reads; Read and Both select the other directions. Receiving EOF does not close the write direction. A connection owns a pending connect attempt until successful, closed or dropped. All data syscalls are synchronous attempts on nonblocking sockets: no overlapped operations, callbacks or retained caller pointers exist. Every slice borrow covers the entire foreign call, and the OS has finished accessing that memory before the call returns. Pending data can therefore be moved or reused after return. A future asynchronous adapter must keep its own operation state and buffer borrows alive until OS completion, including after a cancellation request.

connection.wait_ready(Interest.Read | Write, milliseconds) waits explicitly for readiness, returning false on timeout. A listener has wait_ready(milliseconds). Readiness includes disconnect/error conditions and is only a suggestion to retry; an operation can still return WouldBlock. Waits are one synchronous poll call, clamped to INT_MAX milliseconds, and may return Interrupted.

Deadlines, cancellation and execution

A nonblocking call is an attempt, not a loop that waits for completion. Handle its result according to progress:

Result Action
A positive byte count Advance by exactly that count before trying the remainder.
Zero from a nonempty TCP read Finish reading: the peer sent EOF.
WouldBlock or PollState.Pending Wait for relevant readiness, then retry under your operation policy.
Interrupted Recheck cancellation/deadline before retrying.
Another error Preserve any reported progress and end or explicitly recover the operation.

For example, if an eight-byte message is accepted in chunks of three and five, the second write receives message[3..]. Re-sending all eight bytes would duplicate the prefix. operations.write_all maintains that offset for you.

A deadline is an absolute millisecond value, whereas wait_ready takes a relative wait duration. Form the deadline once and keep it across partial progress; recomputing now + timeout after every byte can let a slow peer keep an operation alive indefinitely. Check integer addition before constructing it, as the quickstart does; a failed native clock reads as the maximum u64.

Operation.until(absolute_milliseconds) carries a deadline in the supplied clock’s domain. Operation.unlimited() has no deadline. Structural clock providers implement now_ms(&mut self) -> u64 and must be monotonic. Cancellation providers implement cancelled(&self) -> bool. NeverCancel always returns false; Cancellation.new() and .cancel() provide caller-local cancellation. A shared cross-thread provider can implement cancelled() with std/sync atomics; ordinary Cancellation is not a concurrent cancellation token.

operation.check(&mut clock, &cancel) checks cancellation first, then rejects a deadline at or before now. It opens no socket. An unlimited operation does not read the clock. native.MonotonicClock explicitly selects the OS monotonic clock; its timestamps cannot be compared with another clock provider’s timestamps. A native clock failure returns the maximum timestamp, causing bounded operations to fail closed as timed out.

native.connect_with composes connection polling with these providers. It checks before opening and before every connect poll, then waits for at most 10 ms per pending attempt. Timeout/cancellation drops and closes the in-progress socket. std/net/operations.read and .write_all apply the same explicit policy to any stream with poll_read, poll_write and wait_ready. write_all retains total progress in its error, including when a deadline or cancellation occurs between partial writes. Already accepted bytes are never replayed. read returns after any progress or EOF; it does not fill the whole destination. Empty operations return immediately without clock or cancellation calls.

These are explicit blocking compositions of nonblocking attempts. Cancellation and deadline checks occur between attempts, with latency up to one 10 ms wait plus one OS attempt and provider execution. Providers must finish promptly. There is no scheduler, background execution, hidden allocation or busy retry loop. std/io.write_all remains available but stops on WouldBlock; selecting a completion policy is the caller’s responsibility.

UDP datagrams

UdpSocket.bind(&address), local_address(), wait_ready(interest, milliseconds) and close() follow the TCP ownership and execution rules. send_to(data, &destination) sends exactly one packet, including an empty packet. A partial native send becomes an error with its actual transferred count. Oversize sends fail; they are never split into multiple datagrams.

receive_from(&mut buffer) consumes exactly one message and returns Datagram { transferred, peer, truncated, original_size }. Truncated suffixes are discarded, never exposed as the next read. A zero-capacity destination still consumes a packet, reporting truncation if it was nonempty. Empty datagrams are successful messages, not EOF. original_size is some(full_length) on Linux; Windows reports none for an oversized packet because Winsock does not provide the original length after consuming it. The copied prefix is always initialized and bounded by the supplied slice. UDP has no stream read/write methods, preventing accidental loss of message boundaries.

DNS messages and resolution policy

std/net/dns provides a bounded RFC 1035 codec: header parsing, question serialization, compressed-name expansion, question/record iteration and borrowed RDATA. query(name, id, type, recursion, output) accepts an ASCII presentation name, an optional final dot and explicit transaction ID, record type and recursion decision. A query requires at most 271 bytes and leaves caller output unchanged on failure.

expand_name(packet, offset, output) emits uncompressed DNS wire labels, including their length octets and the final zero; arbitrary label bytes remain unambiguous. Labels are at most 63 bytes, expanded names at most 255 bytes, and expansion performs at most 128 steps. Compression pointers must refer backward; forward/self pointers, truncated pointers, invalid label tags and capacity exhaustion fail. This avoids the compression-loop and unchecked-offset problems described in RFC 9267.

Reader.new(packet) borrows the message. Consume questions using next_question(name_workspace) before next_record(name_workspace), then call finish() to reject missing records or trailing bytes. Output names borrow the caller workspace; RDATA borrows the packet. The compiler rejects mutation or release of either storage while a returned view is live. Name-expansion failure may leave an initialized prefix in workspace, but no valid view is returned. Reader advancement is transactional on parse failure. Header counts preserve answer/authority/additional section sizes for policy consumers; the record iterator visits those sections consecutively. RDATA interpretation, EDNS, DNSSEC validation, caching, retries, server discovery and TCP fallback are not implemented by this codec.

native.resolve(host, port, workspace) explicitly selects libc getaddrinfo or Winsock getaddrinfo, independent of the DNS codec. Host input is 1–253 ASCII bytes without NUL or whitespace. Internationalized names require an independently selected IDNA integration. The provider accepts a caller workspace with 24 bytes per result; at least one slot is required. It deduplicates addresses and returns borrowed Addresses, with len(), get(index) returning owned socket addresses, and a truncated flag when caller capacity is exhausted. Never assume the first result is preferred or connect all results implicitly. resolve_numeric selects the platform numeric parser with AI_NUMERICHOST, so it never consults a name service; portable strict numeric syntax remains available through parse_ip. NameNotFound and Exhausted distinguish common resolver failures; other native codes are retained.

The platform resolver may allocate internally and always frees its result chain before returning. It is a synchronous, potentially blocking call with OS resolver policy, ordering and configuration. It cannot be interrupted by Operation or cancelled mid-call. Callers needing bounded/cancellable DNS must supply a resolver using the portable message codec and their transport/clock/entropy policy; this release does not provide that DNS resolver implementation. A caller can isolate platform resolution in a explicitly managed thread, retaining all borrowed storage until it finishes. No API claims that dropping a waiter stops the OS resolver.

Providers, dependencies and verification

The native boundary is bundled C11 source compiled against actual system socket headers, avoiding hand-declared platform structure layouts. Linux uses libc sockets, poll, getaddrinfo and CLOCK_MONOTONIC. Windows uses Winsock 2.2, WSAPoll, getaddrinfo and the shared std/time/hosted performance counter, linking ws2_32 plus the existing Windows runtime libraries. Winsock initialization happens once per process under InitOnceExecuteOnce; the process-wide startup reference remains until process exit. Socket options and local-domain sockets have no portable API in this release and belong in separately selected extensions. All Dodo network sources and their C bridge use the repository BSD-2-Clause license; OS provider licensing and implementation updates belong to the platform.

cargo test --test net_library exercises fixtures at -O0 and -O3, IPv4 and IPv6 loopback TCP/UDP, half-close and EOF, partial reads, write backpressure, slow-peer deadlines, cancellation with partial progress, resource exhaustion and cleanup, name-resolution errors, independent Rust TCP peers, parser properties and malformed DNS input, checked borrowing and moved socket rejection. Portable fixtures cross-compile for wasm32 and Cortex-M0; native fixtures cross-compile to Windows x64. scripts/test_stdlib_windows.py --fixture tests/net/native_checks.dodo executes real Windows binaries under Wine, including UDP truncation. Native Windows CI runs the loopback and backpressure fixtures at both optimization levels and checks connected/accepted socket inheritability with scripts/test_windows_native.py. This does not cover all network drivers, IPv6 interface scopes, resolver configurations, socket exhaustion, or load. No Internet endpoint is required: the negative resolver case uses an invalid numeric address with AI_NUMERICHOST, and hostname resolution uses localhost.

Complete API reference

For every public type, field, constant, and function signature, see std/net, std/net/operations, std/net/dns, std/net/linux, std/net/windows.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?