std.net
std.net is the capability-scoped portable networking namespace. Its public resources represent valid protocol states directly: a listening TCP endpoint, a connected TCP stream, an unconnected UDP socket, or a connected UDP socket. There is no public generic socket, raw integer identity, or ambient whole-network authority.
RFC 0065 defines the nominal resource target, RFC 0066 fixes its bootstrap, RFC 0067 fixes the minimal DNS surface, and RFC 0068 fixes the per-instance launch boundary. RFC 0069 fixes its closed allow-only policy language. RFC 0070 adds
zynx-launchversion 2 for directory namespace mutations only; the complete Network schema and meaning are identical in versions 1 and 2. The checked-in implementation is still transitional. An executable receivesnet.Networkonly through a private root@host let ... in _;loan and then delegates it explicitly; no ambient fallback or public acquisition API exists.
Contract
- Every operation that creates a network resource borrows an explicit
net.Networkcapability. - Public stable resource types are exactly
TcpListener,TcpStream,UdpSocket, andConnectedUdpSocket. - No safe constructor accepts a socket/file descriptor as
usize,i32, oru64, and no method returns or transfers such an integer. - Resources are move-only owners. Ordinary
drop(<-resource>)performs best-effort cleanup. close(^self)consumes its owner on success and error. There is nois_open, descriptor-zero sentinel, retryable after-close wrapper, or zombie state.- A live I/O Future retains the resource loan and buffer/session until true completion. That loan statically blocks close, UDP connection transition, and other consuming state changes.
- Readiness and registration are private SDK/runtime mechanisms tied to the nominal resource plus its generation. Host close/reuse cannot retarget an old wait to a later resource.
Raw interop is deferred to a separate platform RFC. Unix owned/borrowed file descriptors, Windows sockets and handles, and WASI resources are different types; portable safe networking does not erase them into one integer.
Network Capability
An executable declares each required Network at its composition root:
// RFC 0066 target; implementation pending.
import std.net;
@host let outbound: net.Network in _;
fn main() {
run(outbound);
}The same six fields, selectors, defaults, validation, and authority semantics apply unchanged in zynx-launch version 2. Supporting v2 does not grant a new DNS or socket operation and does not reinterpret an existing rule.
The host supplies one immutable, nonwidening policy proof for each exact source binding before entry. Only main resolves the root name directly. There is no net.open, default or first Network, registry, enumeration, or string-to- authority conversion; helpers receive an ordinary explicit Network loan.
The backing grant comes only from one explicitly selected zynx-launch document, whose exact names and kinds must match the executable artifact before acquisition. RFC 0069 requires exactly six policy fields:
{
"resolve": {
"kind": "names",
"names": ["api.example.test"]
},
"tcp_connect": [
{
"prefix": "203.0.113.0/24",
"ports": { "kind": "one", "port": 443 }
}
],
"tcp_listen": [],
"udp_bind": [],
"udp_connect": [],
"udp_send_to": []
}Every field is mandatory. Empty operation lists deny that operation; resolve.kind = "names" with an empty names list denies resolution. The alternative { "kind": "any" } explicitly permits every raw nonnumeric host accepted by RFC 0067, but grants no endpoint authority.
Endpoint rules contain only canonical numeric CIDR plus a tagged port selector:
{ "kind": "one", "port": 443 }
{ "kind": "range", "first": 3000, "last": 3999 }
{ "kind": "any" }IPv6 rules additionally require scope as either an exact { "kind": "one", "scope_id": 3 } or { "kind": "any" }; IPv4 forbids it. Flow information is preserved but does not select authority. Port 0 in a local bind/listen selector authorizes only an explicit kernel-chosen ephemeral request; in a remote selector it remains literal port zero.
Rules form an unordered allow-only union. There are no deny rules, precedence, hostnames in endpoint rules, wildcard strings, suffix matching, case folding, IDNA, service names, or policy derived from DNS results. Broad address grants use explicit family-specific 0.0.0.0/0 or ::/0 prefixes.
The following schemas show the authority boundary:
// RFC 0065/0066 target.
fn TcpListener.listen<region L>(
network: net.Network in L,
local: SocketAddr,
backlog: usize,
) -> ^Future<^TcpListener throws(io.IOError)> in L;
fn TcpStream.connect<region L>(
network: net.Network in L,
remote: SocketAddr,
) -> ^Future<^TcpStream throws(io.IOError)> in L;
fn UdpSocket.bind<region L>(
network: net.Network in L,
local: SocketAddr,
) -> ^Future<^UdpSocket throws(io.IOError)> in L;Listening, connecting, and binding return cold Futures. Even when a host can do the operation immediately, WASI or another capability host may need an async permission/authority step. Construction starts no work until the Future is awaited or passed to a structured helper.
There is no public unbound socket. Future pre-bind options must be immutable and protocol-specific so an invalid sequence of open, bind, listen, and connect cannot be expressed.
Addresses
Ipv4Addr, Ipv6Addr, IpAddr, and SocketAddr are Copy value types. Use typed constructors for known values and parse for numeric address text. Hostnames are not addresses; resolve them through std.net.dns.
SocketAddr is a valid tagged IPv4/IPv6 value. It has no invalid numeric family state and no raw/from_raw representation escape. Its IPv6 form preserves the address, port, flow information, and scope ID rather than discarding host-visible address semantics.
import std.net.ipaddr;
fn main() {
let loopback = ipaddr.SocketAddr.localhost(8080);
let any = ipaddr.SocketAddr.any(0);
let family: ipaddr.AddressFamily = any.family();
assert loopback.port() == 8080 as u16;
_ = family;
}Parsing accepts numeric IPv4 and bracketed IPv6 socket addresses. Formatting returns owned text, emits compressed IPv6, and retains IPv4-mapped IPv6 values as IPv6 rather than silently changing their family.
TCP
TcpListener is already bound and listening. accept() returns a nominal connected TcpStream; no generic socket state appears between them.
TcpStream implements std.async.io.Reader and Writer. A read may initialize fewer bytes than the available OutputSpan<u8> capacity; 0 on a nonempty session means the peer closed its sending side. A write is all-or-error and therefore returns void on success.
// RFC 0065 target; surrounding connection setup omitted.
var output = try (<-storage).output(4096);
output = try await stream.read(<-output);
storage = (<-output).commit();
try await stream.write(bytes.Bytes("hello"));The read Future owns the output session and retains a loan of the stream through true completion. An error drops the consumed session under the accepted OutputSpan contract. Cancellation may request a backend cancellation but does not release either resource while the host could still access it.
shutdown(direction) with Shutdown.Receive, .Send, or .Both changes the TCP protocol state; it does not release the resource. close(^self) is the separate consuming resource-release operation.
UDP
UDP uses different types for different valid states:
UdpSocketis bound and unconnected. It hassend_toandreceive_frombut nosend,receive, orpeer_addr.ConnectedUdpSocketis bound and peer-associated. It hassend,receive, andpeer_addrbut nosend_toorreceive_from.
The state transition consumes the unconnected owner:
// RFC 0065 target; implementation pending.
let connected = try await (<-socket).connect(peer);Every receive consumes at most one datagram. The unconnected result preserves the output owner, source, and truncation state explicitly:
struct ReceivedDatagram {
output: ^OutputSpan<u8>,
source: SocketAddr,
truncated: bool,
}receive_from(<-output) and connected receive(<-output) both return an owned ReceivedDatagram. The connected record still reports its associated peer as source. If the datagram exceeds the session capacity, the initialized prefix is returned with truncated = true and the remaining payload is discarded. No byte count can hide truncation.
send_to(data, destination) and connected send(data) are all-or-error. A successful Future yields void, not a host-dependent accepted byte count.
DNS
The complete portable core DNS surface is one forward lookup:
// RFC 0067 target; implementation pending.
export enum ResolvedAddr: Copy {
Ipv4(address: Ipv4Addr),
Ipv6(address: Ipv6Addr, flow_info: u32, scope_id: u32),
}
export fn ResolvedAddr.socket_addr(self, port: u16) -> SocketAddr;
export fn resolve<region L>(
network: net.Network in L,
host: str in L,
) -> ^async.Future<
^[ResolvedAddr] throws(net.NetError | io.IOError | mem.AllocError)
> in L;The cold Future retains both loans through true completion. A denied request starts no resolver operation. A successful result is a nonempty owned sequence in host-resolver order, including duplicates. ResolvedAddr preserves IPv6 flow_info and scope_id; its total socket_addr(port) operation attaches the caller-selected port without allocating or discarding that metadata.
// RFC 0067 target; implementation pending.
let addresses = try await dns.resolve(outbound, "example.com");
let remote = addresses[0].socket_addr(443);
let stream = try await tcp.TcpStream.connect(outbound, remote);Resolver results are ordinary data rather than authority. The later connect, bind, or send still checks the policy of the Network used for that operation. There is no resolver port, service-name lookup, protocol selector, reverse DNS, numeric-host fallback, connect-by-name operation, automatic address selection, or Happy Eyeballs policy in core std.
The host resolver is intentionally non-hermetic: host files, resolver configuration, search domains, network state, and caches may affect results and ordering. Zynx performs no IDNA, search-suffix, case, or trailing-dot normalization. Empty text, embedded NUL, numeric address text, and scoped numeric IPv6 text are rejected as NetError.InvalidName before starting host resolver work. Not-found and temporary failures map to the closest io.IOError case; allocating the result sequence may additionally throw mem.AllocError. An invalid or unrepresentable host result fails the whole operation; there is no partial-success result.
Ownership, Errors, And Unspecified Behavior
Address parsing reports NetError; DNS additionally uses NetError.InvalidName for inputs which are not resolver names. Resource and resolver host failures report portable io.IOError. Raw platform error values are not preserved. Address formatting and DNS results may allocate; ordinary socket I/O does not hide payload allocation.
Scheduling, readiness batching, host notification count, and descriptor/socket representation are private. A resource may move where its structural Send conditions allow, but moving it transfers the one owner; it does not create shared unsynchronized access.
API Reference
Types
| Type | Kind | Public surface | Description |
|---|---|---|---|
net.Network | opaque borrowed capability | private executable-root @host let name: net.Network in _; | Immutable host policy proof required to initiate networking. |
net.NetError | error enum | InvalidAddress, InvalidPort, InvalidName | Deterministic validation errors before address/resolver host work. |
ipaddr.AddressFamily | enum | Ipv4, Ipv6 | Valid typed address family. |
ipaddr.Ipv4Addr | Copy value | constructors, parse, to_string, octets | IPv4 address. |
ipaddr.Ipv6Addr | Copy value | constructors, parse, to_string | IPv6 address. |
ipaddr.IpAddr | tagged Copy value | ipv4, ipv6, parse, to_string, family | IPv4-or-IPv6 address. |
ipaddr.SocketAddr | tagged Copy value | typed constructors, parse, to_string, family, ip, port | Socket address with no raw representation escape. |
dns.ResolvedAddr | tagged Copy value | Ipv4, Ipv6, socket_addr | Resolver address preserving IPv6 flow and scope metadata until a caller attaches a port. |
tcp.TcpListener | move-only owner | listen, accept, local_addr, consuming close | Bound listening TCP resource. |
tcp.TcpStream | move-only async Stream | connect, read, write, shutdown, addresses, consuming close | Connected full-duplex TCP resource. |
tcp.Shutdown | enum | Receive, Send, Both | TCP half-close direction; does not release the owner. |
udp.UdpSocket | move-only owner | bind, connect, receive_from, send_to, local_addr, consuming close | Bound unconnected UDP resource. |
udp.ConnectedUdpSocket | move-only owner | receive, send, addresses, consuming close | Bound peer-associated UDP resource. |
udp.ReceivedDatagram | owning result | output, source, truncated | One received datagram and its output owner. |
Core Resource Operations
| Operation | Target signature/shape | Result |
|---|---|---|
dns.resolve | resolve(network: net.Network in L, host: str in L) | Cold ^Future<^[ResolvedAddr] throws(net.NetError | io.IOError | mem.AllocError)> in L; success is nonempty. |
TcpListener.listen | listen(network: net.Network in L, local: SocketAddr, backlog: usize) | Cold ^Future<^TcpListener throws(io.IOError)> in L. |
TcpListener.accept | accept(&self in L) | Cold region-bound Future of ^TcpStream. |
TcpStream.connect | connect(network: net.Network in L, remote: SocketAddr) | Cold ^Future<^TcpStream throws(io.IOError)> in L. |
TcpStream.read | read(&self in L, output: ^OutputSpan<u8>) | Cold Future returning the advanced output owner. |
TcpStream.write | write(&self in L, data: bytes.Bytes) | Cold all-or-error Future<void>. |
UdpSocket.bind | bind(network: net.Network in L, local: SocketAddr) | Cold ^Future<^UdpSocket throws(io.IOError)> in L. |
UdpSocket.connect | connect(^self, peer: SocketAddr) | Cold Future of ^ConnectedUdpSocket; consumes the old state. |
UdpSocket.receive_from | receive_from(&self in L, output: ^OutputSpan<u8>) | Cold Future of ReceivedDatagram. |
UdpSocket.send_to | send_to(&self in L, data: bytes.Bytes, destination: SocketAddr) | Cold all-or-error Future<void>. |
ConnectedUdpSocket.receive | receive(&self in L, output: ^OutputSpan<u8>) | Cold Future of owned ReceivedDatagram. |
ConnectedUdpSocket.send | send(&self in L, data: bytes.Bytes) | Cold all-or-error Future<void>. |
close | close(^self) throws(io.IOError) | Consumes the resource on success and error. |
Related Modules
Use std.async.io for generic async stream helpers, std.io for byte-stream semantics, and std.bytes plus OutputSpan<u8> for payload input/output. Raw OS-resource interop is not part of the portable target.
Status
This page documents the RFC 0065–0069 accepted target. The checked-in socket.Socket, SocketKind, integer constructors/extractors, raw address bridge, zombie close state, ambient-network operations, resolver port, dns.Protocol, dns.resolve_service, and dns.reverse are migration source only and receive no compatibility aliases.