Compare commits

..
Author SHA1 Message Date
7ee938c98e
feat(http1): ClientHTTP1::Fetch follows redirects by the RFC, on a URL type
One request to an http(s) URL with redirects followed by RFC 9110 §15.4:
301, 302, 303, 307 and 308 for every method; a 303 makes the request a
GET without its body and content headers, a 301 or 302 does so for a POST
alone, a 307 or 308 keeps the method and the body. The Location is
resolved against the sending URL by RFC 3986, a hop between http and
https is allowed, Authorization, Proxy-Authorization and Cookie are
dropped on a change of origin, and each hop is a client of its own, since
one ClientHTTP1 is one origin. A redirect without a usable Location, to
another scheme, back to a URL already visited or past maxRedirects comes
back as the response, with `stop` saying why.

The new :URL partition is the URL type behind it, exported: Parse,
Resolve (RFC 3986 §5.2; its §5.4 examples are the test), ToString, and
the pieces a request is built from.

This replaces the FetchHTTP1 free function, which followed redirects the
way Python's urllib did (its repeat limit, its quoting of the Location, a
POST turned back on a 307) for a port that no longer needs the fidelity.

Breaking: FetchHTTP1, FetchOptionsHTTP1 and FetchResultHTTP1 are gone;
use ClientHTTP1::Fetch, FetchOptions and FetchResult, whose `url` is a
URL and whose `limited` became `stop`.
2026-10-31 20:39:43 +01:00
e86c3b70a0
feat(tcp): socket errors as std::system_error, with the phase that failed
SocketErrorException is now a std::system_error: code() carries the errno
under std::generic_category(), or getaddrinfo's result under the new
ResolverCategory(), whose message() is gai_strerror's text; `phase` says
which step failed (resolve, open, connect, send, receive). It is still a
std::runtime_error with the message ClientTCP always threw, the code's
text after it, so existing handlers keep working.

Before, the exception carried a `kind` and an int, and a caller that had
to print the cause its own way still had to match the message prefix to
learn where the failure happened. The standard type ends both: the
category knows how to print the number, and the phase is a field. A
caller writes "[Errno {}] {}" from code().value() and code().message(),
or compares code() with std::errc::connection_refused.

Breaking: SocketErrorException's `kind` and `code` members are gone;
read code().category() and code().value().
2026-10-31 20:33:37 +01:00
16da0a1979
feat(tcp): a failed send or receive says which errno it failed with
Send() and the receives threw a bare std::runtime_error with the
strerror text, so a caller that reports the cause the way Python does -
"the connection broke ([Errno 104] Connection reset by peer)", which a
language model reads in Crafter.Review's SQL helper output - could not
tell the code. They throw SocketErrorException now, as a failed connect
already does: kind System, the errno, and the same message, still a
std::runtime_error. A receive timeout stays SocketTimeoutException and
a closed peer SocketClosedException.

ShouldReportIoErrors: a peer that resets the connection (SO_LINGER 0)
fails the next receive with ECONNRESET and a send into it with EPIPE
or ECONNRESET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:28:32 +01:00
f9a45c143e
feat(http1): bound the connect, and FetchHTTP1 for a URL with redirects
- ClientHTTP1 gains connectTimeout (default 15 s), applied to each
  address the host resolves to. `timeout` only ever bounded the waits for
  a response, so a host that drops the SYNs held Send() for the kernel's
  retries, about two minutes, whatever the caller had asked for.
- The default Host header puts an IPv6 literal in brackets.
- FetchHTTP1(url, options) makes one request to an http:// or https://
  URL and follows redirects as Python's urllib does, so urllib code ported
  over keeps its behaviour: 301/302/303/307/308 for GET and HEAD,
  301/302/303 for POST (re-sent as a GET without its body), a relative
  Location resolved as urljoin resolves it and percent-encoded as urllib
  quotes it, http and https hops mixed, each redirect's body read before
  the next request, and the chain stopped by urllib's limits (4 repeats
  of one URL, 10 different URLs) with the last redirect returned and
  `limited` set. Proxies are not consulted.

New: ShouldTimeoutSilentHTTP1, ShouldFollowRedirectsHTTP1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:28:32 +01:00
d6f8f4edcb
feat(tcp): ListenerTCP binds the address it is given, at port 0 if asked
A new constructor takes a numeric bind address (127.0.0.1, ::1, 0.0.0.0,
::) and accepts port 0, and Port() reports the port the listener got.
Until now every listener bound 0.0.0.0 at a port the caller chose, so a
helper meant for this machine's own processes was reachable from the
network, and the caller had to guess a free port. A name such as
`localhost` is refused instead of bound to whichever address the
resolver ranks first. The port-only constructor still listens on every
IPv4 interface.

`running` is atomic now: Stop() runs on another thread than the accept
loop that reads it. The accept loops take a sockaddr_storage, so an IPv6
peer's address is no longer cut short.

New: ShouldBindLoopbackEphemeral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:28:32 +01:00
78853033e8
feat(tcp): connect over IPv4 and IPv6, and say why a connect failed
- ClientTCP resolves with AF_UNSPEC and tries every address the name
  resolves to, in getaddrinfo's order, the way Python's
  socket.create_connection does. It used to ask for AF_INET only and keep
  the first answer, so `localhost` could not reach a server listening on
  ::1 alone, which is what a dev server on a recent Node does. The connect
  timeout bounds each address on its own; when none connects, what the
  last one failed with is thrown.
- A failed resolve, socket() or connect throws SocketErrorException: the
  std::runtime_error it always was, with the same message, plus the errno
  (SocketErrorKind::System) or the getaddrinfo code
  (SocketErrorKind::Resolver). A caller that reports the cause in its own
  words, such as a Python-style "[Errno 111] Connection refused", no
  longer has to parse it back out of the text.
- The move constructor carries the resolved addresses along.

New: ShouldConnectDualStack, ShouldReportConnectErrors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:28:32 +01:00
19 changed files with 1673 additions and 118 deletions

View file

@ -13,7 +13,7 @@ The Crafter engine's networking library: a C++ module, `import Crafter.Network;`
|---|---|---|---| |---|---|---|---|
| `HTTPRequest`, `HTTPResponse`, `CreateRequestHTTP`, `CreateResponseHTTP`, `PathWithoutQueryHTTP` | yes | yes | yes | | `HTTPRequest`, `HTTPResponse`, `CreateRequestHTTP`, `CreateResponseHTTP`, `PathWithoutQueryHTTP` | yes | yes | yes |
| `ClientTCP`, `ListenerTCP` | yes | yes | no | | `ClientTCP`, `ListenerTCP` | yes | yes | no |
| `ByteStream`, `PlainStream`, `TLSContext`, `TLSStream`, `HTTP1::*`, `ClientHTTP1`, `ListenerHTTP1` | yes | yes | no | | `ByteStream`, `PlainStream`, `TLSContext`, `TLSStream`, `HTTP1::*`, `URL`, `ClientHTTP1`, `ListenerHTTP1` | yes | yes | no |
| `ClientQUIC`, `QUICStream` | yes | no | `*Async` only, over `WebTransport` | | `ClientQUIC`, `QUICStream` | yes | no | `*Async` only, over `WebTransport` |
| `ClientHTTP` | yes | no | `SendAsync` only, over `fetch()` | | `ClientHTTP` | yes | no | `SendAsync` only, over `fetch()` |
| `ListenerQUIC`, `ListenerHTTP`, `WebTransportSession`, `HTTP3::*` | yes | no | no | | `ListenerQUIC`, `ListenerHTTP`, `WebTransportSession`, `HTTP3::*` | yes | no | no |
@ -41,7 +41,9 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
### HTTP/1.1 ### HTTP/1.1
**`ClientHTTP1(host, port)`** is plaintext, **`ClientHTTP1(host, port, TLSClientCredentials)`** is `https://`. `Send(request)` returns the `HTTPResponse` on the calling thread, no pool; `SendAsync(request, onSuccess, onError)` runs it as a pool task with the error as a string. The connection is persistent: the first `Send` dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty `authority` means `host:port`, with the scheme's default port left off (443 under TLS, 80 without); `scheme` is ignored (origin-form targets; the constructor decided the transport). Set `limits` (`HTTP1::MessageLimits`: start line 8 KiB, header section 64 KiB, body 512 MiB), `timeout` (30 s for the next piece of a response) and `handshakeTimeout` (15 s) before the first `Send`. `Connected()`, `Secure()`, `Protocol()` (the ALPN the last connection agreed, `"http/1.1"` under TLS), `Disconnect()`. One instance serves one caller at a time; instances are independent. Throws `HTTP1::HTTP1ProtocolError` for a response it refuses to parse, `TLSException` for a certificate it rejects, `std::runtime_error` for a socket that cannot connect, times out or closes early. There is no connect timeout: the dial is a `ClientTCP` with the default zero, so a host that never answers blocks `Send` for the kernel's two minutes before `handshakeTimeout` and `timeout` even start. **`ClientHTTP1(host, port)`** is plaintext, **`ClientHTTP1(host, port, TLSClientCredentials)`** is `https://`. `Send(request)` returns the `HTTPResponse` on the calling thread, no pool; `SendAsync(request, onSuccess, onError)` runs it as a pool task with the error as a string. The connection is persistent: the first `Send` dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty `authority` means `host:port`, with the scheme's default port left off (443 under TLS, 80 without); `scheme` is ignored (origin-form targets; the constructor decided the transport). Set `limits` (`HTTP1::MessageLimits`: start line 8 KiB, header section 64 KiB, body 512 MiB), `connectTimeout` (15 s, per address the host resolves to), `handshakeTimeout` (15 s) and `timeout` (30 s for the next piece of a response) before the first `Send`. `Connected()`, `Secure()`, `Protocol()` (the ALPN the last connection agreed, `"http/1.1"` under TLS), `Disconnect()`. One instance serves one caller at a time; instances are independent. Throws `HTTP1::HTTP1ProtocolError` for a response it refuses to parse, `TLSException` for a certificate it rejects, `SocketErrorException` for a socket that cannot resolve or connect or breaks under the request, `SocketTimeoutException` when the connect runs out, and `std::runtime_error` when a response stops arriving or the connection closes before one is complete.
**`ClientHTTP1::Fetch(url, options = {})`** is one request to an `http://` or `https://` URL with redirects followed by RFC 9110 §15.4 (301, 302, 303, 307, 308 for every method; a 303 makes the request a GET without its body and content headers, a 301 or 302 does so for a POST alone, a 307 or 308 keeps method and body), the `Location` resolved against the sending URL, a hop between the schemes allowed, `Authorization`, `Proxy-Authorization` and `Cookie` dropped on a change of origin, and a client per hop. `FetchOptions { method, headers, body, connectTimeout, handshakeTimeout, timeout, limits, credentials, maxRedirects = 10 }`; `FetchResult { response, url, redirects, stop }`, where `url` is the `Crafter::URL` that answered and `stop` (`FetchStop::None`, `NoLocation`, `OtherScheme`, `Loop`, `Limit`) says why a redirect came back unfollowed. Throws `std::invalid_argument` for a URL that is not http(s) or that `URL::Parse` refuses, else what `Send` throws. **`URL::Parse(text)`**, `url.Resolve(reference)`, `url.ToString()`, `Authority()`, `Target()`, `PortOrDefault()`, `Secure()`, `SameOrigin(other)` are the URL type behind it (`scheme`, `userinfo`, `host`, `port`, `path`, `query`, `fragment`; scheme and host lowercased, an IPv6 host without its brackets).
**`ListenerHTTP1(port, routes[, fallback][, TLSServerCredentials])`** is a `ListenerTCP` underneath (every IPv4 address, `SO_REUSEADDR`) and throws `std::runtime_error` when the port cannot be bound, or `TLSException` when the credentials yield no usable certificate. `Listen()` runs the accept loop on the calling thread until `Stop()`, which stops accepting, closes every live connection and joins their threads. **`ListenerAsyncHTTP1`**, same constructors, runs that loop on its `thread` and exposes the `listener`; `Stop()` joins and the destructor stops. Each accepted connection gets its own thread, served one request at a time until the peer closes, sends `connection: close`, or a timeout passes: `keepAliveTimeout` 15 s between requests, `requestTimeout` 30 s between pieces of one request (each read, not the whole request), `handshakeTimeout` 15 s for TLS; `limits` as on the client. `ConnectionCount()`, `AcceptedCount()`, `HandshakeFailureCount()` (connections dropped in the TLS handshake: a scanner, a client with no protocol in common, an untrusted client certificate; ordinary on a public port), `Secure()`. **`ListenerHTTP1(port, routes[, fallback][, TLSServerCredentials])`** is a `ListenerTCP` underneath (every IPv4 address, `SO_REUSEADDR`) and throws `std::runtime_error` when the port cannot be bound, or `TLSException` when the credentials yield no usable certificate. `Listen()` runs the accept loop on the calling thread until `Stop()`, which stops accepting, closes every live connection and joins their threads. **`ListenerAsyncHTTP1`**, same constructors, runs that loop on its `thread` and exposes the `listener`; `Stop()` joins and the destructor stops. Each accepted connection gets its own thread, served one request at a time until the peer closes, sends `connection: close`, or a timeout passes: `keepAliveTimeout` 15 s between requests, `requestTimeout` 30 s between pieces of one request (each read, not the whole request), `handshakeTimeout` 15 s for TLS; `limits` as on the client. `ConnectionCount()`, `AcceptedCount()`, `HandshakeFailureCount()` (connections dropped in the TLS handshake: a scanner, a client with no protocol in common, an untrusted client certificate; ordinary on a public port), `Secure()`.
@ -63,12 +65,12 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
### TCP ### TCP
**`ClientTCP(host, port, connectTimeout = 0ms)`** resolves with `getaddrinfo`, IPv4 only, and connects; zero waits out the kernel's retries (about two minutes to a host that never answers), a timeout throws `SocketTimeoutException` after that long, and a refused connect throws `std::runtime_error` at once. `ClientTCP(int socket)` adopts a descriptor. Move-only; the destructor shuts down and closes `socketid`, a public member. `SetNoDelay(true)` for small frames that must leave at once; `SetReceiveTimeout(ms)` makes a receive that waits longer throw `SocketTimeoutException`, losing what a whole-buffer receive had collected; `Stop()` closes; `Connect(timeout)` after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address). **`ClientTCP(host, port, connectTimeout = 0ms)`** resolves with `getaddrinfo` for IPv4 and IPv6 and tries every address in the order it returned them, so `localhost` reaches a server on `::1` alone; zero waits out the kernel's retries per address (about two minutes to a host that never answers), a timeout throws `SocketTimeoutException` after that long per address, and when no address connects what the last one failed with is thrown. A failed resolve, `socket()`, connect, send or receive throws `SocketErrorException`, a `std::system_error`: `code()` is the errno under `std::generic_category()` or the `getaddrinfo` result under `Crafter::ResolverCategory()` (whose `message()` is `gai_strerror`'s), and `phase` is the step (`SocketPhase::Resolve`, `Open`, `Connect`, `Send`, `Receive`); `what()` is the message ClientTCP always threw with the code's text after it, so a handler for `std::runtime_error` still catches it, and `[Errno {}] {}` is `code().value()` and `code().message()`. `ClientTCP(int socket)` adopts a descriptor. Move-only; the destructor shuts down and closes `socketid`, a public member. `SetNoDelay(true)` for small frames that must leave at once; `SetReceiveTimeout(ms)` makes a receive that waits longer throw `SocketTimeoutException`, losing what a whole-buffer receive had collected; `Stop()` closes; `Connect(timeout)` after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address).
- `Send(buffer, size)` writes it all. The receives return what arrived, at least one byte: `RecieveSync()` one chunk, `RecieveSync(n)` up to `n`, `RecieveUntilFullSync(n)` exactly `n`, `RecieveUntilCloseSync()` everything to the peer's close, `RecieveSync(n, buffer)` into your memory, returning the count. Asked for zero bytes they return at once. They throw `SocketClosedException` once the peer has closed, `SocketTimeoutException` on a receive timeout, `std::runtime_error` otherwise, and wait on through a signal. - `Send(buffer, size)` writes it all. The receives return what arrived, at least one byte: `RecieveSync()` one chunk, `RecieveSync(n)` up to `n`, `RecieveUntilFullSync(n)` exactly `n`, `RecieveUntilCloseSync()` everything to the peer's close, `RecieveSync(n, buffer)` into your memory, returning the count. Asked for zero bytes they return at once. They throw `SocketClosedException` once the peer has closed, `SocketTimeoutException` on a receive timeout, `SocketErrorException` otherwise, and wait on through a signal.
- The `*Async` receives (`RecieveAsync`, `RecieveUntilCloseAsync`, `RecieveAsync(n, ...)`, `RecieveUntilFullAsync(n, ...)`, and `RecieveAsync(n, callback(count), buffer, ...)` into memory that must outlive the call) run the same receive as a pool task and take an `onError(std::exception_ptr)`: what the receive throws goes there, and an empty `onError` drops it, since an exception leaving a pool task ends the program. The task uses the `ClientTCP`, which must outlive it; a parked receive ends through `Stop()` from another thread or a receive timeout, both of which arrive at `onError`. - The `*Async` receives (`RecieveAsync`, `RecieveUntilCloseAsync`, `RecieveAsync(n, ...)`, `RecieveUntilFullAsync(n, ...)`, and `RecieveAsync(n, callback(count), buffer, ...)` into memory that must outlive the call) run the same receive as a pool task and take an `onError(std::exception_ptr)`: what the receive throws goes there, and an empty `onError` drops it, since an exception leaving a pool task ends the program. The task uses the `ClientTCP`, which must outlive it; a parked receive ends through `Stop()` from another thread or a receive timeout, both of which arrive at `onError`.
**`ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit)`** binds every IPv4 address with `SO_REUSEADDR`, backlog 128, and throws `std::runtime_error` when it cannot bind or listen. The callback gets a heap-allocated `ClientTCP*` and owns it: delete it when the connection is done. `ListenSyncSync()` accepts on the calling thread and calls the callback there; `ListenSyncAsync()` calls it as a pool task; `ListenAsyncSync()` and `ListenAsyncAsync()` run the accept loop itself as a pool task, the second with the callback as another. `Stop()` closes the socket, which ends the loop. `totalClientLimit` ends it after that many accepts; `concurrentClientLimit` is stored and not enforced. **`ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit)`** binds every IPv4 address with `SO_REUSEADDR`, backlog 128, and throws `std::runtime_error` when it cannot bind or listen; **`ListenerTCP(address, port, ...)`** binds that one address, IPv4 or IPv6, and port 0 lets the kernel pick, reported by `Port()`. The callback gets a heap-allocated `ClientTCP*` and owns it: delete it when the connection is done. `ListenSyncSync()` accepts on the calling thread and calls the callback there; `ListenSyncAsync()` calls it as a pool task; `ListenAsyncSync()` and `ListenAsyncAsync()` run the accept loop itself as a pool task, the second with the callback as another. `Stop()` closes the socket, which ends the loop. `totalClientLimit` ends it after that many accepts; `concurrentClientLimit` is stored and not enforced.
### QUIC ### QUIC
@ -107,14 +109,15 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
| Call | On failure | | Call | On failure |
|---|---| |---|---|
| `ClientTCP` constructor, `Connect`, `Send`, `Recieve*Sync` | `std::runtime_error` (resolve, refused, socket errors); `SocketTimeoutException` (connect or receive timeout); `SocketClosedException` (the peer closed) | | `ClientTCP` constructor, `Connect`, `Send`, `Recieve*Sync` | `SocketErrorException` (a `std::system_error`: a failed resolve, `socket()`, connect, send or receive, with `phase` and `code()`); `SocketTimeoutException` (connect or receive timeout); `SocketClosedException` (the peer closed) |
| `ClientTCP::Recieve*Async` | the same, as an `std::exception_ptr` to `onError`; dropped without one | | `ClientTCP::Recieve*Async` | the same, as an `std::exception_ptr` to `onError`; dropped without one |
| `ListenerTCP`, `ListenerHTTP1`, `ListenerAsyncHTTP1` constructors | `std::runtime_error` when the port cannot be bound; `TLSException` for unusable credentials | | `ListenerTCP`, `ListenerHTTP1`, `ListenerAsyncHTTP1` constructors | `std::runtime_error` when the port cannot be bound; `TLSException` for unusable credentials |
| `ListenerHTTP`, `ListenerAsyncHTTP` constructors | `QUICException` (they build a `ListenerQUIC`) | | `ListenerHTTP`, `ListenerAsyncHTTP` constructors | `QUICException` (they build a `ListenerQUIC`) |
| `ClientHTTP1(host, port, TLSClientCredentials)` constructor | `TLSException` for an unreadable certificate or trust anchor, at construction rather than at the first `Send` | | `ClientHTTP1(host, port, TLSClientCredentials)` constructor | `TLSException` for an unreadable certificate or trust anchor, at construction rather than at the first `Send` |
| `TLSContext::Server`/`Client`, `TLSStream::Connect`/`Accept` | `TLSException`, naming the certificate error | | `TLSContext::Server`/`Client`, `TLSStream::Connect`/`Accept` | `TLSException`, naming the certificate error |
| `ByteStream::ReadSome` / `Write` | `std::runtime_error`; a read that times out is `StreamStatus::TimedOut`, a write that does throws | | `ByteStream::ReadSome` / `Write` | `std::runtime_error`; a read that times out is `StreamStatus::TimedOut`, a write that does throws |
| `ClientHTTP1::Send` | `HTTP1::HTTP1ProtocolError`, `TLSException`, `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`, the socket's) | | `ClientHTTP1::Send` | `HTTP1::HTTP1ProtocolError`, `TLSException`, `SocketErrorException` (the socket's), `SocketTimeoutException` (the connect), `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`) |
| `ClientHTTP1::Fetch`, `URL::Parse`, `URL::Resolve` | `std::invalid_argument` for a URL that is not http(s), has no host or a bad port; `Fetch` otherwise what `Send` throws |
| `ClientHTTP1::SendAsync`, `ClientHTTP::SendAsync` | `what()` as a string to `onError` | | `ClientHTTP1::SendAsync`, `ClientHTTP::SendAsync` | `what()` as a string to `onError` |
| `HTTP1::MessageParser`, `SerializeRequest`/`SerializeResponse` | `HTTP1::HTTP1ProtocolError` | | `HTTP1::MessageParser`, `SerializeRequest`/`SerializeResponse` | `HTTP1::HTTP1ProtocolError` |
| `ClientQUIC` constructor, `OpenStream`, `SendDatagram`, `GetStreamId`, `ListenerQUIC` constructor | `QUICException` (native; the browser build's `ClientQUIC` never throws, see "Browser build") | | `ClientQUIC` constructor, `OpenStream`, `SendDatagram`, `GetStreamId`, `ListenerQUIC` constructor | `QUICException` (native; the browser build's `ClientQUIC` never throws, see "Browser build") |
@ -123,7 +126,7 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
| `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` | | `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` |
| Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed | | Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed |
`SocketClosedException`, `SocketTimeoutException` and `QUICClosedException` derive from `std::exception`, not `std::runtime_error`; `QUICException`, `TLSException`, `HTTP1ProtocolError` and `HTTP3ProtocolError` do, so a `catch (const std::runtime_error&)` misses the first three. `SocketClosedException`, `SocketTimeoutException` and `QUICClosedException` derive from `std::exception`, not `std::runtime_error`; `SocketErrorException` (through `std::system_error`), `QUICException`, `TLSException`, `HTTP1ProtocolError` and `HTTP3ProtocolError` do, so a `catch (const std::runtime_error&)` misses the first three.
### Spellings ### Spellings

View file

@ -4,13 +4,13 @@ Guidance for coding agents and contributors working on Crafter.Network itself. T
## Project overview ## Project overview
Crafter.Network is the Crafter engine's networking library: one C++ module, `Crafter.Network`, with fourteen partitions behind its primary interface (fifteen interface files), with TCP, a byte-stream seam and a libssl TLS transport, HTTP/1.1 (client, listener and a transport-free codec), QUIC over msquic, HTTP/3 (client, listener and a wire-format partition) and WebTransport on the HTTP/3 listener. One source tree builds three libraries: native Linux (everything), native with `--no-quic` (TCP, TLS and HTTP/1.1, no msquic), and the browser (`wasm32-*`: the client half of HTTP and WebTransport over `fetch()` and the `WebTransport` API, `*Async` only). The only crafter-build dependency is Crafter.Thread, whose `ThreadPool` runs every callback and `*Async` call; msquic is an `ExternalDependency` built with CMake, and OpenSSL 3 is a system package. Crafter.Network is the Crafter engine's networking library: one C++ module, `Crafter.Network`, with fifteen partitions behind its primary interface (sixteen interface files), with TCP, URLs, a byte-stream seam and a libssl TLS transport, HTTP/1.1 (client, listener and a transport-free codec), QUIC over msquic, HTTP/3 (client, listener and a wire-format partition) and WebTransport on the HTTP/3 listener. One source tree builds three libraries: native Linux (everything), native with `--no-quic` (TCP, TLS and HTTP/1.1, no msquic), and the browser (`wasm32-*`: the client half of HTTP and WebTransport over `fetch()` and the `WebTransport` API, `*Async` only). The only crafter-build dependency is Crafter.Thread, whose `ThreadPool` runs every callback and `*Async` call; msquic is an `ExternalDependency` built with CMake, and OpenSSL 3 is a system package.
| Path | What lives there | | Path | What lives there |
|---|---| |---|---|
| `interfaces/Crafter.Network.cppm` | The umbrella: `export import`s every partition, under `#ifndef CRAFTER_NETWORK_NO_QUIC` and `#ifndef CRAFTER_NETWORK_BROWSER` for the ones a build leaves out. | | `interfaces/Crafter.Network.cppm` | The umbrella: `export import`s every partition, under `#ifndef CRAFTER_NETWORK_NO_QUIC` and `#ifndef CRAFTER_NETWORK_BROWSER` for the ones a build leaves out. |
| `interfaces/Crafter.Network-<Partition>.cppm` | One partition per file. The native-only ones wrap their whole body in `#ifndef CRAFTER_NETWORK_BROWSER`, so the browser build compiles them as empty modules. | | `interfaces/Crafter.Network-<Partition>.cppm` | One partition per file. The native-only ones wrap their whole body in `#ifndef CRAFTER_NETWORK_BROWSER`, so the browser build compiles them as empty modules. |
| `implementations/Crafter.Network-<Partition>.cpp` | The native implementation units, `Crafter.Network:<Partition>_impl`. `HTTP`, `HTTP1` and `HTTP3` have none: they are inline. | | `implementations/Crafter.Network-<Partition>.cpp` | The native implementation units, `Crafter.Network:<Partition>_impl`. `HTTP`, `HTTP1`, `HTTP3` and `URL` have none: they are inline. |
| `implementations/Crafter.Network-ClientHTTP-Browser.cpp`, `-ClientQUIC-Browser.cpp` | The browser implementations of those two partitions, over the JS bridge. | | `implementations/Crafter.Network-ClientHTTP-Browser.cpp`, `-ClientQUIC-Browser.cpp` | The browser implementations of those two partitions, over the JS bridge. |
| `additional/network-env.js` | The JS bridge: `fetch()` and `WebTransport` behind the `env` imports the wasm declares, shipped next to a consumer's `.wasm` through `cfg.files`. | | `additional/network-env.js` | The JS bridge: `fetch()` and `WebTransport` behind the `env` imports the wasm declares, shipped next to a consumer's `.wasm` through `cfg.files`. |
| `project.cpp` | The build description: one static library, `crafter-network`, in its three variants, the msquic external, the OpenSSL link flags and the test declarations. | | `project.cpp` | The build description: one static library, `crafter-network`, in its three variants, the msquic external, the OpenSSL link flags and the test declarations. |
@ -21,12 +21,13 @@ Crafter.Network is the Crafter engine's networking library: one C++ module, `Cra
Partitions and what they own: Partitions and what they own:
- `:ClientTCP`, `:ListenerTCP`: POSIX sockets, IPv4. `SocketClosedException`, `SocketTimeoutException`, the receive semantics (what arrived, never a padded buffer) and the `onError` of the async receives. The listener hands its callback a `new ClientTCP` the callback owns. - `:ClientTCP`, `:ListenerTCP`: POSIX sockets, IPv4 and IPv6. `SocketClosedException`, `SocketTimeoutException`, `SocketErrorException` (a `std::system_error` with the `SocketPhase` that failed; `ResolverCategory()` for `getaddrinfo`'s codes), the receive semantics (what arrived, never a padded buffer) and the `onError` of the async receives. The listener hands its callback a `new ClientTCP` the callback owns.
- `:Stream`: `ByteStream` (`ReadSome` and `Write` with a timeout per call, `Shutdown`, `Descriptor`, `Protocol`, `Secure`) and `PlainStream`, so one HTTP/1.1 implementation serves `http://` and `https://`. `SetNonBlocking`, `PollDescriptor`. - `:Stream`: `ByteStream` (`ReadSome` and `Write` with a timeout per call, `Shutdown`, `Descriptor`, `Protocol`, `Secure`) and `PlainStream`, so one HTTP/1.1 implementation serves `http://` and `https://`. `SetNonBlocking`, `PollDescriptor`.
- `:TLS`: `TLSContext`, `TLSStream`, both credential structs, `GetSelfSignedCertificatePem`. OpenSSL stays behind the `Impl` pointers; no OpenSSL header reaches an importer. - `:TLS`: `TLSContext`, `TLSStream`, both credential structs, `GetSelfSignedCertificatePem`. OpenSSL stays behind the `Impl` pointers; no OpenSSL header reaches an importer.
- `:HTTP`: `HTTPRequest`, `HTTPResponse`, the `Create*` helpers, `PathWithoutQueryHTTP`. Shared by both HTTP versions and by every build. - `:HTTP`: `HTTPRequest`, `HTTPResponse`, the `Create*` helpers, `PathWithoutQueryHTTP`. Shared by both HTTP versions and by every build.
- `:URL`: `URL`, an absolute URL as RFC 3986 reads it (`Parse`), the resolution of a reference against it (`Resolve`, RFC 3986 §5.2, dot segments removed), the recomposition, and the pieces a request is built from. Inline, native only; what `ClientHTTP1::Fetch` follows a Location with.
- `:HTTP1`: the RFC 9112 codec, inline: `SerializeRequest`, `SerializeResponse`, `SerializeContinue`, `MessageParser`, `MessageLimits`, `HTTP1ProtocolError`, and the framing it refuses to guess at. - `:HTTP1`: the RFC 9112 codec, inline: `SerializeRequest`, `SerializeResponse`, `SerializeContinue`, `MessageParser`, `MessageLimits`, `HTTP1ProtocolError`, and the framing it refuses to guess at.
- `:ClientHTTP1`, `:ListenerHTTP1`: HTTP/1.1 over a `ByteStream&`. The listener is a `ListenerTCP` with a thread per accepted connection; the client keeps one pooled connection with a replay-once rule. - `:ClientHTTP1`, `:ListenerHTTP1`: HTTP/1.1 over a `ByteStream&`. The listener is a `ListenerTCP` with a thread per accepted connection; the client keeps one pooled connection with a replay-once rule, and `ClientHTTP1::Fetch` is the URL fetch that follows redirects by RFC 9110 §15.4 with a client per hop (`FetchOptions`, `FetchResult`, `FetchStop`).
- `:ClientQUIC`: `ClientQUIC` and `QUICStream` for both ends of a connection, `QUICSettings`, both QUIC credential structs, `QUICException`, `QUICClosedException`. The native unit holds the process-wide `MsQuicRuntime` (API table and registration, closed by a static destructor at exit), the stream registry with its refcounted handles, and the drain in `~ClientQUIC`. - `:ClientQUIC`: `ClientQUIC` and `QUICStream` for both ends of a connection, `QUICSettings`, both QUIC credential structs, `QUICException`, `QUICClosedException`. The native unit holds the process-wide `MsQuicRuntime` (API table and registration, closed by a static destructor at exit), the stream registry with its refcounted handles, and the drain in `~ClientQUIC`.
- `:ListenerQUIC`: the msquic listener, its four `Listen*` variants, the self-signed certificate (made by the `openssl` CLI and cached under `/tmp/crafter-network-quic-cert/`), `ComputeCertificateHashSHA256`, `GetSelfSignedCertificatePath`. - `:ListenerQUIC`: the msquic listener, its four `Listen*` variants, the self-signed certificate (made by the `openssl` CLI and cached under `/tmp/crafter-network-quic-cert/`), `ComputeCertificateHashSHA256`, `GetSelfSignedCertificatePath`.
- `:HTTP3`: QUIC varints, HTTP/3 frames, the QPACK static table, Huffman decoding, the control-stream preludes and the WebTransport stream prefixes. Native only: it throws for protocol errors. - `:HTTP3`: QUIC varints, HTTP/3 frames, the QPACK static table, Huffman decoding, the control-stream preludes and the WebTransport stream prefixes. Native only: it throws for protocol errors.
@ -50,9 +51,9 @@ crafter-build # native: TCP, TLS, HTTP/1.1, QUI
crafter-build --no-quic # native without QUIC, HTTP/3, WebTransport and msquic crafter-build --no-quic # native without QUIC, HTTP/3, WebTransport and msquic
crafter-build --local # Crafter.Thread from ../Crafter.Thread crafter-build --local # Crafter.Thread from ../Crafter.Thread
crafter-build --target=wasm32-wasip1 # the browser library crafter-build --target=wasm32-wasip1 # the browser library
crafter-build test # the suite, native: 23 tests crafter-build test # the suite, native: 30 tests
crafter-build test 'Should*HTTP1' # a name glob crafter-build test 'Should*HTTP1' # a name glob
crafter-build test --no-quic # the TCP, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 12 crafter-build test --no-quic # the TCP, URL, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 19
crafter-build test --list crafter-build test --list
crafter-build test --jobs=1 --timeout=120 crafter-build test --jobs=1 --timeout=120
crafter-build clean crafter-build clean
@ -130,8 +131,7 @@ Decided deliberately; change them only with the maintainer's say-so.
- **More comments the code outgrew**: `-ListenerQUIC.cpp` says the certificate pair lives in a `mkdtemp`'d directory for the life of the process (it is the fixed `/tmp/crafter-network-quic-cert/`, reused) and is valid 13 days (`-days 10`); `ClientQUIC.cppm` calls `selfSigned` an in-memory certificate (it is on disk, made by the CLI); `ListenerHTTP.cppm` says `Listen()` dispatches on the calling thread (it is `ListenSyncAsync`); the link-flag comment in `project.cpp` still has msquic bundling quictls (it links the system `libssl`). Fix a comment when you touch its code. - **More comments the code outgrew**: `-ListenerQUIC.cpp` says the certificate pair lives in a `mkdtemp`'d directory for the life of the process (it is the fixed `/tmp/crafter-network-quic-cert/`, reused) and is valid 13 days (`-days 10`); `ClientQUIC.cppm` calls `selfSigned` an in-memory certificate (it is on disk, made by the CLI); `ListenerHTTP.cppm` says `Listen()` dispatches on the calling thread (it is `ListenSyncAsync`); the link-flag comment in `project.cpp` still has msquic bundling quictls (it links the system `libssl`). Fix a comment when you touch its code.
- **`WebTransportSession::OpenStream(true)` does not throw** as its comment says; it writes the unidirectional prefix. Peer-initiated unidirectional WebTransport streams are drained by the listener and never reach a session. - **`WebTransportSession::OpenStream(true)` does not throw** as its comment says; it writes the unidirectional prefix. Peer-initiated unidirectional WebTransport streams are drained by the listener and never reach a session.
- **`ListenerTCP::concurrentClientLimit` is stored and never read**; only `totalClientLimit` ends the loop. - **`ListenerTCP::concurrentClientLimit` is stored and never read**; only `totalClientLimit` ends the loop.
- **TCP and HTTP/1.1 are IPv4 only** (`AF_INET` in `ClientTCP` and `ListenerTCP`); QUIC listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`). - **`ListenerTCP(port, ...)` listens on every IPv4 address**; given an address it binds that one, IPv4 or IPv6, and port 0 asks the kernel for one. `ClientTCP` resolves for both families and tries every address; QUIC listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`).
- **The Flatpak's host triple is `x86_64-unknown-linux-gnu`**, and the tests register on any Linux triple since the TCP-only commit; before it they never ran inside the Flatpak. - **The Flatpak's host triple is `x86_64-unknown-linux-gnu`**, and the tests register on any Linux triple since the TCP-only commit; before it they never ran inside the Flatpak.
- **A port in use from a crashed run** is `Could not bind port <n>: Address already in use` on the HTTP/1.1 side and a `ListenerStart failed` `QUICException` on the QUIC side; find the stray process rather than changing the port. - **A port in use from a crashed run** is `Could not bind port <n>: Address already in use` on the HTTP/1.1 side and a `ListenerStart failed` `QUICException` on the QUIC side; find the stray process rather than changing the port.
- **A warning is printed only when its unit actually recompiles**; a build with nothing to do is silent and says nothing about warnings. - **A warning is printed only when its unit actually recompiles**; a build with nothing to do is silent and says nothing about warnings.
- **`claude/tcp-only` carries four unmerged TCP and HTTP/1.1 commits** (IPv4 and IPv6 connects with the reason a connect failed, which retires the IPv4-only gotcha for the client; a bind address and port 0 for `ListenerTCP`; a bounded connect and `FetchHTTP1` following redirects; errno in the TCP errors), and `bin/` and `build/` keep its six test directories until `clean`.

View file

@ -53,6 +53,19 @@ client.Send("Hello World", 11);
std::vector<char> data = client.RecieveSync(); std::vector<char> data = client.RecieveSync();
``` ```
The host is resolved for IPv4 and IPv6 alike and every address it resolves to is tried in turn, as Python's `socket.create_connection` does, so `localhost` reaches a server that listens on `::1` only. The optional third argument is a connect timeout, applied to each address on its own. A failed resolve, connect, send or receive throws `SocketErrorException`: a `std::system_error`, so still a `std::runtime_error` with the same message, whose `code()` is the errno under `std::generic_category()` or the `getaddrinfo` result under `Crafter::ResolverCategory()`, and whose `phase` names the step (`SocketPhase::Resolve`, `Open`, `Connect`, `Send` or `Receive`), so a caller can report the cause without parsing the text.
```cpp
try {
Crafter::ClientTCP client("localhost", 5432, std::chrono::seconds(5));
} catch (const Crafter::SocketErrorException& e) {
if (e.code() == std::errc::connection_refused) std::println("nothing listens there");
std::println("[Errno {}] {}", e.code().value(), e.code().message()); // [Errno 111] Connection refused
} catch (const Crafter::SocketTimeoutException&) {
std::println("timed out");
}
```
#### ListenerTCP #### ListenerTCP
```cpp ```cpp
// Create a TCP listener // Create a TCP listener
@ -63,6 +76,15 @@ Crafter::ListenerTCP listener(8080, callback);
listener.ListenSyncSync(); // Synchronous listening listener.ListenSyncSync(); // Synchronous listening
``` ```
The port-only constructor listens on every IPv4 interface. To keep a listener to this machine, or to let the kernel pick the port, name the address and pass port 0; `Port()` reports the port it got. The address must be numeric (`127.0.0.1`, `::1`, `0.0.0.0`, `::`): a name such as `localhost` is refused rather than bound to whichever address the resolver ranks first.
```cpp
Crafter::ListenerTCP broker("127.0.0.1", 0, callback);
const std::uint16_t port = broker.Port();
std::thread([&] { broker.ListenSyncSync(); }).detach();
// ... broker.Stop() ends the loop from another thread.
```
### HTTP/3 Components ### HTTP/3 Components
HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers). HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers).
@ -108,7 +130,26 @@ Crafter::HTTPResponse response = client.Send(
); );
``` ```
The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is the scheme default — 443 under TLS, 80 without). The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is the scheme default — 443 under TLS, 80 without; an IPv6 literal goes in brackets).
Three timeouts bound a request: `connectTimeout` (default 15 s) for the TCP connect, per address the host resolves to; `handshakeTimeout` (default 15 s) for the TLS handshake; and `timeout` (default 30 s) for each wait on the next piece of a response.
#### ClientHTTP1::Fetch
`ClientHTTP1::Fetch(url, options)` is one request to an `http://` or `https://` URL, following redirects by RFC 9110 §15.4:
```cpp
Crafter::FetchOptions options;
options.timeout = std::chrono::seconds(10);
options.headers["accept-encoding"] = "identity";
Crafter::FetchResult result = Crafter::ClientHTTP1::Fetch("http://localhost:8080/", options);
// result.response: the last response; result.url: the URL that sent it (a Crafter::URL);
// result.redirects: how many were followed; result.stop: why a redirect was not followed, or None.
```
301, 302, 303, 307 and 308 are followed for every method: a 303 turns the request into a GET (a HEAD stays a HEAD) without its body and content headers, a 301 or 302 does the same for a POST alone, and a 307 or 308 keeps the method and the body. A relative `Location` is resolved against the URL that sent it (RFC 3986, through `Crafter::URL::Resolve`), and a redirect may move between `http://` and `https://` (`options.credentials` applies to every TLS hop). `Authorization`, `Proxy-Authorization` and `Cookie` are not carried to another origin. Each hop is a client of its own, so a redirect's body is read and its connection closed before the next request goes out. A redirect comes back as the response, with `result.stop` saying why, when it has no usable `Location`, points at another scheme, points back at a URL the chain already visited, or would be the eleventh (`maxRedirects`, 10). Proxies are not consulted. A URL that is not `http(s)://`, has no host or has a bad port throws `std::invalid_argument`.
`Crafter::URL` is the URL type behind it, exported for callers that build or resolve URLs themselves: `URL::Parse(text)`, `url.Resolve(reference)`, `url.ToString()`, and the pieces a request is built from (`Authority()`, `Target()`, `PortOrDefault()`, `Secure()`, `SameOrigin(other)`).
#### ListenerHTTP1 #### ListenerHTTP1
```cpp ```cpp
@ -287,6 +328,12 @@ The library includes tests covering:
- HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off - HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off
- HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify` - HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify`
- Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top - Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top
- Dual-stack connect (`ShouldConnectDualStack`) — `localhost` reaches a server on `::1` only and one on `127.0.0.1` only, an IPv6 literal connects over IPv6, and the last failure is what is thrown when no address answers; skips on a host without IPv6 loopback
- Connect errors (`ShouldReportConnectErrors`) — a refused connect and an unresolvable name throw `SocketErrorException` with their errno or getaddrinfo code and the message ClientTCP always threw, and still reach a `std::runtime_error` handler
- Send and receive errors (`ShouldReportIoErrors`) — a connection the peer resets fails the next receive, and a send into it, with `SocketErrorException` carrying ECONNRESET or EPIPE under the plain strerror text a receive and a send always threw
- Loopback listeners (`ShouldBindLoopbackEphemeral`) — a listener on `127.0.0.1` at port 0 reports its port, takes loopback clients and refuses them through the host's other address; `::1` works too, a name is refused, and the port-only constructor still listens on every interface
- HTTP/1.1 timeouts (`ShouldTimeoutSilentHTTP1`) — a server that accepts and never answers fails after `timeout`, and a connect nobody answers after `connectTimeout`
- Redirects (`ShouldFollowRedirectsHTTP1`) — relative and `..` Locations, an http-to-https hop, a loop and the redirect limit, a redirect without Location or to another scheme, a POST after 303, 302 and 307 and a PUT after 302, credentials dropped across origins, and the URLs `Fetch` refuses; `ShouldParseURLs` runs RFC 3986 §5.4's resolution examples and the parse rules
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them. The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them.

View file

@ -11,6 +11,7 @@ import :HTTP;
import :HTTP1; import :HTTP1;
import :Stream; import :Stream;
import :TLS; import :TLS;
import :URL;
import Crafter.Thread; import Crafter.Thread;
import std; import std;
@ -21,8 +22,10 @@ namespace {
// which is what every other HTTP/1.1 client on the wire does and what // which is what every other HTTP/1.1 client on the wire does and what
// virtual-host matching on the far side tends to expect. // virtual-host matching on the far side tends to expect.
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) { std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
if (port == (secure ? 443 : 80)) return host; // An IPv6 literal goes in brackets, or its colons read as the port's.
return host + ":" + std::to_string(port); const std::string name = host.contains(':') ? "[" + host + "]" : host;
if (port == (secure ? 443 : 80)) return name;
return name + ":" + std::to_string(port);
} }
} }
@ -37,8 +40,8 @@ struct ClientHTTP1::Impl {
std::unique_ptr<ByteStream> stream; std::unique_ptr<ByteStream> stream;
std::string protocol; std::string protocol;
void Connect(std::chrono::milliseconds handshakeTimeout) { void Connect(std::chrono::milliseconds connectTimeout, std::chrono::milliseconds handshakeTimeout) {
tcp = std::make_unique<ClientTCP>(host, port); tcp = std::make_unique<ClientTCP>(host, port, connectTimeout);
try { try {
if (tls) { if (tls) {
stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout); stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout);
@ -146,7 +149,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
// read fails. Nothing is replayed once a response byte has arrived. // read fails. Nothing is replayed once a response byte has arrived.
for (int attempt = 0;; ++attempt) { for (int attempt = 0;; ++attempt) {
const bool reused = impl->tcp != nullptr; const bool reused = impl->tcp != nullptr;
if (!reused) impl->Connect(handshakeTimeout); if (!reused) impl->Connect(connectTimeout, handshakeTimeout);
bool received = false; bool received = false;
try { try {
@ -176,3 +179,97 @@ void ClientHTTP1::SendAsync(const HTTPRequest& request,
} }
}); });
} }
namespace {
std::int32_t StatusCode(const HTTPResponse& response) {
std::int32_t code = 0;
std::from_chars(response.status.data(), response.status.data() + response.status.size(), code);
return code;
}
bool IsRedirect(std::int32_t code) {
return code == 301 || code == 302 || code == 303 || code == 307 || code == 308;
}
}
FetchResult ClientHTTP1::Fetch(std::string_view url, const FetchOptions& options) {
URL current = URL::Parse(url);
if (current.scheme != "http" && current.scheme != "https") {
throw std::invalid_argument("ClientHTTP1::Fetch: not an http:// or https:// URL: " + std::string(url));
}
std::string method = options.method.empty() ? std::string("GET") : options.method;
std::unordered_map<std::string, std::string> headers = options.headers;
std::string body = options.body;
// Every URL the chain has been sent to: a redirect back to one of them
// would loop.
std::set<std::string> visited { current.ToString() };
FetchResult result;
while (true) {
// A client per hop: one ClientHTTP1 is one origin, and a redirect
// may leave it. Destroying the client closes the hop's connection
// once its response, body included, has been read.
ClientHTTP1 client = current.Secure() ? ClientHTTP1(current.host, current.PortOrDefault(), options.credentials)
: ClientHTTP1(current.host, current.PortOrDefault());
client.connectTimeout = options.connectTimeout;
client.handshakeTimeout = options.handshakeTimeout;
client.timeout = options.timeout;
client.limits = options.limits;
HTTPRequest request;
request.method = method;
request.scheme = current.scheme;
request.authority = current.Authority();
request.path = current.Target();
request.headers = headers;
request.body = body;
result.response = client.Send(request);
result.url = current;
const std::int32_t code = StatusCode(result.response);
if (!IsRedirect(code)) return result;
const auto location = result.response.headers.find("location");
if (location == result.response.headers.end()) {
result.stop = FetchStop::NoLocation;
return result;
}
URL next;
try {
next = current.Resolve(location->second);
} catch (const std::invalid_argument&) {
result.stop = FetchStop::NoLocation;
return result;
}
if (next.scheme != "http" && next.scheme != "https") {
result.stop = FetchStop::OtherScheme;
return result;
}
if (result.redirects >= options.maxRedirects) {
result.stop = FetchStop::Limit;
return result;
}
if (!visited.insert(next.ToString()).second) {
result.stop = FetchStop::Loop;
return result;
}
++result.redirects;
// RFC 9110 §15.4: a 303 asks for a GET of the Location; a 301 or 302
// keeps the method, but a POST becomes a GET, as the user agents the
// sections describe have always done; a 307 or 308 keeps the method
// and the body. A request that turned into a GET loses the body and
// the headers that described it.
const bool toGet = (code == 303 && method != "HEAD") || ((code == 301 || code == 302) && method == "POST");
if (toGet) {
method = "GET";
body.clear();
std::erase_if(headers, [](const auto& header) { return header.first.starts_with("content-"); });
}
// Credentials stay with the origin they were meant for.
if (!next.SameOrigin(current)) {
headers.erase("authorization");
headers.erase("proxy-authorization");
headers.erase("cookie");
}
current = std::move(next);
}
}

View file

@ -38,11 +38,85 @@ namespace {
return read; return read;
} }
// A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out. // A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out;
// anything else carries its errno, under the message a receive always
// threw.
[[noreturn]] void ThrowRecieveError() { [[noreturn]] void ThrowRecieveError() {
if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException(); const int failure = errno;
throw std::runtime_error(std::strerror(errno)); if (failure == EAGAIN || failure == EWOULDBLOCK) throw SocketTimeoutException();
throw SocketErrorException(SocketPhase::Receive, std::error_code(failure, std::generic_category()));
} }
// getaddrinfo's results as an error category: the number is one of
// netdb.h's EAI_* codes and the text is what gai_strerror makes of it.
class ResolverErrorCategory : public std::error_category {
public:
const char* name() const noexcept override {
return "resolver";
}
std::string message(int code) const override {
return gai_strerror(code);
}
};
// One connect attempt to one address: a connected, blocking socket, or a
// throw that leaves nothing open.
int ConnectTo(const sockaddr* address, socklen_t length, std::chrono::milliseconds timeout) {
const int fd = socket(address->sa_family, SOCK_STREAM, 0);
if (fd == -1) {
// An address family the host cannot open — IPv6 switched off,
// say — fails this address alone; the caller moves on.
const int failure = errno;
throw SocketErrorException(SocketPhase::Open, std::error_code(failure, std::generic_category()), "Could not open socket");
}
// With a timeout the connect runs non-blocking, so poll can bound the
// wait; the socket is made blocking again once it is connected.
const bool bounded = timeout > std::chrono::milliseconds::zero();
const int flags = fcntl(fd, F_GETFL, 0);
if (bounded) {
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int failure = 0;
if (connect(fd, address, length) == -1) {
failure = errno;
if (failure == EINPROGRESS) {
pollfd connecting { .fd = fd, .events = POLLOUT, .revents = 0 };
const int waitMs = static_cast<int>(std::min<std::chrono::milliseconds::rep>(timeout.count(), std::numeric_limits<int>::max()));
int ready;
do {
ready = poll(&connecting, 1, waitMs);
} while (ready == -1 && errno == EINTR);
if (ready == 0) {
close(fd);
throw SocketTimeoutException();
}
if (ready == -1) {
failure = errno;
} else {
// The connect's own result.
socklen_t size = sizeof(failure);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &failure, &size);
}
}
}
if (failure != 0) {
// Report the failure instead of handing back a socket that is not
// connected to anything — every later send/recv on it would fail
// with a far less obvious error.
close(fd);
throw SocketErrorException(SocketPhase::Connect, std::error_code(failure, std::generic_category()), "Could not connect to server");
}
if (bounded) {
fcntl(fd, F_SETFL, flags);
}
return fd;
}
}
const std::error_category& Crafter::ResolverCategory() noexcept {
static const ResolverErrorCategory category;
return category;
} }
ClientTCP::ClientTCP(int socketid) : socketid(socketid) ClientTCP::ClientTCP(int socketid) : socketid(socketid)
@ -55,17 +129,28 @@ ClientTCP::ClientTCP(const char* hostName, std::uint16_t port, std::chrono::mill
// getaddrinfo rather than gethostbyname: the latter is not thread safe, // getaddrinfo rather than gethostbyname: the latter is not thread safe,
// and it signals failure by returning null — which was then dereferenced // and it signals failure by returning null — which was then dereferenced
// straight into a crash on any unresolvable host. // straight into a crash on any unresolvable host.
//
// AF_UNSPEC, and every result kept: `localhost` resolves to ::1 and
// 127.0.0.1, and a server may listen on either one alone. Asking for
// AF_INET only, as this used to, made a server bound to ::1 unreachable
// by name. No AI_ADDRCONFIG, as Python's create_connection passes none
// either: it hides ::1 on a host whose only IPv6 address is loopback.
addrinfo hints{}; addrinfo hints{};
hints.ai_family = AF_INET; hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM; hints.ai_socktype = SOCK_STREAM;
addrinfo* resolved = nullptr; addrinfo* resolved = nullptr;
const std::string service = std::to_string(port); const std::string service = std::to_string(port);
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved); const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
if (status != 0 || resolved == nullptr) { if (status != 0 || resolved == nullptr) {
throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': " throw SocketErrorException(SocketPhase::Resolve, std::error_code(status, ResolverCategory()),
+ gai_strerror(status)); std::string("Could not resolve host '") + hostName + "'");
}
for (const addrinfo* entry = resolved; entry != nullptr; entry = entry->ai_next) {
Address address {};
std::memcpy(&address.storage, entry->ai_addr, entry->ai_addrlen);
address.length = entry->ai_addrlen;
addresses.push_back(address);
} }
serv_addr = *reinterpret_cast<sockaddr_in*>(resolved->ai_addr);
freeaddrinfo(resolved); freeaddrinfo(resolved);
Connect(connectTimeout); Connect(connectTimeout);
@ -79,7 +164,7 @@ ClientTCP::ClientTCP(std::string hostName, std::uint16_t port, std::chrono::mill
// The moved-from object gives up ownership; the socket itself stays open — // The moved-from object gives up ownership; the socket itself stays open —
// closing it here (as this used to) meant moving a ClientTCP silently // closing it here (as this used to) meant moving a ClientTCP silently
// dropped the connection it was carrying. // dropped the connection it was carrying.
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) { ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid), addresses(std::move(other.addresses)) {
other.socketid = -1; other.socketid = -1;
} }
@ -92,52 +177,23 @@ ClientTCP::~ClientTCP()
} }
void ClientTCP::Connect(std::chrono::milliseconds timeout) { void ClientTCP::Connect(std::chrono::milliseconds timeout) {
if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){ if (addresses.empty()) {
throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno)); throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again");
}
// Each address in turn, in the order getaddrinfo ranked them; when none
// connects, the caller sees what the last one failed with, as with
// Python's create_connection.
std::exception_ptr last;
for (const Address& address : addresses) {
try {
socketid = ConnectTo(reinterpret_cast<const sockaddr*>(&address.storage), address.length, timeout);
return;
} catch (...) {
last = std::current_exception();
} }
// With a timeout the connect runs non-blocking, so poll can bound the
// wait; the socket is made blocking again once it is connected.
const bool bounded = timeout > std::chrono::milliseconds::zero();
const int flags = fcntl(socketid, F_GETFL, 0);
if (bounded) {
fcntl(socketid, F_SETFL, flags | O_NONBLOCK);
} }
int failure = 0;
if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){
failure = errno;
if (failure == EINPROGRESS) {
pollfd connecting { .fd = socketid, .events = POLLOUT, .revents = 0 };
const int waitMs = static_cast<int>(std::min<std::chrono::milliseconds::rep>(timeout.count(), std::numeric_limits<int>::max()));
int ready;
do {
ready = poll(&connecting, 1, waitMs);
} while (ready == -1 && errno == EINTR);
if (ready == 0) {
close(socketid);
socketid = -1; socketid = -1;
throw SocketTimeoutException(); std::rethrow_exception(last);
}
if (ready == -1) {
failure = errno;
} else {
// The connect's own result.
socklen_t length = sizeof(failure);
getsockopt(socketid, SOL_SOCKET, SO_ERROR, &failure, &length);
}
}
}
if (failure != 0) {
// Report the failure instead of handing back a socket that is not
// connected to anything — every later send/recv on it would fail
// with a far less obvious error.
close(socketid);
socketid = -1;
throw std::runtime_error(std::string("Could not connect to server: ") + std::strerror(failure));
}
if (bounded) {
fcntl(socketid, F_SETFL, flags);
}
} }
void ClientTCP::Stop() { void ClientTCP::Stop() {
@ -175,7 +231,8 @@ void ClientTCP::Send(const void* buffer, std::uint32_t size) const {
throw SocketClosedException(); throw SocketClosedException();
} else if (status < 0) { } else if (status < 0) {
if (errno == EINTR) continue; if (errno == EINTR) continue;
throw std::runtime_error(std::strerror(errno)); const int failure = errno;
throw SocketErrorException(SocketPhase::Send, std::error_code(failure, std::generic_category()));
} }
sent += static_cast<std::uint32_t>(status); sent += static_cast<std::uint32_t>(status);
} }

View file

@ -12,6 +12,7 @@ module;
#include <sys/time.h> #include <sys/time.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <strings.h> #include <strings.h>
#include <netdb.h>
#include <cerrno> #include <cerrno>
module Crafter.Network:ListenerTCP_impl; module Crafter.Network:ListenerTCP_impl;
@ -21,14 +22,30 @@ import Crafter.Thread;
using namespace Crafter; using namespace Crafter;
ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) { ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit)
sockaddr_in servAddr; : ListenerTCP("0.0.0.0", port, std::move(connectCallback), concurrentClientLimit, totalClientLimit) {}
bzero((char*)&servAddr, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(port);
s = socket(AF_INET, SOCK_STREAM, 0); ListenerTCP::ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) {
// AI_NUMERICHOST: no lookup, so `localhost` is refused here rather than
// bound to whichever of ::1 and 127.0.0.1 the resolver ranks first.
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE | AI_NUMERICHOST | AI_NUMERICSERV;
const std::string host(bindAddress);
const std::string service = std::to_string(port);
addrinfo* resolved = nullptr;
const int status = getaddrinfo(host.c_str(), service.c_str(), &hints, &resolved);
if (status != 0 || resolved == nullptr) {
throw std::runtime_error("Could not bind " + host + ": not a numeric IPv4 or IPv6 address (" + gai_strerror(status) + ")");
}
sockaddr_storage servAddr {};
std::memcpy(&servAddr, resolved->ai_addr, resolved->ai_addrlen);
const socklen_t servAddrLength = resolved->ai_addrlen;
const int family = resolved->ai_family;
freeaddrinfo(resolved);
s = socket(family, SOCK_STREAM, 0);
if(s < 0) if(s < 0)
{ {
throw std::runtime_error("Error establishing the server socket"); throw std::runtime_error("Error establishing the server socket");
@ -39,7 +56,7 @@ ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> con
int reuse = 1; int reuse = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if(bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)) < 0) { if(bind(s, reinterpret_cast<const sockaddr*>(&servAddr), servAddrLength) < 0) {
// Ignoring this left the listener silently accepting nothing at // Ignoring this left the listener silently accepting nothing at
// all, with no hint as to why. // all, with no hint as to why.
const std::string reason = std::strerror(errno); const std::string reason = std::strerror(errno);
@ -55,6 +72,18 @@ ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> con
} }
} }
std::uint16_t ListenerTCP::Port() const {
sockaddr_storage address {};
socklen_t length = sizeof(address);
if (getsockname(s, reinterpret_cast<sockaddr*>(&address), &length) == -1) {
throw std::runtime_error(std::string("Could not read the listening port: ") + std::strerror(errno));
}
if (address.ss_family == AF_INET6) {
return ntohs(reinterpret_cast<const sockaddr_in6*>(&address)->sin6_port);
}
return ntohs(reinterpret_cast<const sockaddr_in*>(&address)->sin_port);
}
void ListenerTCP::Stop() { void ListenerTCP::Stop() {
running = false; running = false;
shutdown(s, SHUT_RDWR); shutdown(s, SHUT_RDWR);
@ -64,7 +93,7 @@ void ListenerTCP::Stop() {
void ListenerTCP::ListenSyncSync() { void ListenerTCP::ListenSyncSync() {
while (running && totalClientCounter < totalClientLimit) { while (running && totalClientCounter < totalClientLimit) {
sockaddr_in newSockAddr; sockaddr_storage newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr); socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) { if (client > 0) {
@ -81,7 +110,7 @@ void ListenerTCP::ListenSyncSync() {
void ListenerTCP::ListenSyncAsync() { void ListenerTCP::ListenSyncAsync() {
while (running && totalClientCounter < totalClientLimit) { while (running && totalClientCounter < totalClientLimit) {
sockaddr_in newSockAddr; sockaddr_storage newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr); socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) { if (client > 0) {
@ -97,7 +126,7 @@ void ListenerTCP::ListenSyncAsync() {
void ListenerTCP::ListenAsyncSync() { void ListenerTCP::ListenAsyncSync() {
ThreadPool::Enqueue([this]() { ThreadPool::Enqueue([this]() {
while (running && totalClientCounter < totalClientLimit) { while (running && totalClientCounter < totalClientLimit) {
sockaddr_in newSockAddr; sockaddr_storage newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr); socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) { if (client > 0) {
@ -114,7 +143,7 @@ void ListenerTCP::ListenAsyncSync() {
void ListenerTCP::ListenAsyncAsync() { void ListenerTCP::ListenAsyncAsync() {
ThreadPool::Enqueue([this]() { ThreadPool::Enqueue([this]() {
while (running && totalClientCounter < totalClientLimit) { while (running && totalClientCounter < totalClientLimit) {
sockaddr_in newSockAddr; sockaddr_storage newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr); socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) { if (client > 0) {

View file

@ -6,9 +6,53 @@ import std;
import :HTTP; import :HTTP;
import :HTTP1; import :HTTP1;
import :TLS; import :TLS;
import :URL;
#ifndef CRAFTER_NETWORK_BROWSER #ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter { namespace Crafter {
// What ClientHTTP1::Fetch needs beyond the URL.
export struct FetchOptions {
std::string method = "GET";
// Lowercase names, as on HTTPRequest. Host comes from the URL.
std::unordered_map<std::string, std::string> headers;
std::string body;
// As on ClientHTTP1, for every request of the chain.
std::chrono::milliseconds connectTimeout{15000};
std::chrono::milliseconds handshakeTimeout{15000};
std::chrono::milliseconds timeout{30000};
HTTP1::MessageLimits limits;
// For every https:// request of the chain.
TLSClientCredentials credentials;
// How many redirects are followed at most; the one after comes back
// unfollowed.
std::uint32_t maxRedirects = 10;
};
// Why a Fetch ended on a redirect it did not follow.
export enum class FetchStop {
// It did not: the chain ended on a final response.
None,
// The redirect carried no Location, or one that is no URL.
NoLocation,
// The Location names a scheme other than http or https.
OtherScheme,
// The Location is a URL the chain has already been sent to.
Loop,
// maxRedirects redirects had been followed already.
Limit,
};
export struct FetchResult {
// The last response of the chain: the final response, or the
// redirect that was not followed, with `stop` saying why.
HTTPResponse response;
// The URL that answered with `response`.
URL url;
// Redirects followed to get there.
std::uint32_t redirects = 0;
FetchStop stop = FetchStop::None;
};
// HTTP/1.1 client over TCP, with or without TLS, for peers that cannot // HTTP/1.1 client over TCP, with or without TLS, for peers that cannot
// speak HTTP/3. The request/response types are the ones the HTTP/3 client // speak HTTP/3. The request/response types are the ones the HTTP/3 client
// uses, so swapping ClientHTTP for ClientHTTP1 is a one-line change at the // uses, so swapping ClientHTTP for ClientHTTP1 is a one-line change at the
@ -78,6 +122,27 @@ namespace Crafter {
// Drop the pooled connection; the next Send() dials again. // Drop the pooled connection; the next Send() dials again.
void Disconnect(); void Disconnect();
// One request to an http:// or https:// URL, following redirects by
// RFC 9110 §15.4: 301, 302, 303, 307 and 308, for every method. A
// 303 turns the request into a GET (a HEAD stays a HEAD) without its
// body and content headers; 301 and 302 do the same for a POST
// alone; 307 and 308 keep the method and the body. The Location is
// resolved against the URL that sent it (URL::Resolve), and a
// redirect may move between http:// and https://, `credentials`
// serving every https:// hop. Authorization, Proxy-Authorization and
// Cookie are not carried to another origin. Each hop is a client of
// its own, since one ClientHTTP1 is one origin: the redirect's body
// is read and its connection closed before the next request goes
// out. A redirect without a usable Location, to another scheme, to
// a URL already visited, or past maxRedirects comes back as the
// response with `stop` set. Proxies are not consulted.
//
// Throws std::invalid_argument for a URL that is not http(s) or that
// URL::Parse refuses, and whatever a request throws otherwise
// (SocketErrorException, SocketTimeoutException, TLSException,
// HTTP1::HTTP1ProtocolError, std::runtime_error).
static FetchResult Fetch(std::string_view url, const FetchOptions& options = {});
// Limits applied to responses. Set before the first Send(). // Limits applied to responses. Set before the first Send().
HTTP1::MessageLimits limits; HTTP1::MessageLimits limits;
// How long to wait for the next piece of a response before giving // How long to wait for the next piece of a response before giving
@ -87,6 +152,11 @@ namespace Crafter {
// from `timeout` because it covers a multi-round-trip exchange before // from `timeout` because it covers a multi-round-trip exchange before
// any request has been written. // any request has been written.
std::chrono::milliseconds handshakeTimeout{15000}; std::chrono::milliseconds handshakeTimeout{15000};
// How long the TCP connect may take, for each address the host
// resolves to (see ClientTCP). Without it a host that drops the SYNs
// held Send() for the kernel's retries, about two minutes, whatever
// `timeout` said.
std::chrono::milliseconds connectTimeout{15000};
private: private:
struct Impl; struct Impl;

View file

@ -37,14 +37,60 @@ namespace Crafter {
} }
}; };
// The step of a connection a SocketErrorException failed at.
export enum class SocketPhase {
// getaddrinfo on the host name.
Resolve,
// socket() for an address the host resolved to.
Open,
// connect() to that address.
Connect,
Send,
Receive,
};
// The error category of getaddrinfo's results (EAI_NONAME, EAI_AGAIN,
// ...): a number space of its own that overlaps the errno values, so a
// resolver code is never confused with an errno, and whose message() is
// gai_strerror's text. One instance for the process, like
// std::generic_category().
export const std::error_category& ResolverCategory() noexcept;
// A resolve, a socket(), a connect, a send or a receive that failed. A
// std::system_error, so still a std::runtime_error for the handlers that
// catch one: code() is the errno under std::generic_category(), or the
// getaddrinfo result under ResolverCategory(), and what() is the text
// ClientTCP has always thrown, the code's own text after it. `phase`
// says which step failed. A caller that reports the cause in its own
// words — "[Errno 111] Connection refused", say — takes code().value()
// and code().message() instead of parsing what(), and compares code()
// with std::errc::connection_refused instead of the number.
export class SocketErrorException : public std::system_error {
public:
SocketErrorException(SocketPhase phase, std::error_code code, const std::string& what)
: std::system_error(code, what), phase(phase) {}
// Without a message of its own: what() is the code's text alone.
SocketErrorException(SocketPhase phase, std::error_code code)
: std::system_error(code), phase(phase) {}
SocketPhase phase;
};
export class ClientTCP { export class ClientTCP {
public: public:
int socketid; int socketid;
ClientTCP(int socket); ClientTCP(int socket);
// The host is resolved for IPv4 and IPv6 alike and every address it
// resolves to is tried in turn, the way Python's
// socket.create_connection does: `localhost` reaches a server that
// listens on ::1 only, as a dev server on a recent Node does. The
// first address that connects wins; when none does, what the last
// one failed with is thrown — SocketErrorException, or
// SocketTimeoutException for a timeout.
//
// A non-zero connectTimeout makes a connect nobody answers throw // A non-zero connectTimeout makes a connect nobody answers throw
// SocketTimeoutException after that long, where it would otherwise // SocketTimeoutException after that long, where it would otherwise
// wait out the kernel's retries, about two minutes. A refused connect // wait out the kernel's retries, about two minutes. It bounds each
// fails at once either way. // address on its own. A refused connect fails at once either way.
ClientTCP(const char* host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero()); ClientTCP(const char* host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
ClientTCP(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero()); ClientTCP(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
~ClientTCP(); ~ClientTCP();
@ -83,7 +129,13 @@ namespace Crafter {
// Into `buffer`, which must outlive the receive; the callback gets the count. // Into `buffer`, which must outlive the receive; the callback gets the count.
void RecieveAsync(std::uint32_t bufferSize, std::function<void(std::uint32_t)> recieveCallback, char* buffer, std::function<void(std::exception_ptr)> onError) const; void RecieveAsync(std::uint32_t bufferSize, std::function<void(std::uint32_t)> recieveCallback, char* buffer, std::function<void(std::exception_ptr)> onError) const;
private: private:
sockaddr_in serv_addr; struct Address {
sockaddr_storage storage;
socklen_t length;
};
// What the host resolved to, in getaddrinfo's order; empty for a
// socket handed over by accept().
std::vector<Address> addresses;
}; };
} }
#endif #endif

View file

@ -9,12 +9,24 @@ import :ClientTCP;
namespace Crafter { namespace Crafter {
export class ListenerTCP { export class ListenerTCP {
public: public:
bool running = true; // Atomic: Stop() is called from another thread than the one running
// an accept loop, which reads this on every turn.
std::atomic<bool> running = true;
std::function<void(ClientTCP*)> connectCallback; std::function<void(ClientTCP*)> connectCallback;
std::uint32_t concurrentClientLimit; std::uint32_t concurrentClientLimit;
std::uint32_t totalClientLimit; std::uint32_t totalClientLimit;
// Every IPv4 interface (0.0.0.0) at `port`.
ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(), std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max()); ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(), std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
// `bindAddress` is a numeric IPv4 or IPv6 address — 127.0.0.1 or ::1
// for this machine alone, 0.0.0.0 or :: for every interface — and a
// name such as `localhost` is refused: the address decides who can
// reach the listener, so it is spelled out rather than looked up. A
// `port` of 0 lets the kernel pick a free one; Port() says which.
ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(), std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
~ListenerTCP(); ~ListenerTCP();
// The port the listener is bound to: the one asked for, or the
// kernel's pick when that was 0.
std::uint16_t Port() const;
void Stop(); void Stop();
void ListenSyncSync(); void ListenSyncSync();
void ListenSyncAsync(); void ListenSyncAsync();

View file

@ -0,0 +1,271 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Network:URL;
import std;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
namespace URLDetail {
// The components RFC 3986 appendix B cuts a URI reference into; a
// reference may lack the scheme and the authority, a URL may not.
struct Parts {
std::optional<std::string> scheme;
std::optional<std::string> authority;
std::string path;
std::optional<std::string> query;
std::optional<std::string> fragment;
};
inline bool IsAsciiAlpha(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
inline bool IsSchemeChar(char c) {
return IsAsciiAlpha(c) || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.';
}
inline std::string Lowered(std::string_view text) {
std::string lower(text);
for (char& c : lower) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return lower;
}
// ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?, with the
// scheme taken only when it is one by RFC 3986 §3.1.
inline Parts Split(std::string_view text) {
Parts parts;
const std::size_t mark = text.find_first_of(":/?#");
if (mark != std::string_view::npos && mark > 0 && text[mark] == ':' && IsAsciiAlpha(text[0])
&& std::ranges::all_of(text.substr(0, mark), IsSchemeChar)) {
parts.scheme = Lowered(text.substr(0, mark));
text.remove_prefix(mark + 1);
}
if (text.starts_with("//")) {
text.remove_prefix(2);
std::size_t end = text.find_first_of("/?#");
if (end == std::string_view::npos) end = text.size();
parts.authority = std::string(text.substr(0, end));
text.remove_prefix(end);
}
std::size_t end = text.find_first_of("?#");
if (end == std::string_view::npos) end = text.size();
parts.path = std::string(text.substr(0, end));
text.remove_prefix(end);
if (text.starts_with('?')) {
text.remove_prefix(1);
std::size_t hash = text.find('#');
if (hash == std::string_view::npos) hash = text.size();
parts.query = std::string(text.substr(0, hash));
text.remove_prefix(hash);
}
if (text.starts_with('#')) parts.fragment = std::string(text.substr(1));
return parts;
}
inline void RemoveLastSegment(std::string& path) {
const std::size_t slash = path.rfind('/');
path.erase(slash == std::string::npos ? 0 : slash);
}
// RFC 3986 §5.2.4.
inline std::string RemoveDotSegments(std::string_view path) {
std::string in(path);
std::string out;
while (!in.empty()) {
if (in.starts_with("../")) {
in.erase(0, 3);
} else if (in.starts_with("./")) {
in.erase(0, 2);
} else if (in.starts_with("/./")) {
in.erase(0, 2);
} else if (in == "/.") {
in = "/";
} else if (in.starts_with("/../")) {
in.erase(0, 3);
RemoveLastSegment(out);
} else if (in == "/..") {
in = "/";
RemoveLastSegment(out);
} else if (in == "." || in == "..") {
in.clear();
} else {
std::size_t next = in.find('/', in.starts_with('/') ? 1 : 0);
if (next == std::string::npos) next = in.size();
out += in.substr(0, next);
in.erase(0, next);
}
}
return out;
}
// RFC 3986 §5.2.3.
inline std::string Merge(bool baseHasAuthority, std::string_view basePath, std::string_view referencePath) {
if (baseHasAuthority && basePath.empty()) return "/" + std::string(referencePath);
const std::size_t slash = basePath.rfind('/');
if (slash == std::string_view::npos) return std::string(referencePath);
return std::string(basePath.substr(0, slash + 1)) + std::string(referencePath);
}
}
// An absolute URL, as RFC 3986 reads it:
// `scheme://[userinfo@]host[:port][/path][?query][#fragment]`. What an
// HTTP client needs of URLs: the parse, the resolution of the reference
// a redirect carries against the URL that sent it, the recomposition,
// and the pieces a request is built from.
export struct URL {
// Lowercased.
std::string scheme;
std::string userinfo;
// Lowercased; an IPv6 literal without its brackets.
std::string host;
// None when the URL names no port: the scheme's default.
std::optional<std::uint16_t> port;
// Empty, or starting with '/'.
std::string path;
// Without the '?'; none when the URL has no query (an empty query,
// `?` and nothing after it, is "").
std::optional<std::string> query;
// Without the '#'.
std::optional<std::string> fragment;
// Throws std::invalid_argument for text without a scheme or a host,
// a port that is not a number or is above 65535, and unbalanced
// brackets around an IPv6 host.
static URL Parse(std::string_view text);
// RFC 3986 §5.2 resolution of a reference against this URL as the
// base: an absolute reference stands as it is, a relative one takes
// the parts it lacks from the base, and dot segments are removed.
// Throws std::invalid_argument when the result is no URL (a
// reference such as `mailto:x` has no host).
URL Resolve(std::string_view reference) const;
std::string ToString() const;
bool Secure() const {
return scheme == "https";
}
// The port, or the scheme's default: 443 for https, 80 for http, 0
// for any other scheme.
std::uint16_t PortOrDefault() const {
if (port) return *port;
return scheme == "https" ? 443 : scheme == "http" ? 80 : 0;
}
// host[:port] as the Host header carries it: brackets around an IPv6
// literal, the port only when the URL names one.
std::string Authority() const {
std::string authority = host.contains(':') ? "[" + host + "]" : host;
if (port) authority += ":" + std::to_string(*port);
return authority;
}
// The request target: the path, "/" when there is none, and the
// query.
std::string Target() const {
std::string target = path.empty() ? std::string("/") : path;
if (query) target += "?" + *query;
return target;
}
// The same scheme, host and effective port.
bool SameOrigin(const URL& other) const {
return scheme == other.scheme && host == other.host && PortOrDefault() == other.PortOrDefault();
}
};
namespace URLDetail {
inline URL FromParts(const Parts& parts, std::string_view shown) {
if (!parts.scheme) throw std::invalid_argument("URL: no scheme in " + std::string(shown));
if (!parts.authority) throw std::invalid_argument("URL: no host in " + std::string(shown));
URL url;
url.scheme = *parts.scheme;
std::string hostport = *parts.authority;
if (const std::size_t at = hostport.rfind('@'); at != std::string::npos) {
url.userinfo = hostport.substr(0, at);
hostport.erase(0, at + 1);
}
std::string port;
if (hostport.starts_with('[')) {
const std::size_t close = hostport.find(']');
if (close == std::string::npos) throw std::invalid_argument("URL: unbalanced brackets in the host of " + std::string(shown));
url.host = Lowered(std::string_view(hostport).substr(1, close - 1));
const std::string rest = hostport.substr(close + 1);
if (rest.starts_with(':')) {
port = rest.substr(1);
} else if (!rest.empty()) {
throw std::invalid_argument("URL: unexpected text after the IPv6 host in " + std::string(shown));
}
} else {
if (hostport.contains(']')) throw std::invalid_argument("URL: unbalanced brackets in the host of " + std::string(shown));
const std::size_t colon = hostport.rfind(':');
url.host = Lowered(std::string_view(hostport).substr(0, colon));
if (colon != std::string::npos) port = hostport.substr(colon + 1);
}
if (url.host.empty()) throw std::invalid_argument("URL: no host in " + std::string(shown));
if (!port.empty()) {
std::uint32_t value = 0;
const auto [end, error] = std::from_chars(port.data(), port.data() + port.size(), value);
if (error != std::errc() || end != port.data() + port.size() || value > 65535) {
throw std::invalid_argument("URL: bad port '" + port + "' in " + std::string(shown));
}
url.port = static_cast<std::uint16_t>(value);
}
url.path = parts.path;
url.query = parts.query;
url.fragment = parts.fragment;
return url;
}
}
inline URL URL::Parse(std::string_view text) {
return URLDetail::FromParts(URLDetail::Split(text), text);
}
inline URL URL::Resolve(std::string_view reference) const {
using namespace URLDetail;
const Parts ref = Split(reference);
Parts target;
if (ref.scheme) {
target = ref;
target.path = RemoveDotSegments(ref.path);
} else {
if (ref.authority) {
target.authority = ref.authority;
target.path = RemoveDotSegments(ref.path);
target.query = ref.query;
} else {
if (ref.path.empty()) {
target.path = path;
target.query = ref.query ? ref.query : query;
} else {
target.path = RemoveDotSegments(ref.path.starts_with('/') ? ref.path : Merge(true, path, ref.path));
target.query = ref.query;
}
// The base's authority, as it was written.
std::string authority;
if (!userinfo.empty()) authority += userinfo + "@";
authority += Authority();
target.authority = authority;
}
target.scheme = scheme;
target.fragment = ref.fragment;
}
return FromParts(target, reference);
}
inline std::string URL::ToString() const {
std::string text = scheme + "://";
if (!userinfo.empty()) text += userinfo + "@";
text += Authority();
text += path;
if (query) text += "?" + *query;
if (fragment) text += "#" + *fragment;
return text;
}
}
#endif

View file

@ -33,6 +33,9 @@ export import :HTTP1;
// same way. // same way.
export import :Stream; export import :Stream;
export import :TLS; export import :TLS;
// The URL type ClientHTTP1::Fetch parses and resolves, for callers that
// build or resolve URLs themselves.
export import :URL;
export import :ClientHTTP1; export import :ClientHTTP1;
export import :ListenerHTTP1; export import :ListenerHTTP1;
#endif #endif

View file

@ -8,7 +8,7 @@ using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// TCP, HTTP/1.1 and TLS: what every native build has. // TCP, HTTP/1.1 and TLS: what every native build has.
constexpr std::array<std::string_view, 9> networkInterfaces = { constexpr std::array<std::string_view, 10> networkInterfaces = {
"interfaces/Crafter.Network", "interfaces/Crafter.Network",
"interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ClientTCP",
"interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ListenerTCP",
@ -16,6 +16,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Network-HTTP1", "interfaces/Crafter.Network-HTTP1",
"interfaces/Crafter.Network-Stream", "interfaces/Crafter.Network-Stream",
"interfaces/Crafter.Network-TLS", "interfaces/Crafter.Network-TLS",
"interfaces/Crafter.Network-URL",
"interfaces/Crafter.Network-ClientHTTP1", "interfaces/Crafter.Network-ClientHTTP1",
"interfaces/Crafter.Network-ListenerHTTP1", "interfaces/Crafter.Network-ListenerHTTP1",
}; };
@ -174,6 +175,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
if (cfg.target.find("linux") != std::string::npos) { if (cfg.target.find("linux") != std::string::npos) {
cfg.AddTest("ShouldConfigureTCP").Dependencies({ &cfg }); cfg.AddTest("ShouldConfigureTCP").Dependencies({ &cfg });
cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg }); cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg });
cfg.AddTest("ShouldConnectDualStack").Dependencies({ &cfg });
cfg.AddTest("ShouldReportConnectErrors").Dependencies({ &cfg });
cfg.AddTest("ShouldReportIoErrors").Dependencies({ &cfg });
cfg.AddTest("ShouldBindLoopbackEphemeral").Dependencies({ &cfg });
cfg.AddTest("ShouldTimeoutSilentHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldFollowRedirectsHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldParseURLs").Dependencies({ &cfg });
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });

View file

@ -0,0 +1,143 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ListenerTCP can be told the address it binds and take port 0, with Port()
// reporting what the kernel picked. Before, it always bound 0.0.0.0 at a port
// the caller chose: a helper meant for this machine's own processes — a broker
// that hands out database access, say — was reachable from the network, and
// the caller had to guess a free port and hope.
//
// Also: a host name is refused rather than bound to whichever address the
// resolver ranks first, and the port-only constructor keeps listening on every
// IPv4 interface.
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// An IPv4 address of this host that is not loopback, if it has one.
std::optional<std::string> NonLoopbackIPv4() {
ifaddrs* interfaces = nullptr;
if (getifaddrs(&interfaces) != 0) return std::nullopt;
std::optional<std::string> found;
for (const ifaddrs* entry = interfaces; entry != nullptr && !found; entry = entry->ifa_next) {
if (entry->ifa_addr == nullptr || entry->ifa_addr->sa_family != AF_INET) continue;
const auto* v4 = reinterpret_cast<const sockaddr_in*>(entry->ifa_addr);
if (ntohl(v4->sin_addr.s_addr) >> 24 == 127) continue;
char text[INET_ADDRSTRLEN] = {};
inet_ntop(AF_INET, &v4->sin_addr, text, sizeof(text));
found = std::string(text);
}
freeifaddrs(interfaces);
return found;
}
// Runs a listener's accept loop on a thread of its own and counts the
// connections it was handed.
struct Serving {
ListenerTCP& listener;
std::atomic<int> accepted = 0;
std::thread loop;
explicit Serving(ListenerTCP& listener) : listener(listener) {
listener.connectCallback = [this](ClientTCP* client) {
++accepted;
delete client;
};
loop = std::thread([this] { this->listener.ListenSyncSync(); });
}
~Serving() {
listener.Stop();
loop.join();
}
bool WaitFor(int count) {
for (int i = 0; i < 200 && accepted.load() < count; ++i) std::this_thread::sleep_for(10ms);
return accepted.load() >= count;
}
};
} // namespace
int main() {
{
ListenerTCP listener("127.0.0.1", 0, nullptr);
const std::uint16_t port = listener.Port();
Check(port != 0, std::format("port 0 gets a port from the kernel, which Port() reports ({})", port));
Serving serving(listener);
{
ClientTCP client("127.0.0.1", port, 2s);
}
Check(serving.WaitFor(1), "a client on loopback reaches it");
if (const std::optional<std::string> outside = NonLoopbackIPv4()) {
bool refused = false;
try {
ClientTCP client(*outside, port, 2s);
} catch (const SocketErrorException& e) {
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
} catch (const std::exception&) {
}
Check(refused, std::format("through {} it is refused: it listens on 127.0.0.1 alone", *outside));
} else {
std::println("SKIP no non-loopback IPv4 address to try from");
}
}
{
std::optional<ListenerTCP> v6;
try {
v6.emplace("::1", 0, nullptr);
} catch (const std::exception&) {
}
if (v6) {
Serving serving(*v6);
{
ClientTCP client("::1", v6->Port(), 2s);
}
Check(serving.WaitFor(1), "an IPv6 loopback listener takes a client on ::1");
} else {
std::println("SKIP no IPv6 loopback on this host");
}
}
{
bool refused = false;
try {
ListenerTCP named("localhost", 0, nullptr);
} catch (const std::runtime_error&) {
refused = true;
}
Check(refused, "a host name is refused: the address to bind is spelled out");
}
{
ListenerTCP any(0, nullptr);
Check(any.Port() != 0, "the port-only constructor takes port 0 as well");
Serving serving(any);
{
ClientTCP client("127.0.0.1", any.Port(), 2s);
}
Check(serving.WaitFor(1), "and still listens on every IPv4 interface, loopback included");
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,151 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ClientTCP resolves a host for IPv4 and IPv6 alike and tries every address
// the name resolves to, in order, the way Python's socket.create_connection
// does. Before, it asked getaddrinfo for AF_INET only and kept the first
// answer, so a server listening on ::1 alone — a dev server on a recent Node
// binds `localhost` that way — could not be reached by name, while every
// Python client reached it.
//
// The listeners are raw sockets, so nothing here depends on ListenerTCP: one
// IPv6-only on ::1, one on 127.0.0.1. Skips (77) on a host without IPv6
// loopback.
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A socket listening on the loopback address of `family`, on a port the
// kernel picks; fd stays -1 when the host cannot open one.
struct LoopbackListener {
int fd = -1;
std::uint16_t port = 0;
explicit LoopbackListener(int family) {
fd = socket(family, SOCK_STREAM, 0);
if (fd == -1) return;
sockaddr_storage address {};
socklen_t length = 0;
if (family == AF_INET6) {
// v6 only, or the socket would also take IPv4 connects and the
// checks below would not tell the two apart.
int only = 1;
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &only, sizeof(only));
auto* v6 = reinterpret_cast<sockaddr_in6*>(&address);
v6->sin6_family = AF_INET6;
v6->sin6_addr = in6addr_loopback;
length = sizeof(sockaddr_in6);
} else {
auto* v4 = reinterpret_cast<sockaddr_in*>(&address);
v4->sin_family = AF_INET;
v4->sin_addr.s_addr = htonl(INADDR_LOOPBACK);
length = sizeof(sockaddr_in);
}
if (bind(fd, reinterpret_cast<sockaddr*>(&address), length) != 0 || listen(fd, 8) != 0) {
close(fd);
fd = -1;
return;
}
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
port = family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&address)->sin6_port)
: ntohs(reinterpret_cast<sockaddr_in*>(&address)->sin_port);
}
~LoopbackListener() {
if (fd != -1) close(fd);
}
};
// The address family of the peer a connected client reached.
int PeerFamily(const ClientTCP& client) {
sockaddr_storage address {};
socklen_t length = sizeof(address);
getpeername(client.socketid, reinterpret_cast<sockaddr*>(&address), &length);
return address.ss_family;
}
// Whether `localhost` resolves to ::1 here; /etc/hosts decides.
bool LocalhostResolvesToIPv6() {
addrinfo hints {};
hints.ai_family = AF_INET6;
hints.ai_socktype = SOCK_STREAM;
addrinfo* resolved = nullptr;
if (getaddrinfo("localhost", nullptr, &hints, &resolved) != 0) return false;
freeaddrinfo(resolved);
return true;
}
} // namespace
int main() {
LoopbackListener v6(AF_INET6);
if (v6.fd == -1) {
std::println("SKIP no IPv6 loopback on this host");
return 77;
}
{
ClientTCP client("::1", v6.port, 2s);
Check(PeerFamily(client) == AF_INET6, "an IPv6 literal connects over IPv6");
}
if (LocalhostResolvesToIPv6()) {
try {
ClientTCP client("localhost", v6.port, 2s);
Check(PeerFamily(client) == AF_INET6, "localhost reaches a server that listens on ::1 only");
} catch (const std::exception& e) {
Check(false, std::format("localhost reaches a server that listens on ::1 only (threw: {})", e.what()));
}
} else {
std::println("SKIP localhost does not resolve to ::1 on this host");
}
{
// Whichever address the resolver ranks first, a refused one is not
// the end: the next is tried.
LoopbackListener v4(AF_INET);
try {
ClientTCP client("localhost", v4.port, 2s);
Check(PeerFamily(client) == AF_INET, "localhost reaches a server that listens on 127.0.0.1 only");
} catch (const std::exception& e) {
Check(false, std::format("localhost reaches a server that listens on 127.0.0.1 only (threw: {})", e.what()));
}
}
{
std::uint16_t closedPort = 0;
{
LoopbackListener gone(AF_INET);
closedPort = gone.port;
}
bool refused = false;
try {
ClientTCP client("localhost", closedPort, 2s);
} catch (const SocketErrorException& e) {
refused = e.phase == SocketPhase::Connect && e.code() == std::errc::connection_refused;
} catch (const std::exception&) {
}
Check(refused, "when no address answers, the last failure is thrown, with its errno");
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,183 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ClientHTTP1::Fetch follows redirects by RFC 9110 §15.4:
// - a relative Location is resolved against the URL that sent it (RFC
// 3986, `..` included), and a redirect may move from http:// to https://;
// - 301, 302, 303, 307 and 308 are followed for every method: 303 turns
// the request into a GET without its body, 301 and 302 do so for a POST
// alone, 307 and 308 keep the method and the body;
// - a redirect without a Location, or to another scheme, comes back as the
// response, with `stop` saying why;
// - a chain stops at a URL it has already been sent to, or after
// maxRedirects, returning that redirect;
// - Authorization and Cookie are not carried to another origin;
// - a URL that is not http(s), has no host or has a bad port is refused.
//
// Two listeners: plaintext on 8130, TLS on 8131 with a self-signed certificate
// the client is handed as its trust anchor. The cross-origin case goes from
// 127.0.0.1 to localhost, the same listener under another name.
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
HTTPResponse Redirect(std::string status, std::string location) {
return CreateResponseHTTP(std::move(status), {{"location", std::move(location)}}, std::string());
}
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/start"] = [](const HTTPRequest&) { return Redirect("302", "next"); };
routes["/next"] = [](const HTTPRequest&) { return Redirect("301", "/final?x=1"); };
routes["/a/b/start"] = [](const HTTPRequest&) { return Redirect("302", "../c/d"); };
routes["/to-https"] = [](const HTTPRequest&) { return Redirect("307", "https://localhost:8131/secure"); };
routes["/loop"] = [](const HTTPRequest&) { return Redirect("302", "/loop"); };
routes["/nowhere"] = [](const HTTPRequest&) { return CreateResponseHTTP("302", std::string("no location")); };
routes["/elsewhere"] = [](const HTTPRequest&) { return Redirect("302", "ftp://127.0.0.1/file"); };
routes["/form"] = [](const HTTPRequest&) { return Redirect("303", "/method"); };
routes["/found"] = [](const HTTPRequest&) { return Redirect("302", "/method"); };
routes["/keep"] = [](const HTTPRequest&) { return Redirect("307", "/method"); };
routes["/method"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.method + " body=" + request.body + " type=" + (request.headers.contains("content-type") ? request.headers.at("content-type") : "none"));
};
routes["/auth-away"] = [](const HTTPRequest&) { return Redirect("302", "http://localhost:8130/show-auth"); };
routes["/auth-home"] = [](const HTTPRequest&) { return Redirect("302", "/show-auth"); };
routes["/show-auth"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", std::string("authorization=") + (request.headers.contains("authorization") ? "yes" : "no")
+ " cookie=" + (request.headers.contains("cookie") ? "yes" : "no")
+ " accept=" + (request.headers.contains("accept") ? "yes" : "no"));
};
return routes;
}
// Everything else echoes its target, and /hop/<n> redirects to /hop/<n+1>.
HTTPResponse Fallback(const HTTPRequest& request) {
if (request.path.starts_with("/hop/")) {
const std::int32_t next = std::stoi(request.path.substr(5)) + 1;
return Redirect("302", std::format("/hop/{}", next));
}
return CreateResponseHTTP("200", request.path);
}
bool Refused(std::string_view url) {
try {
ClientHTTP1::Fetch(url);
} catch (const std::invalid_argument&) {
return true;
} catch (const std::exception&) {
}
return false;
}
} // namespace
int main() {
try {
ListenerAsyncHTTP1 plain(8130, Routes(), Fallback);
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> secureRoutes;
secureRoutes["/secure"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", "secure over " + request.scheme);
};
ListenerAsyncHTTP1 secure(8131, secureRoutes, TLSServerCredentials { .selfSigned = true });
{
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/start");
Check(result.response.status == "200" && result.response.body == "/final?x=1",
std::format("a relative chain arrives at /final?x=1 (got {} '{}')", result.response.status, result.response.body));
Check(result.url.ToString() == "http://127.0.0.1:8130/final?x=1", std::format("the URL that answered is reported ({})", result.url.ToString()));
Check(result.redirects == 2 && result.stop == FetchStop::None, std::format("two redirects, the chain ended on a final response (got {})", result.redirects));
}
{
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/a/b/start");
Check(result.response.body == "/a/c/d", std::format("../c/d from /a/b/start resolves to /a/c/d (got '{}')", result.response.body));
}
{
FetchOptions options;
options.credentials.caPem = GetSelfSignedCertificatePem().certificate;
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/to-https", options);
Check(result.response.status == "200" && result.response.body == "secure over https",
std::format("a redirect from http:// to https:// is followed (got {} '{}')", result.response.status, result.response.body));
Check(result.url.ToString() == "https://localhost:8131/secure" && result.url.Secure(), std::format("and lands on the https:// URL ({})", result.url.ToString()));
}
{
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/loop");
Check(result.stop == FetchStop::Loop && result.response.status == "302" && result.redirects == 0,
std::format("a redirect back to a URL already visited stops the chain at once (stop {}, {} redirects)", static_cast<int>(result.stop), result.redirects));
}
{
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/hop/0");
Check(result.stop == FetchStop::Limit && result.redirects == 10 && result.url.ToString() == "http://127.0.0.1:8130/hop/10",
std::format("an endless chain stops after maxRedirects, 10 ({} redirects, at {})", result.redirects, result.url.ToString()));
FetchOptions two;
two.maxRedirects = 2;
const FetchResult shorter = ClientHTTP1::Fetch("http://127.0.0.1:8130/hop/0", two);
Check(shorter.stop == FetchStop::Limit && shorter.redirects == 2, "maxRedirects is the option's");
}
{
const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/nowhere");
Check(result.stop == FetchStop::NoLocation && result.response.status == "302" && result.redirects == 0,
"a redirect without a Location comes back as the response");
const FetchResult other = ClientHTTP1::Fetch("http://127.0.0.1:8130/elsewhere");
Check(other.stop == FetchStop::OtherScheme && other.response.status == "302", "a redirect to another scheme comes back as the response");
}
{
FetchOptions post;
post.method = "POST";
post.body = "payload";
post.headers["content-type"] = "text/plain";
const FetchResult seeOther = ClientHTTP1::Fetch("http://127.0.0.1:8130/form", post);
Check(seeOther.response.body == "GET body= type=none",
std::format("a POST answered with 303 is followed as a GET without its body or content headers (got '{}')", seeOther.response.body));
const FetchResult found = ClientHTTP1::Fetch("http://127.0.0.1:8130/found", post);
Check(found.response.body == "GET body= type=none", std::format("a POST answered with 302 is followed as a GET (got '{}')", found.response.body));
const FetchResult kept = ClientHTTP1::Fetch("http://127.0.0.1:8130/keep", post);
Check(kept.response.body == "POST body=payload type=text/plain" && kept.redirects == 1,
std::format("a POST answered with 307 is followed with its method, body and content headers (got '{}')", kept.response.body));
FetchOptions put = post;
put.method = "PUT";
const FetchResult putFound = ClientHTTP1::Fetch("http://127.0.0.1:8130/found", put);
Check(putFound.response.body == "PUT body=payload type=text/plain", std::format("a PUT answered with 302 keeps its method and body (got '{}')", putFound.response.body));
}
{
FetchOptions options;
options.headers["authorization"] = "Bearer secret";
options.headers["cookie"] = "a=b";
options.headers["accept"] = "text/plain";
const FetchResult away = ClientHTTP1::Fetch("http://127.0.0.1:8130/auth-away", options);
Check(away.response.body == "authorization=no cookie=no accept=yes",
std::format("Authorization and Cookie are dropped on a redirect to another origin, other headers kept (got '{}')", away.response.body));
const FetchResult home = ClientHTTP1::Fetch("http://127.0.0.1:8130/auth-home", options);
Check(home.response.body == "authorization=yes cookie=yes accept=yes",
std::format("and kept on a redirect within the origin (got '{}')", home.response.body));
}
Check(Refused("ftp://127.0.0.1/file"), "an ftp:// URL is refused");
Check(Refused("http:///path"), "a URL without a host is refused");
Check(Refused("http://127.0.0.1:99999/"), "a port above 65535 is refused");
Check(Refused("http://127.0.0.1:8x/"), "a port that is not a number is refused");
} catch (const std::exception& e) {
Check(false, std::format("unexpected exception: {}", e.what()));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,105 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// URL reads an absolute URL as RFC 3986 does and resolves a reference
// against it as §5.2 says, which is what ClientHTTP1::Fetch leans on to
// follow a Location. The resolution cases are RFC 3986 §5.4's own examples,
// normal and abnormal, against its base `http://a/b/c/d;p?q`; the parse
// cases guard the pieces a request is built from (the default port, the
// IPv6 brackets, the userinfo that must not reach the host) and what is
// refused.
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
bool Refused(std::string_view text) {
try {
URL::Parse(text);
} catch (const std::invalid_argument&) {
return true;
}
return false;
}
} // namespace
int main() {
{
const URL url = URL::Parse("HTTP://User:pw@Example.COM:8080/a/b?x=1&y#frag");
Check(url.scheme == "http" && url.host == "example.com", "the scheme and the host are lowercased");
Check(url.userinfo == "User:pw", "the userinfo is kept apart from the host");
Check(url.port && *url.port == 8080 && url.PortOrDefault() == 8080, "an explicit port");
Check(url.path == "/a/b" && url.query && *url.query == "x=1&y" && url.fragment && *url.fragment == "frag", "path, query and fragment");
Check(url.Authority() == "example.com:8080" && url.Target() == "/a/b?x=1&y", "the Host value and the request target");
Check(url.ToString() == "http://User:pw@example.com:8080/a/b?x=1&y#frag", std::format("recomposed ({})", url.ToString()));
}
{
const URL plain = URL::Parse("http://example.com");
const URL secure = URL::Parse("https://example.com/");
Check(!plain.port && plain.PortOrDefault() == 80 && secure.PortOrDefault() == 443, "the scheme's default port when none is named");
Check(plain.path.empty() && plain.Target() == "/" && plain.Authority() == "example.com", "no path is the target `/`, and the Host value carries no port");
Check(!plain.query && URL::Parse("http://example.com/?").query && URL::Parse("http://example.com/?").query->empty(),
"no query is none; `?` alone is an empty one");
Check(!plain.SameOrigin(secure) && plain.SameOrigin(URL::Parse("http://EXAMPLE.com:80/other")) && !plain.SameOrigin(URL::Parse("http://example.com:8080/")),
"the origin is the scheme, the host and the effective port");
}
{
const URL v6 = URL::Parse("http://[::1]:8130/x");
Check(v6.host == "::1" && v6.port && *v6.port == 8130 && v6.Authority() == "[::1]:8130" && v6.ToString() == "http://[::1]:8130/x",
std::format("an IPv6 host loses its brackets in `host` and gets them back in the Host value ({})", v6.ToString()));
}
Check(Refused("example.com/path"), "text without a scheme is refused");
Check(Refused("http:///path"), "a URL without a host is refused");
Check(Refused("http://example.com:99999/"), "a port above 65535 is refused");
Check(Refused("http://example.com:8x/"), "a port that is not a number is refused");
Check(Refused("http://[::1/"), "unbalanced brackets are refused");
Check(URL::Parse("http://example.com:/").PortOrDefault() == 80, "an empty port is the default");
// RFC 3986 §5.4.
const URL base = URL::Parse("http://a/b/c/d;p?q");
const std::vector<std::pair<std::string_view, std::string_view>> cases = {
{"g", "http://a/b/c/g"}, {"./g", "http://a/b/c/g"}, {"g/", "http://a/b/c/g/"}, {"/g", "http://a/g"}, {"//g", "http://g"},
{"?y", "http://a/b/c/d;p?y"}, {"g?y", "http://a/b/c/g?y"}, {"#s", "http://a/b/c/d;p?q#s"}, {"g#s", "http://a/b/c/g#s"},
{"g?y#s", "http://a/b/c/g?y#s"}, {";x", "http://a/b/c/;x"}, {"g;x", "http://a/b/c/g;x"}, {"g;x?y#s", "http://a/b/c/g;x?y#s"},
{"", "http://a/b/c/d;p?q"}, {".", "http://a/b/c/"}, {"./", "http://a/b/c/"}, {"..", "http://a/b/"}, {"../", "http://a/b/"},
{"../g", "http://a/b/g"}, {"../..", "http://a/"}, {"../../", "http://a/"}, {"../../g", "http://a/g"},
// §5.4.2, the abnormal ones.
{"../../../g", "http://a/g"}, {"../../../../g", "http://a/g"}, {"/./g", "http://a/g"}, {"/../g", "http://a/g"},
{"g.", "http://a/b/c/g."}, {".g", "http://a/b/c/.g"}, {"g..", "http://a/b/c/g.."}, {"..g", "http://a/b/c/..g"},
{"./../g", "http://a/b/g"}, {"./g/.", "http://a/b/c/g/"}, {"g/./h", "http://a/b/c/g/h"}, {"g/../h", "http://a/b/c/h"},
{"g;x=1/./y", "http://a/b/c/g;x=1/y"}, {"g;x=1/../y", "http://a/b/c/y"}, {"g?y/./x", "http://a/b/c/g?y/./x"},
{"g?y/../x", "http://a/b/c/g?y/../x"}, {"g#s/./x", "http://a/b/c/g#s/./x"}, {"g#s/../x", "http://a/b/c/g#s/../x"},
{"https://b/x", "https://b/x"},
};
for (const auto& [reference, expected] : cases) {
const std::string resolved = base.Resolve(reference).ToString();
Check(resolved == expected, std::format("'{}' against the base resolves to {} (got {})", reference, expected, resolved));
}
{
const URL from = URL::Parse("http://127.0.0.1:8130/a/b/start");
Check(from.Resolve("../c/d").ToString() == "http://127.0.0.1:8130/a/c/d", "a port and a userinfo-free base keep their authority through a resolve");
Check(URL::Parse("http://u@h:81/p").Resolve("x").ToString() == "http://u@h:81/x", "the base's userinfo stays");
bool refused = false;
try {
from.Resolve("mailto:someone@example.com");
} catch (const std::invalid_argument&) {
refused = true;
}
Check(refused, "a reference that resolves to no URL (mailto:) is refused");
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,102 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A failed resolve or connect throws SocketErrorException, a std::system_error
// whose code() carries what it failed with: the errno for a connect, under
// std::generic_category(); the getaddrinfo result for a name, under
// ResolverCategory(), whose text is gai_strerror's. `phase` says which step
// failed. Before, both were a bare std::runtime_error, so a caller that
// reports the cause in its own words — "[Errno 111] Connection refused" in
// front of a language model reading the output — had to parse the message
// back apart. The message itself stays what it always was, and the exception
// is still a std::runtime_error, so existing handlers keep working.
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
#include <cstring>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A port on 127.0.0.1 nothing listens on: bound by the kernel, then released.
std::uint16_t ClosedPort() {
const int fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
socklen_t length = sizeof(address);
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
close(fd);
return ntohs(address.sin_port);
}
} // namespace
int main() {
{
bool thrown = false;
try {
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
} catch (const SocketErrorException& e) {
thrown = true;
Check(e.phase == SocketPhase::Connect, "a refused connect failed in the connect phase");
Check(e.code().category() == std::generic_category(), "its code is an errno");
Check(e.code().value() == ECONNREFUSED, std::format("it carries ECONNREFUSED (got {})", e.code().value()));
Check(e.code() == std::errc::connection_refused, "and compares with std::errc::connection_refused");
Check(e.code().message() == std::strerror(ECONNREFUSED), std::format("its code's text is strerror's (got '{}')", e.code().message()));
Check(std::string_view(e.what()) == std::string("Could not connect to server: ") + std::strerror(ECONNREFUSED),
std::format("its message is the one ClientTCP always threw (got '{}')", e.what()));
}
Check(thrown, "a refused connect throws SocketErrorException");
}
{
bool caught = false;
bool system = false;
try {
ClientTCP client("127.0.0.1", ClosedPort(), 2s);
} catch (const std::system_error& e) {
system = e.code() == std::errc::connection_refused;
caught = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
}
Check(caught && system, "it is caught as std::system_error, and is still a std::runtime_error");
}
{
// .invalid never resolves (RFC 6761); without a network the resolver
// fails differently (EAI_AGAIN), but always as a resolver error.
bool thrown = false;
try {
ClientTCP client("no-such-host.invalid", 80, 2s);
} catch (const SocketErrorException& e) {
thrown = true;
Check(e.phase == SocketPhase::Resolve, "a name that does not resolve failed in the resolve phase");
Check(e.code().category() == ResolverCategory(), "its code is in the resolver category");
Check(e.code().value() != 0, std::format("it carries the getaddrinfo code (got {})", e.code().value()));
Check(e.code().message() == gai_strerror(e.code().value()), std::format("the category's text is gai_strerror's (got '{}')", e.code().message()));
Check(std::string_view(e.what()) == std::string("Could not resolve host 'no-such-host.invalid': ") + gai_strerror(e.code().value()),
std::format("its message names the host, the code's text after it (got '{}')", e.what()));
}
Check(thrown, "an unresolvable host throws SocketErrorException");
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,113 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A send or a receive that fails throws SocketErrorException with its errno
// in code() and the phase that failed, as a failed connect does: a caller
// that reports the cause in its own words — "the connection broke ([Errno
// 104] Connection reset by peer)" — needs the code, and the message alone is
// the strerror text a send and a receive always threw. A receive timeout is
// still SocketTimeoutException and a clean close SocketClosedException.
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A listener on 127.0.0.1 that accepts one connection and resets it: SO_LINGER
// with a zero timeout makes close() send an RST instead of a FIN.
struct Resetter {
int listener = -1;
std::uint16_t port = 0;
std::thread thread;
Resetter() {
listener = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address));
listen(listener, 1);
socklen_t length = sizeof(address);
getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length);
port = ntohs(address.sin_port);
thread = std::thread([this] {
const int client = accept(listener, nullptr, nullptr);
linger reset { .l_onoff = 1, .l_linger = 0 };
setsockopt(client, SOL_SOCKET, SO_LINGER, &reset, sizeof(reset));
close(client);
});
}
~Resetter() {
if (thread.joinable()) thread.join();
close(listener);
}
};
} // namespace
int main() {
{
Resetter peer;
ClientTCP client("127.0.0.1", peer.port, 2s);
client.SetReceiveTimeout(2s);
peer.thread.join();
bool thrown = false;
try {
client.RecieveSync(16);
} catch (const SocketErrorException& e) {
thrown = true;
Check(e.phase == SocketPhase::Receive, "a reset connection fails in the receive phase");
Check(e.code() == std::errc::connection_reset && e.code().value() == ECONNRESET, std::format("it carries ECONNRESET (got {})", e.code().value()));
Check(std::string_view(e.what()) == std::strerror(ECONNRESET), std::format("its message is the strerror text (got '{}')", e.what()));
} catch (const std::exception& e) {
Check(false, std::format("the receive threw something else: {}", e.what()));
}
Check(thrown, "a receive on a reset connection throws SocketErrorException");
}
{
Resetter peer;
ClientTCP client("127.0.0.1", peer.port, 2s);
peer.thread.join();
// The first send after the reset may still be accepted locally; the
// kernel answers the next one with EPIPE or ECONNRESET.
std::int32_t code = 0;
bool runtime = false;
bool sending = false;
const std::string data(4096, 'x');
for (int i = 0; i < 50 && code == 0; ++i) {
try {
client.Send(data.data(), static_cast<std::uint32_t>(data.size()));
} catch (const SocketErrorException& e) {
code = e.code().value();
sending = e.phase == SocketPhase::Send && e.code().category() == std::generic_category();
runtime = dynamic_cast<const std::runtime_error*>(&e) != nullptr;
} catch (const std::exception&) {
break;
}
std::this_thread::sleep_for(10ms);
}
Check(code == EPIPE || code == ECONNRESET, std::format("a send into a reset connection throws SocketErrorException with EPIPE or ECONNRESET (got {})", code));
Check(sending && runtime, "in the send phase, an errno, and still a std::runtime_error");
}
std::println("{}", failures == 0 ? "all passed" : std::format("{} failed", failures));
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,109 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ClientHTTP1 bounds the TCP connect (connectTimeout) as well as every wait
// for a response (timeout). Before, the connect had no bound of its own: a
// host that drops the SYNs held Send() for the kernel's retries, about two
// minutes, whatever `timeout` said — a readiness probe with a ten-second
// budget could hang its caller for two minutes.
//
// Two servers on loopback. One accepts — the kernel completes the handshake
// from its backlog — and never answers: the read timeout. The other has a
// full accept queue, so Linux drops the SYNs of further connects: the connect
// timeout.
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A socket listening on 127.0.0.1, on a port the kernel picks.
struct LoopbackListener {
int fd = -1;
std::uint16_t port = 0;
explicit LoopbackListener(int backlog) {
fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
listen(fd, backlog);
socklen_t length = sizeof(address);
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
port = ntohs(address.sin_port);
}
~LoopbackListener() { close(fd); }
};
long long Milliseconds(std::chrono::steady_clock::duration duration) {
return std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
}
} // namespace
int main() {
{
LoopbackListener silent(8);
ClientHTTP1 client("127.0.0.1", silent.port);
client.timeout = 300ms;
const auto start = std::chrono::steady_clock::now();
bool failed = false;
try {
client.Send(CreateRequestHTTP("GET", "/", "127.0.0.1"));
} catch (const std::exception&) {
failed = true;
}
const auto waited = std::chrono::steady_clock::now() - start;
Check(failed, "a server that accepts and never answers fails the request");
Check(waited >= 280ms && waited < 5s, std::format("after about `timeout` ({} ms)", Milliseconds(waited)));
}
{
// Connects queue up until the accept queue is full, which with a
// backlog of 0 is after one or two; the next one goes unanswered.
LoopbackListener full(0);
std::vector<ClientTCP> queued;
bool queueFull = false;
for (int i = 0; i < 8 && !queueFull; ++i) {
try {
queued.emplace_back("127.0.0.1", full.port, 200ms);
} catch (const SocketTimeoutException&) {
queueFull = true;
}
}
Check(queueFull, std::format("the accept queue fills up (after {} connects)", queued.size()));
ClientHTTP1 client("127.0.0.1", full.port);
client.connectTimeout = 300ms;
const auto start = std::chrono::steady_clock::now();
bool timedOut = false;
try {
client.Send(CreateRequestHTTP("GET", "/", "127.0.0.1"));
} catch (const SocketTimeoutException&) {
timedOut = true;
} catch (const std::exception&) {
}
const auto waited = std::chrono::steady_clock::now() - start;
Check(timedOut, "a connect nobody answers throws SocketTimeoutException");
Check(waited >= 280ms && waited < 5s, std::format("after about `connectTimeout` ({} ms)", Milliseconds(waited)));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}