char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somfeat: add ordered write-all vectored I/O for files and sockets7a27268

main
7.5 KiB157 linesraw

Raw networking

connect and listen from avril:std provide plaintext TCP and Unix streams. TLS is not yet implemented.

import { listen } from "avril:std";

const listener = listen({ hostname: "127.0.0.1", port: 9000 });
for await (const connection of listener) {
  // Handle connections concurrently if one must not block accepting the next.
  connection.readable.pipeTo(connection.writable).finally(() => connection.close());
}
import { connect } from "avril:std";

const connection = await connect({ hostname: "127.0.0.1", port: 9000 });
const writer = connection.writable.getWriter();
await writer.write(new TextEncoder().encode("hello"));
await writer.close(); // Send FIN; receiving remains possible.
writer.releaseLock();
const reader = connection.readable.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(value);
}
reader.releaseLock();
connection.close();

Addresses and permissions

Both functions default to hostname 127.0.0.1; port is required. listen permits port 0, while connect requires a nonzero port. Addresses are immutable { transport: "tcp", hostname, port } objects. A listener exposes addr; a connection exposes localAddr and remoteAddr.

TCP requires net authority for the requested hostname/port. Port-zero listeners check both port 0 and the selected port; use a hostname-wide grant. Accepting and using accepted connections check listener authority, not an outbound grant for the peer. DNS lookup and connection use the requested hostname's authority.

Every accept, read, write, and write shutdown rechecks authority. Connect, accept, and read also recheck after awaiting I/O, before releasing results. pledge and worker restrictions apply to existing resources. Closing never requires authority. The CLI HTTP listener exemption does not apply to raw sockets.

Unix streams

const listener = listen({ transport: "unix", path: "/tmp/avril.sock" });
const connection = await connect({ transport: "unix", path: "/tmp/avril.sock" });

Unix streams use the same Conn, half-close, byte-stream, cancellation, and iterator APIs as TCP. Specify transport: "unix" and a nonempty path; hostname and port are not accepted. Relative paths resolve against the runtime's working directory. Addresses are { transport: "unix", path }; unnamed local/peer addresses have path: null.

Both connecting and listening require filesystem write authority at the socket path, not net or filesystem read authority. Connecting resolves the final symlink and checks authority at its target. Accepts and subsequent connection I/O recheck that same write authority. Closing and unlinking do not need a fresh write grant.

Listeners never overwrite existing filesystem entries. Cleanup checks device/inode identity and preserves replacements. Linux listeners pin their parent directory and inode for binding/cleanup. Other Unix systems use the documented path-based, race-prone fallback shared with HTTP serving. Unix connects perform path-based lookups on every platform and can also race with filesystem changes after the authority check. These APIs are not supported on non-Unix platforms.

UDP datagrams

import { listenDatagram } from "avril:std";

const socket = listenDatagram({ hostname: "127.0.0.1", port: 9001 });
for await (const [packet, peer] of socket) {
  await socket.send(packet, peer);
}

listenDatagram({ transport?: "udp", hostname?, port }) returns a DatagramConn with immutable addr: { transport: "udp", hostname, port }. The default hostname is 127.0.0.1; port 0 selects an available port. send(bytes, { hostname?, port, transport?: "udp" }) also defaults to loopback.

Binding and receiving require net authority for the bound hostname/port. Every send also requires authority for its destination hostname/port, checked before DNS and again before sending. Hostname/port grants cover both TCP and UDP. DNS chooses an address matching the socket's IPv4/IPv6 family. The CLI HTTP exemption does not apply.

receive(buffer?) returns [Uint8Array, UdpAddr]. With a supplied buffer, the returned view shares that buffer; an oversized incoming packet is truncated and the remainder discarded, not carried into the next receive. An empty buffer still consumes a packet. Empty datagrams return an empty array with a peer address: they are not EOF. Without a buffer, receives allow up to 65,535 bytes.

send snapshots its payload, preserves message boundaries, and returns the number of bytes sent. Sends are ordered in invocation order; UDP itself does not guarantee delivery or ordering. Payloads over the portable 65,507-byte limit are rejected rather than split. IP fragmentation may still happen below that limit.

setBroadcast(enabled = true) controls IPv4 broadcast permission on the socket; each broadcast destination still needs its own network grant. Multicast membership and Unix datagrams are not implemented.

Closing cancels pending receives and sends; disposal is idempotent and handles cannot be structured-cloned. The async iterator ends on close and closes the socket when its loop is broken. Worker termination releases the socket. Merely binding does not keep the event loop alive; a pending receive does.

Stream I/O and lifetime

Conn.read(Uint8Array) returns a byte count or null for EOF. An empty buffer returns 0. Conn.write(Uint8Array) snapshots its input and may write fewer bytes than requested, with a maximum of 64 KiB per call. Loop over partial writes, or use writable, which writes each chunk completely with backpressure. Writes and closeWrite() are ordered in invocation order.

Conn.writev(readonly Uint8Array[]) writes one ordered vector as a single queued operation: the byte sequence equals the concatenation of its views, empty views are skipped, and unlike write the call completes the entire vector before resolving with the total bytes written. Later writes and closeWrite() cannot interleave between its batches, and an error may leave a prefix written without rolling it back.

The whole vector is validated and snapshotted at invocation, so mutating the array or its views afterwards cannot affect any submitted bytes. An empty or all-empty vector returns 0, still performing the usual closed and authority checks. Snapshots retain the entire payload until completion; await writes rather than queueing unbounded batches.

readable is a byte stream supporting BYOB readers. Its EOF does not close the write half. closeWrite() and closing writable send FIN without closing the read half; repeated write shutdown is harmless. Reading and writing can proceed simultaneously. Do not mix direct reads with a stream reader.

close() closes both halves and cancels pending I/O. Canceling readable or aborting writable also closes the connection. Stream errors close the connection. Sockets and listeners support idempotent [Symbol.dispose]() and cannot be structured-cloned. Close resources explicitly; garbage collection is only a fallback. Worker termination closes the worker's sockets and pending operations.

Listener.accept() returns a connection. Its async iterator ends when the listener closes; breaking the loop closes the listener, but not connections already accepted. A pending accept keeps the event loop alive; merely binding a listener does not.

connect({ ..., signal }) supports aborting an in-progress connect, including DNS lookup (the OS resolver itself may continue after cancellation). An already-aborted signal prevents networking. The signal does not own the connection once connect returns; use connection.close() to cancel later I/O.

1# Raw networking
2
3`connect` and `listen` from `avril:std` provide plaintext TCP and Unix streams.
4TLS is not yet implemented.
5
6```ts
7import { listen } from "avril:std";
8
9const listener = listen({ hostname: "127.0.0.1", port: 9000 });
10for await (const connection of listener) {
11  // Handle connections concurrently if one must not block accepting the next.
12  connection.readable.pipeTo(connection.writable).finally(() => connection.close());
13}
14```
15
16```ts
17import { connect } from "avril:std";
18
19const connection = await connect({ hostname: "127.0.0.1", port: 9000 });
20const writer = connection.writable.getWriter();
21await writer.write(new TextEncoder().encode("hello"));
22await writer.close(); // Send FIN; receiving remains possible.
23writer.releaseLock();
24const reader = connection.readable.getReader();
25while (true) {
26  const { value, done } = await reader.read();
27  if (done) break;
28  console.log(value);
29}
30reader.releaseLock();
31connection.close();
32```
33
34## Addresses and permissions
35
36Both functions default to hostname `127.0.0.1`; port is required. `listen` permits
37port `0`, while `connect` requires a nonzero port. Addresses are immutable
38`{ transport: "tcp", hostname, port }` objects. A listener exposes `addr`; a
39connection exposes `localAddr` and `remoteAddr`.
40
41TCP requires `net` authority for the requested hostname/port. Port-zero listeners
42check both port `0` and the selected port; use a hostname-wide grant. Accepting and
43using accepted connections check listener authority, not an outbound grant for the
44peer. DNS lookup and connection use the requested hostname's authority.
45
46Every accept, read, write, and write shutdown rechecks authority. Connect, accept,
47and read also recheck after awaiting I/O, before releasing results. `pledge` and
48worker restrictions apply to existing resources. Closing never requires authority.
49The CLI HTTP listener exemption does not apply to raw sockets.
50
51## Unix streams
52
53```ts
54const listener = listen({ transport: "unix", path: "/tmp/avril.sock" });
55const connection = await connect({ transport: "unix", path: "/tmp/avril.sock" });
56```
57
58Unix streams use the same `Conn`, half-close, byte-stream, cancellation, and iterator
59APIs as TCP. Specify `transport: "unix"` and a nonempty path; hostname and port are
60not accepted. Relative paths resolve against the runtime's working directory.
61Addresses are `{ transport: "unix", path }`; unnamed local/peer addresses have
62`path: null`.
63
64Both connecting and listening require filesystem **write** authority at the socket
65path, not `net` or filesystem read authority. Connecting resolves the final symlink
66and checks authority at its target. Accepts and subsequent connection I/O recheck
67that same write authority. Closing and unlinking do not need a fresh write grant.
68
69Listeners never overwrite existing filesystem entries. Cleanup checks device/inode
70identity and preserves replacements. Linux listeners pin their parent directory
71and inode for binding/cleanup. Other Unix systems use the documented path-based,
72race-prone fallback shared with HTTP serving. Unix connects perform path-based
73lookups on every platform and can also race with filesystem changes after the
74authority check. These APIs are not supported on non-Unix platforms.
75
76## UDP datagrams
77
78```ts
79import { listenDatagram } from "avril:std";
80
81const socket = listenDatagram({ hostname: "127.0.0.1", port: 9001 });
82for await (const [packet, peer] of socket) {
83  await socket.send(packet, peer);
84}
85```
86
87`listenDatagram({ transport?: "udp", hostname?, port })` returns a
88`DatagramConn` with immutable `addr: { transport: "udp", hostname, port }`.
89The default hostname is `127.0.0.1`; port `0` selects an available port.
90`send(bytes, { hostname?, port, transport?: "udp" })` also defaults to loopback.
91
92Binding and receiving require `net` authority for the bound hostname/port.
93Every send also requires authority for its **destination** hostname/port, checked
94before DNS and again before sending. Hostname/port grants cover both TCP and UDP.
95DNS chooses an address matching the socket's IPv4/IPv6 family. The CLI HTTP
96exemption does not apply.
97
98`receive(buffer?)` returns `[Uint8Array, UdpAddr]`. With a supplied buffer, the
99returned view shares that buffer; an oversized incoming packet is truncated and
100the remainder discarded, not carried into the next receive. An empty buffer still
101consumes a packet. Empty datagrams return an empty array with a peer address:
102they are not EOF. Without a buffer, receives allow up to 65,535 bytes.
103
104`send` snapshots its payload, preserves message boundaries, and returns the number
105of bytes sent. Sends are ordered in invocation order; UDP itself does not guarantee
106delivery or ordering. Payloads over the portable 65,507-byte limit are rejected
107rather than split. IP fragmentation may still happen below that limit.
108
109`setBroadcast(enabled = true)` controls IPv4 broadcast permission on the socket;
110each broadcast destination still needs its own network grant. Multicast membership
111and Unix datagrams are not implemented.
112
113Closing cancels pending receives and sends; disposal is idempotent and handles
114cannot be structured-cloned. The async iterator ends on close and closes the socket
115when its loop is broken. Worker termination releases the socket. Merely binding
116does not keep the event loop alive; a pending receive does.
117
118## Stream I/O and lifetime
119
120`Conn.read(Uint8Array)` returns a byte count or `null` for EOF. An empty buffer
121returns `0`. `Conn.write(Uint8Array)` snapshots its input and may write fewer bytes
122than requested, with a maximum of 64 KiB per call. Loop over partial writes, or use
123`writable`, which writes each chunk completely with backpressure. Writes and
124`closeWrite()` are ordered in invocation order.
125
126`Conn.writev(readonly Uint8Array[])` writes one ordered vector as a single queued
127operation: the byte sequence equals the concatenation of its views, empty views
128are skipped, and unlike `write` the call completes the entire vector before
129resolving with the total bytes written. Later writes and `closeWrite()` cannot
130interleave between its batches, and an error may leave a prefix written without
131rolling it back.
132
133The whole vector is validated and snapshotted at invocation, so mutating the
134array or its views afterwards cannot affect any submitted bytes. An empty or
135all-empty vector returns 0, still performing the usual closed and authority
136checks. Snapshots retain the entire payload until completion; await writes
137rather than queueing unbounded batches.
138
139`readable` is a byte stream supporting BYOB readers. Its EOF does not close the
140write half. `closeWrite()` and closing `writable` send FIN without closing the read
141half; repeated write shutdown is harmless. Reading and writing can proceed
142simultaneously. Do not mix direct reads with a stream reader.
143
144`close()` closes both halves and cancels pending I/O. Canceling `readable` or
145aborting `writable` also closes the connection. Stream errors close the connection.
146Sockets and listeners support idempotent `[Symbol.dispose]()` and cannot be
147structured-cloned. Close resources explicitly; garbage collection is only a
148fallback. Worker termination closes the worker's sockets and pending operations.
149
150`Listener.accept()` returns a connection. Its async iterator ends when the listener
151closes; breaking the loop closes the listener, but not connections already accepted.
152A pending accept keeps the event loop alive; merely binding a listener does not.
153
154`connect({ ..., signal })` supports aborting an in-progress connect, including DNS
155lookup (the OS resolver itself may continue after cancellation). An already-aborted
156signal prevents networking. The signal does not own the
157connection once `connect` returns; use `connection.close()` to cancel later I/O.