Compare commits
19 changed files with 118 additions and 1673 deletions
|
|
@ -13,7 +13,7 @@ The Crafter engine's networking library: a C++ module, `import Crafter.Network;`
|
|||
|---|---|---|---|
|
||||
| `HTTPRequest`, `HTTPResponse`, `CreateRequestHTTP`, `CreateResponseHTTP`, `PathWithoutQueryHTTP` | yes | yes | yes |
|
||||
| `ClientTCP`, `ListenerTCP` | yes | yes | no |
|
||||
| `ByteStream`, `PlainStream`, `TLSContext`, `TLSStream`, `HTTP1::*`, `URL`, `ClientHTTP1`, `ListenerHTTP1` | yes | yes | no |
|
||||
| `ByteStream`, `PlainStream`, `TLSContext`, `TLSStream`, `HTTP1::*`, `ClientHTTP1`, `ListenerHTTP1` | yes | yes | no |
|
||||
| `ClientQUIC`, `QUICStream` | yes | no | `*Async` only, over `WebTransport` |
|
||||
| `ClientHTTP` | yes | no | `SendAsync` only, over `fetch()` |
|
||||
| `ListenerQUIC`, `ListenerHTTP`, `WebTransportSession`, `HTTP3::*` | yes | no | no |
|
||||
|
|
@ -41,9 +41,7 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
|
||||
### 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), `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).
|
||||
**`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.
|
||||
|
||||
**`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()`.
|
||||
|
||||
|
|
@ -65,12 +63,12 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
|
||||
### TCP
|
||||
|
||||
**`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).
|
||||
**`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).
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
- 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; **`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.
|
||||
**`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.
|
||||
|
||||
### QUIC
|
||||
|
||||
|
|
@ -109,15 +107,14 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
|||
|
||||
| Call | On failure |
|
||||
|---|---|
|
||||
| `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` constructor, `Connect`, `Send`, `Recieve*Sync` | `std::runtime_error` (resolve, refused, socket errors); `SocketTimeoutException` (connect or receive timeout); `SocketClosedException` (the peer closed) |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `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::Send` | `HTTP1::HTTP1ProtocolError`, `TLSException`, `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`, the socket's) |
|
||||
| `ClientHTTP1::SendAsync`, `ClientHTTP::SendAsync` | `what()` as a string to `onError` |
|
||||
| `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") |
|
||||
|
|
@ -126,7 +123,7 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
|||
| `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` |
|
||||
| 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`; `SocketErrorException` (through `std::system_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`; `QUICException`, `TLSException`, `HTTP1ProtocolError` and `HTTP3ProtocolError` do, so a `catch (const std::runtime_error&)` misses the first three.
|
||||
|
||||
### Spellings
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ Guidance for coding agents and contributors working on Crafter.Network itself. T
|
|||
|
||||
## Project overview
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| 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-<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`, `HTTP3` and `URL` have none: they are inline. |
|
||||
| `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-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`. |
|
||||
| `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,13 +21,12 @@ Crafter.Network is the Crafter engine's networking library: one C++ module, `Cra
|
|||
|
||||
Partitions and what they own:
|
||||
|
||||
- `: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.
|
||||
- `: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.
|
||||
- `: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.
|
||||
- `: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.
|
||||
- `: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`).
|
||||
- `: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.
|
||||
- `: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`.
|
||||
- `: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.
|
||||
|
|
@ -51,9 +50,9 @@ crafter-build # native: TCP, TLS, HTTP/1.1, QUI
|
|||
crafter-build --no-quic # native without QUIC, HTTP/3, WebTransport and msquic
|
||||
crafter-build --local # Crafter.Thread from ../Crafter.Thread
|
||||
crafter-build --target=wasm32-wasip1 # the browser library
|
||||
crafter-build test # the suite, native: 30 tests
|
||||
crafter-build test # the suite, native: 23 tests
|
||||
crafter-build test 'Should*HTTP1' # a name glob
|
||||
crafter-build test --no-quic # the TCP, URL, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 19
|
||||
crafter-build test --no-quic # the TCP, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 12
|
||||
crafter-build test --list
|
||||
crafter-build test --jobs=1 --timeout=120
|
||||
crafter-build clean
|
||||
|
|
@ -131,7 +130,8 @@ 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.
|
||||
- **`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(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`).
|
||||
- **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`).
|
||||
- **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 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`.
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -53,19 +53,6 @@ client.Send("Hello World", 11);
|
|||
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
|
||||
```cpp
|
||||
// Create a TCP listener
|
||||
|
|
@ -76,15 +63,6 @@ Crafter::ListenerTCP listener(8080, callback);
|
|||
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 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).
|
||||
|
|
@ -130,26 +108,7 @@ 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; 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)`).
|
||||
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).
|
||||
|
||||
#### ListenerHTTP1
|
||||
```cpp
|
||||
|
|
@ -328,12 +287,6 @@ 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 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
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import :HTTP;
|
|||
import :HTTP1;
|
||||
import :Stream;
|
||||
import :TLS;
|
||||
import :URL;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
|
|
@ -22,10 +21,8 @@ namespace {
|
|||
// 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.
|
||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
|
||||
// An IPv6 literal goes in brackets, or its colons read as the port's.
|
||||
const std::string name = host.contains(':') ? "[" + host + "]" : host;
|
||||
if (port == (secure ? 443 : 80)) return name;
|
||||
return name + ":" + std::to_string(port);
|
||||
if (port == (secure ? 443 : 80)) return host;
|
||||
return host + ":" + std::to_string(port);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +37,8 @@ struct ClientHTTP1::Impl {
|
|||
std::unique_ptr<ByteStream> stream;
|
||||
std::string protocol;
|
||||
|
||||
void Connect(std::chrono::milliseconds connectTimeout, std::chrono::milliseconds handshakeTimeout) {
|
||||
tcp = std::make_unique<ClientTCP>(host, port, connectTimeout);
|
||||
void Connect(std::chrono::milliseconds handshakeTimeout) {
|
||||
tcp = std::make_unique<ClientTCP>(host, port);
|
||||
try {
|
||||
if (tls) {
|
||||
stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout);
|
||||
|
|
@ -149,7 +146,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
|||
// read fails. Nothing is replayed once a response byte has arrived.
|
||||
for (int attempt = 0;; ++attempt) {
|
||||
const bool reused = impl->tcp != nullptr;
|
||||
if (!reused) impl->Connect(connectTimeout, handshakeTimeout);
|
||||
if (!reused) impl->Connect(handshakeTimeout);
|
||||
|
||||
bool received = false;
|
||||
try {
|
||||
|
|
@ -179,97 +176,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,85 +38,11 @@ namespace {
|
|||
return read;
|
||||
}
|
||||
|
||||
// A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out;
|
||||
// anything else carries its errno, under the message a receive always
|
||||
// threw.
|
||||
// A failed recv. EAGAIN is the receive timeout (SO_RCVTIMEO) running out.
|
||||
[[noreturn]] void ThrowRecieveError() {
|
||||
const int failure = errno;
|
||||
if (failure == EAGAIN || failure == EWOULDBLOCK) throw SocketTimeoutException();
|
||||
throw SocketErrorException(SocketPhase::Receive, std::error_code(failure, std::generic_category()));
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException();
|
||||
throw std::runtime_error(std::strerror(errno));
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -129,28 +55,17 @@ ClientTCP::ClientTCP(const char* hostName, std::uint16_t port, std::chrono::mill
|
|||
// getaddrinfo rather than gethostbyname: the latter is not thread safe,
|
||||
// and it signals failure by returning null — which was then dereferenced
|
||||
// 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{};
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
addrinfo* resolved = nullptr;
|
||||
const std::string service = std::to_string(port);
|
||||
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
|
||||
if (status != 0 || resolved == nullptr) {
|
||||
throw SocketErrorException(SocketPhase::Resolve, std::error_code(status, ResolverCategory()),
|
||||
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);
|
||||
throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': "
|
||||
+ gai_strerror(status));
|
||||
}
|
||||
serv_addr = *reinterpret_cast<sockaddr_in*>(resolved->ai_addr);
|
||||
freeaddrinfo(resolved);
|
||||
|
||||
Connect(connectTimeout);
|
||||
|
|
@ -164,7 +79,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 —
|
||||
// closing it here (as this used to) meant moving a ClientTCP silently
|
||||
// dropped the connection it was carrying.
|
||||
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid), addresses(std::move(other.addresses)) {
|
||||
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) {
|
||||
other.socketid = -1;
|
||||
}
|
||||
|
||||
|
|
@ -177,23 +92,52 @@ ClientTCP::~ClientTCP()
|
|||
}
|
||||
|
||||
void ClientTCP::Connect(std::chrono::milliseconds timeout) {
|
||||
if (addresses.empty()) {
|
||||
throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again");
|
||||
if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){
|
||||
throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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();
|
||||
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;
|
||||
throw SocketTimeoutException();
|
||||
}
|
||||
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);
|
||||
}
|
||||
socketid = -1;
|
||||
std::rethrow_exception(last);
|
||||
}
|
||||
|
||||
void ClientTCP::Stop() {
|
||||
|
|
@ -231,8 +175,7 @@ void ClientTCP::Send(const void* buffer, std::uint32_t size) const {
|
|||
throw SocketClosedException();
|
||||
} else if (status < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
const int failure = errno;
|
||||
throw SocketErrorException(SocketPhase::Send, std::error_code(failure, std::generic_category()));
|
||||
throw std::runtime_error(std::strerror(errno));
|
||||
}
|
||||
sent += static_cast<std::uint32_t>(status);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ module;
|
|||
#include <sys/time.h>
|
||||
#include <sys/wait.h>
|
||||
#include <strings.h>
|
||||
#include <netdb.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Network:ListenerTCP_impl;
|
||||
|
|
@ -22,30 +21,14 @@ import Crafter.Thread;
|
|||
|
||||
using namespace Crafter;
|
||||
|
||||
ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit)
|
||||
: ListenerTCP("0.0.0.0", port, std::move(connectCallback), concurrentClientLimit, totalClientLimit) {}
|
||||
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) {
|
||||
sockaddr_in servAddr;
|
||||
bzero((char*)&servAddr, sizeof(servAddr));
|
||||
servAddr.sin_family = AF_INET;
|
||||
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
servAddr.sin_port = htons(port);
|
||||
|
||||
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);
|
||||
s = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if(s < 0)
|
||||
{
|
||||
throw std::runtime_error("Error establishing the server socket");
|
||||
|
|
@ -56,7 +39,7 @@ ListenerTCP::ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::
|
|||
int reuse = 1;
|
||||
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
|
||||
|
||||
if(bind(s, reinterpret_cast<const sockaddr*>(&servAddr), servAddrLength) < 0) {
|
||||
if(bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)) < 0) {
|
||||
// Ignoring this left the listener silently accepting nothing at
|
||||
// all, with no hint as to why.
|
||||
const std::string reason = std::strerror(errno);
|
||||
|
|
@ -72,18 +55,6 @@ ListenerTCP::ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::
|
|||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
running = false;
|
||||
shutdown(s, SHUT_RDWR);
|
||||
|
|
@ -93,7 +64,7 @@ void ListenerTCP::Stop() {
|
|||
|
||||
void ListenerTCP::ListenSyncSync() {
|
||||
while (running && totalClientCounter < totalClientLimit) {
|
||||
sockaddr_storage newSockAddr;
|
||||
sockaddr_in newSockAddr;
|
||||
socklen_t newSockAddrSize = sizeof(newSockAddr);
|
||||
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
|
||||
if (client > 0) {
|
||||
|
|
@ -110,7 +81,7 @@ void ListenerTCP::ListenSyncSync() {
|
|||
|
||||
void ListenerTCP::ListenSyncAsync() {
|
||||
while (running && totalClientCounter < totalClientLimit) {
|
||||
sockaddr_storage newSockAddr;
|
||||
sockaddr_in newSockAddr;
|
||||
socklen_t newSockAddrSize = sizeof(newSockAddr);
|
||||
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
|
||||
if (client > 0) {
|
||||
|
|
@ -126,7 +97,7 @@ void ListenerTCP::ListenSyncAsync() {
|
|||
void ListenerTCP::ListenAsyncSync() {
|
||||
ThreadPool::Enqueue([this]() {
|
||||
while (running && totalClientCounter < totalClientLimit) {
|
||||
sockaddr_storage newSockAddr;
|
||||
sockaddr_in newSockAddr;
|
||||
socklen_t newSockAddrSize = sizeof(newSockAddr);
|
||||
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
|
||||
if (client > 0) {
|
||||
|
|
@ -143,7 +114,7 @@ void ListenerTCP::ListenAsyncSync() {
|
|||
void ListenerTCP::ListenAsyncAsync() {
|
||||
ThreadPool::Enqueue([this]() {
|
||||
while (running && totalClientCounter < totalClientLimit) {
|
||||
sockaddr_storage newSockAddr;
|
||||
sockaddr_in newSockAddr;
|
||||
socklen_t newSockAddrSize = sizeof(newSockAddr);
|
||||
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
|
||||
if (client > 0) {
|
||||
|
|
|
|||
|
|
@ -6,53 +6,9 @@ import std;
|
|||
import :HTTP;
|
||||
import :HTTP1;
|
||||
import :TLS;
|
||||
import :URL;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
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
|
||||
// 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
|
||||
|
|
@ -122,27 +78,6 @@ namespace Crafter {
|
|||
// Drop the pooled connection; the next Send() dials again.
|
||||
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().
|
||||
HTTP1::MessageLimits limits;
|
||||
// How long to wait for the next piece of a response before giving
|
||||
|
|
@ -152,11 +87,6 @@ namespace Crafter {
|
|||
// from `timeout` because it covers a multi-round-trip exchange before
|
||||
// any request has been written.
|
||||
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:
|
||||
struct Impl;
|
||||
|
|
|
|||
|
|
@ -37,60 +37,14 @@ 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 {
|
||||
public:
|
||||
int socketid;
|
||||
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
|
||||
// SocketTimeoutException after that long, where it would otherwise
|
||||
// wait out the kernel's retries, about two minutes. It bounds each
|
||||
// address on its own. A refused connect fails at once either way.
|
||||
// wait out the kernel's retries, about two minutes. 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(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero());
|
||||
~ClientTCP();
|
||||
|
|
@ -129,13 +83,7 @@ namespace Crafter {
|
|||
// 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;
|
||||
private:
|
||||
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;
|
||||
sockaddr_in serv_addr;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,40 +1,28 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Network:ListenerTCP;
|
||||
import std;
|
||||
import :ClientTCP;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
export class ListenerTCP {
|
||||
public:
|
||||
// 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::uint32_t concurrentClientLimit;
|
||||
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());
|
||||
// `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();
|
||||
// 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 ListenSyncSync();
|
||||
void ListenSyncAsync();
|
||||
void ListenAsyncSync();
|
||||
void ListenAsyncAsync();
|
||||
private:
|
||||
std::uint32_t totalClientCounter = 0;
|
||||
int s;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Network:ListenerTCP;
|
||||
import std;
|
||||
import :ClientTCP;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
export class ListenerTCP {
|
||||
public:
|
||||
bool running = true;
|
||||
std::function<void(ClientTCP*)> connectCallback;
|
||||
std::uint32_t concurrentClientLimit;
|
||||
std::uint32_t totalClientLimit;
|
||||
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();
|
||||
void Stop();
|
||||
void ListenSyncSync();
|
||||
void ListenSyncAsync();
|
||||
void ListenAsyncSync();
|
||||
void ListenAsyncAsync();
|
||||
private:
|
||||
std::uint32_t totalClientCounter = 0;
|
||||
int s;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
//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
|
||||
|
|
@ -33,9 +33,6 @@ export import :HTTP1;
|
|||
// same way.
|
||||
export import :Stream;
|
||||
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 :ListenerHTTP1;
|
||||
#endif
|
||||
10
project.cpp
10
project.cpp
|
|
@ -8,7 +8,7 @@ using namespace Crafter;
|
|||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||
// TCP, HTTP/1.1 and TLS: what every native build has.
|
||||
constexpr std::array<std::string_view, 10> networkInterfaces = {
|
||||
constexpr std::array<std::string_view, 9> networkInterfaces = {
|
||||
"interfaces/Crafter.Network",
|
||||
"interfaces/Crafter.Network-ClientTCP",
|
||||
"interfaces/Crafter.Network-ListenerTCP",
|
||||
|
|
@ -16,7 +16,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
"interfaces/Crafter.Network-HTTP1",
|
||||
"interfaces/Crafter.Network-Stream",
|
||||
"interfaces/Crafter.Network-TLS",
|
||||
"interfaces/Crafter.Network-URL",
|
||||
"interfaces/Crafter.Network-ClientHTTP1",
|
||||
"interfaces/Crafter.Network-ListenerHTTP1",
|
||||
};
|
||||
|
|
@ -175,13 +174,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
if (cfg.target.find("linux") != std::string::npos) {
|
||||
cfg.AddTest("ShouldConfigureTCP").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("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
|
||||
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
//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;
|
||||
}
|
||||
Loading…
Reference in a new issue