Skip to content

Perfect-NIO 0.1.0

Latest

Choose a tag to compare

@taplin taplin released this 14 Oct 13:48
a1c2595

The first tagged release of Perfect-NIO: the Swift 6 rewrite of Perfect's HTTP(S) server on SwiftNIO, with typed route pipelines, async handlers, WebSockets, multi-tenant TLS, an optional admin console and Perfect-CRUD route helpers. These are initial-release notes, not a migration guide from an earlier tag (there is none). Apps that tracked main and the older legacy branch should read "Breaking and behaviour changes" and "Upgrade notes".

0.x: the API may change between minor versions. Perfect-NIO stays on 0.x until its API settles. A 0.x minor release (0.1 → 0.2) may contain breaking changes, and its release notes will list them. from: "0.1.0" accepts any 0.x release; .upToNextMinor(from: "0.1.0") takes only 0.1.x fixes.

Requirements

  • Swift 6.2 or later (// swift-tools-version: 6.2), Swift 6 language mode.
  • macOS 12+ or Linux. No iOS, tvOS or watchOS.
  • Linux: pkg-config and zlib1g-dev for the CZlib system library. macOS needs nothing extra.
  • PerfectNIOCRUD uses Perfect-CRUD 3.0.1 or newer. Perfect-MySQL 4.0.0 is used only by the package's own MySQL tests; building the library products needs no MySQL client library.
.package(url: "https://github.com/PerfectlySoft/Perfect-NIO.git", from: "0.1.0")

This release adds a GitHub Actions workflow for every push and pull request: Linux (Swift 6.2 and 6.4, debug and release) against a MySQL 8.4 service with the live tests required (MYSQL_REQUIRE_LIVE=1), and macOS (Xcode 26.0.1 and 26.6, debug and release). Warnings are errors everywhere, tests included, and every job runs with TZ=America/Denver.

Highlights

  • Swift 6 rewrite (#13). Route closures are async and @Sendable. Server(routes:port:…) replaces bind().listen().wait(): run() serves until the task is cancelled, withServer { port in … } gives a scoped server for tests. The response body is pulled from HTTPOutput.nextChunk(allocator:). Logging uses apple/swift-log.
  • WebSockets (#13, #18, #19). webSocket(protocol:options:_:) declares an endpoint; its callback runs through the normal route pipeline at handshake time and returns an async WebSocketHandler. Only routes declared with webSocket(...) upgrade, a handshake to any other path is served as ordinary HTTP (RFC 6455 §4.2.2), and the declared subprotocol is now selected in the 101 when the client offers it.
  • Multi-tenant TLS (#13). TLSContextManager picks a certificate per SNI hostname and can be updated live; CertificateWatcher reloads renewed certificates; ACMEChallengeResponder answers HTTP-01 challenges; Server.httpRedirect() builds an HTTP→HTTPS redirect server.
  • Graceful restarts (#13). alwaysReusePort lets a replacement process bind the same port before the old one stops; reusePortCount opens several SO_REUSEPORT sockets in one process.
  • PerfectAdminConsole (#13). An opt-in admin server bound to 127.0.0.1: status, TLS domains, logs, routes, metrics, datasources and actions, with bearer-token auth, CSRF protection and a self-contained web dashboard.
  • PerfectNIOCRUD (#13). .db(_:_:) / .table(_:_:_:) route operators for Perfect-CRUD, plus pooled .db(pool:_:) / .table(pool:_:_:) over Perfect-CRUD 3's DatabaseConnectionPool.
  • Request hardening (#14, #15, #17, #19). A 10 MiB default request body limit, upload temp files that only the server's user can read, no internal error text in responses, an idle timeout from the moment a connection is accepted, and a set of parser fixes for input that used to crash the server (see Fixes).

Breaking and behaviour changes

These are relative to the code on main before the release (which apps such as Perfect-Lasso, FMTestApp and PerfectTemplate tracked by branch) and, where noted, to the legacy branch.

  • Not source compatible with the legacy branch. See the README's "Migrating from the legacy branch". Upgrade: replace routes.bind(port:).listen().wait() with try await Server(routes: routes, port: …).run(), make route closures @Sendable (and async where useful), move custom outputs to nextChunk(allocator:), and drop PerfectLib, Perfect-Mustache, Perfect-MIME, Perfect-CZlib-src and Perfect-CURL. There is no Mustache output.
  • Request bodies are capped at 10 MiB by default (#17). Larger bodies, uploads included, get 413 and the connection is closed. Upgrade: pass maxRequestBodySize: (bytes, or nil for no limit) to Server if you accept bigger bodies.
  • Error text is no longer sent to clients (#17, #19). Errors other than ErrorOutput / TerminationType become 500 "Internal server error." and are logged under perfect.nio.server. A failure to decode the request becomes 400: from the decode modifier (#17) and now also from a direct HTTPRequest.decode(_:content:) call whose DecodingError the handler lets escape (it was a 500). That error is marked: its context's underlyingError is now a public RequestDecodingFailure wrapping the original; DecodingError.isRequestDecodingFailure tests for it and DecodingError.originalUnderlyingError returns the decoder's own error. Any other DecodingError, such as one from the handler's own data, stays a 500 logged at error, even after a handled request-decode failure. Upgrade: throw ErrorOutput(status:description:) for anything the client should see.
  • Requests with a .. path segment, or a #, get 400 (#19). A path (everything before the first ?) with a .. segment, as sent or percent-encoded (%2e%2e, .%2e, ..%2f, ..%5c, ..\, up to 7 layers of nested encoding, deeper nesting refused outright), or with ..;… / ..%00, is refused with 400 and the connection closed, before any route runs (WebSocket handshakes included), so wild and trailing never return a traversal segment. So is any request target containing # (conforming clients never send a fragment; it was a bypass). A request target longer than 8 KiB gets 414 URI Too Long. Dots within names (a..b, .hidden) are allowed. Upgrade: nothing, unless a client legitimately sends .. segments; resolve them on the client.
  • Upload temp files (#15). They are created 0600 under NSTemporaryDirectory() (they were 0666 in /tmp/), a failed upload fails the request (413, 507 or 500) instead of reaching the handler truncated, and a multipart body cut off before its closing boundary gets 400. Upgrade: if another user's process reads tmpFileName, copy or re-permission the file in your handler; cleanup jobs matching /tmp/perfect_upload_* must look in TMPDIR.
  • A hand-built WebSocketUpgradeHTTPOutput no longer upgrades (#18). Its public initializer is deprecated; such a route answers 426. Upgrade: declare the route with webSocket(protocol:options:_:).
  • The idle timeout starts at accept and no longer cuts off steady responses (#19). idleTimeout used to be installed only once a request head had been routed, so a client that sent nothing or half a request head was never timed out; and it measured reads only, so any response that took longer than the timeout to deliver (a slow download, a long poll, server-sent events) was cut off mid-body. Now, while no response is pending, a connection is idle when nothing has been read for the timeout (covering the TLS handshake, the first request and keep-alive gaps); while a response is pending, it is idle only when the response is backed up and nothing more of it has been written or (per the kernel's TCP counters) has left the send buffer for the timeout. Writes are sliced to 16 KiB so progress is seen often. A client that closes its side while its response is pending is released once nothing has been written for the timeout (it used to pin the socket in CLOSE_WAIT until the route finished); the close is detected from the TCP state as well (any state other than ESTABLISHED, so a reset client counts too), since on macOS NIO doesn't read a FIN or RST that arrives while a response is pending. A response on a connection that closes after it (Connection: close, HTTP/1.0) is now fully flushed before the close (NIO dropped the queued tail before). It is still removed on a WebSocket upgrade. Upgrade: nothing, unless clients legitimately wait longer than idleTimeout (default 60 s) before sending their first request; raise it or pass nil.
  • A WebSocket handshake that isn't upgraded closes the connection (#19). NIO's HTTP decoder stops parsing a connection after any request that asks to upgrade, so the response to a refused or non-WebSocket handshake now says Connection: close and the connection is closed. Before, it was kept alive and the client's next request on it hung until the idle timeout. Upgrade: nothing; clients open a new connection.
  • A 1xx final status gets 500 (#19). A route returning 100 or 103 as its final status crashed the process (a precondition in NIO's protocol error handler); 101 left the connection confused. It is now logged and answered 500 with Connection: close. Upgrade: nothing.
  • An output's Content-Length is enforced (#19). Equal duplicates are collapsed; conflicting or invalid values are dropped (logged) and the body framed without them; a body shorter or longer than the declared length is logged and the connection closed after it (a longer body is cut at the declared length: the bytes that fit are sent). They used to stay keep-alive and desync the next response. Upgrade: fix outputs that declare a wrong length.
  • A non-WebSocket Upgrade offer is ignored (#19). Upgrade: h2c (curl --http2) and the like got an empty reply; the request is now served as HTTP/1.1 and the connection closed. Upgrade: nothing.
  • Responses are framed by the server; no body with HEAD, 204 or 304 (#19). The body was sent anyway, and a HEAD response for an output of unknown length (any compressed() route, any streaming HTTPOutput) was framed as chunked and ended with a 0\r\n\r\n terminator, so keep-alive clients and proxies read stray bytes as the start of the next response. Now HEAD responses keep the GET's headers except Transfer-Encoding (Content-Length when known), 204 and 304 carry no body (204 no Content-Length), a body of unknown length is chunked on HTTP/1.1, and on HTTP/1.0 it ends the connection (it used to promise keep-alive with no length). An output that sets Connection: close now closes the connection. Upgrade: nothing, unless a client relied on the stray body.
  • A route's status survives an output with no status (#19). HTTPHead.merged(with:) let a head with no status wipe out the status already set, so statusCheck { .created } followed by .text() or .json() answered 200. It now answers 201. Upgrade: check any route that set a 2xx status through statusCheck or the route context and was relying on 200.
  • FileOutput ranges and conditional requests (#19). A served range was 200 with a partial body; it is 206 now. Ranges are comma-separated as in RFC 9110 (they were split on /), only the first satisfiable range is served, and a malformed, backwards or unsatisfiable range gets the whole file with 200. If-Range is honoured: a stale validator gets the whole file instead of a range spliced onto an old copy. The ETag is now a quoted string (it was bare hex) that also covers the file size, inode and nanosecond modification time (a same-second, same-size rewrite kept the old tag), Last-Modified is sent, and If-None-Match accepts lists, weak tags and *. Upgrade: nothing for standard clients; cached ETags from before change once.
  • FileOutput on Linux (#19). A directory path now serves its index.html on Linux too (it was treated as a file, which crashed the server), and a directory without one is 404. The file is read in chunks of up to 256 KiB instead of all at once. Upgrade: nothing.
  • Multipart header parsing (#19). A quoted boundary="…" loses its quotes, a boundary followed by another parameter no longer swallows it, a missing or empty boundary is refused with 400, and filename= listed before name= no longer becomes the field name. Upgrade: nothing for conforming clients.
  • Wildcard routes match route text literally (#19). A route component containing regex syntax ("v1.0", "a+b") in a route with * or ** used to be a regular expression, so /v1.0/* also matched /v1x0/…. Upgrade: if you relied on regex syntax in route components, list the routes explicitly.
  • HTTPMethod is no longer Hashable through this package (#19). The package declared a public retroactive conformance of NIO's HTTPMethod to Hashable; it is gone, so Set<HTTPMethod>, dictionaries keyed by HTTPMethod and generics requiring Hashable of it stop compiling. Upgrade: key by method.name, or declare extension HTTPMethod: @retroactive Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(name) } } in your own module.
  • SendableMetatype constraints (#19). decode(_:), json(), text() and map over a Collection's elements now require the type to be SendableMetatype. Concrete non-isolated types already are, so ordinary call sites are unaffected, but generic helpers stop compiling (func f<T: Decodable>(_: T.Type) -> Routes<HTTPRequest, T> { root().decode(T.self) }: "type 'T' does not conform to 'SendableMetatype'"), and a type isolated to a global actor can't be used. Upgrade: add & SendableMetatype to the generic parameter (T: Decodable & SendableMetatype, Encodable & SendableMetatype for json(), CustomStringConvertible & SendableMetatype for text()); decode into a non-isolated type.
  • HTTPRequest is Sendable (#19). Needed so plain functions over any HTTPRequest can be passed to route builders on Swift 6.2 (the README's auth pattern warned there). Upgrade: a custom HTTPRequest conformer must be Sendable (or @unchecked Sendable).
  • withServer runs in the caller's isolation (#19). It is nonisolated(nonsending), so it can be called from the main actor (it failed to compile there: "sending value of non-Sendable type"). Upgrade: nothing.
  • A multipart file part decodes only as FileUpload (#19). Decoded into a String (or other text) field it used to give the server's temp file path; it now throws DecodingError (400 from decode). Upgrade: declare file fields as FileUpload.
  • compressed() sends Vary: Accept-Encoding on every response, honours q=0 against *, and no longer compresses partial (206) responses (#19). Upgrade: nothing.
  • Two Host headers get 400; a malformed WebSocket handshake gets an HTTP answer (#19). A handshake with no Sec-WebSocket-Key or a version other than 13 was closed without a response; it is now served as HTTP (a webSocket route answers 426 with Sec-WebSocket-Version: 13). Upgrade: nothing.
  • Form and query decoding throws for nested containers (#19). A Decodable type whose init(from:) asks the form/query decoder for a nested or super decoder used to crash the server; it now throws DecodingError (400 from decode). Upgrade: nothing.

Fixes

  • #13: Swift 6 modernization. Multi-tenant TLS, the admin console, alwaysReusePort, PerfectNIOCRUD, an inline zlib system library instead of Perfect-CZlib, first standalone Linux build.
  • #14: TempUploadFile.write checks its range, retries short writes and EINTR, and reports the real errno (it wrote stray bytes, reported short writes as complete and turned every error into EIO).
  • #15: Upload temp files are 0600 under NSTemporaryDirectory(); failed and cut-off uploads fail the request; two force unwraps removed.
  • #16: The MySQL tests read MYSQL_TEST_* and never default to port 3306 or the Unix socket.
  • #17: Request body size limit (including a bypass through the WebSocket pre-upgrade router); SIGXFSZ ignored at server start so an upload past RLIMIT_FSIZE gets 413 instead of killing the process; no error text echoed in 500s.
  • #18: A request carrying Upgrade: websocket to a plain route runs that route once, not twice.
  • #19: Crash on Accept-Encoding: gzip;q (any parameter without "=") on a .compressed() route: an index out of range took the server down.
  • #19: Crash on Range: bytes=0-9223372036854775807 on a FileOutput route (integer overflow).
  • #19: Crashes on malformed multipart input: Content-Type: multipart/form-data; boundary with no value, a part header parameter with nothing after "=", and a part header line with nothing after ":".
  • #19: Crash serving a directory with FileOutput on Linux, and traps in the non-throwing FileHandle calls on I/O errors.
  • #19: fatalError in the form/query decoder's nested containers.
  • #19: A streaming HTTPOutput under .compressed() lost the rest of its body and the gzip trailer when a chunk compressed to nothing.
  • #19: FileOutput put the local file path in a 500 body.
  • #19: Idle timeout before the first request; connections left open after a request the HTTP parser rejected.
  • #19: HEAD/204/304 responses carried a body.
  • #19: The route context's status was lost when the output set none.
  • #19: A refused webSocket(...) handshake (or a WebSocket parent dir'd into a plain child) ran the route's pipeline twice. The result of the handshake-time run is now served.
  • #19: Keep-alive connections hung after a WebSocket handshake that wasn't upgraded (confirmed on main).
  • #19: The protocol argument of webSocket(protocol:) was ignored; it is now selected in the 101 when offered (browsers fail a connection otherwise).
  • #19: Wildcard routes matched over a range measured in Characters rather than UTF-16 units, and route text was used as regex syntax. Not reachable through Server today (NIO's parser refuses raw non-ASCII request lines), but wrong in the finder.
  • #19: Admin console script injection: datasource, action, job, TLS domain and log subsystem names were put into onclick handlers with escaping that a " or \ could break out of, in the page that holds the admin token. Checked in node: a name such as x\');alert(1);// ran.
  • #19: HTTPHead, HTTPRequestInfo, QueryDecoder, FileUpload and RouteDescription are Sendable; RouteDescription.uri is public (the README's routes.describe example didn't compile outside the module).
  • #19 (review round): HEAD responses of unknown length were framed as chunked and wrote a terminator that desynced keep-alive connections; HTTP/1.0 streams promised keep-alive with no length.
  • #19 (review round): The idle timeout cut off slow responses (downloads, long polls, server-sent events) after idleTimeout.
  • #19 (review round): FileOutput ignored If-Range, sent an unquoted ETag and matched If-None-Match only exactly.
  • #19 (review round): compressed() sent no Vary and let * override gzip;q=0.
  • #19 (review round): A file part decoded into a String field exposed the server's temp file path.
  • #19 (review round): Two Host headers were accepted; malformed WebSocket handshakes were closed silently.
  • #19 (review round): Paths with .. segments (raw or encoded) reached wild/trailing handlers; they are now refused with 400. A failed direct HTTPRequest.decode is answered 400.
  • #19 (re-review): the .. guard was bypassed with # (it cut at #, routing doesn't); # is refused, ..; and ..%00 count, decoding repeats to a fixed point.
  • #19 (re-review): slow but steady readers were still cut (writes completed per body chunk; the response counted as done when queued; Connection: close responses dropped their queued tail at close); disconnected clients pinned sockets while their route ran.
  • #19 (re-review): a 1xx final status crashed the process; an output's wrong Content-Length desynced keep-alive; Upgrade: h2c got an empty reply; the ETag missed same-second rewrites.
  • #19 (re-review): the server's request object raced when used from two tasks (Thread Sanitizer); a handled request-decode failure turned a later internal DecodingError into a 400.
  • #19 (round 3): the .. check was quadratic (an 80 KB nested-%25 URI cost seconds of CPU and stalled other connections); it is bounded (7 layers of encoding are checked; deeper nesting is refused), and request targets over 8 KiB get 414. On macOS a client's FIN arriving after its request was never seen, so the disconnect release didn't work there; the TCP state is now checked. An overrunning body was dropped whole instead of cut at its Content-Length. The request-decoding mark is public API. Ranges are no longer gzipped.
  • #19 (review round): HTTPRequest is Sendable; withServer can be called from the main actor; every README example compiles (several didn't, including the dir closure form).
  • #19: Warning-free on Swift 6.2 and 6.4; testWebSocketEcho runs on Linux (it used URLSessionWebSocketTask, which Ubuntu's libcurl doesn't support, so it always failed there: a test problem, not a server bug); MYSQL_REQUIRE_LIVE=1 makes the live MySQL tests fail instead of skip; new regression tests for HTTP request smuggling and malformed requests (NIO's parser already refuses them; the tests pin that and the connection close).

Known limitations

  • 0.x API. Expect breaking changes between minor versions until 1.0.
  • Bodies are buffered in memory. The whole request body, multipart uploads included, is read before the route runs. The size limit is per request, so total memory scales with the number of connections.
  • Slow clients. The idle timeout resets on every byte received, so a client trickling a request is not bounded by it; there is no whole-request deadline. While a response is pending, a client counts as stalled when nothing has left the server's send buffer for the timeout. TCP reopens a slow reader's window only after it has read much of its own receive buffer, so a reader with a large buffer and a low read rate is silent for buffer/rate seconds (about 40 s for 1 MB at 25 KB/s) and is cut if that exceeds the timeout, as with nginx's send_timeout. Measured: 100-300 KB/s readers complete with a 2 s timeout; 25-40 KB/s ones need about 5 s or more on macOS loopback. Idle checks run every quarter of the timeout.
  • A route that never returns keeps a still-connected client's connection open (the route isn't cancelled). Once the client closes its side, the connection is released after the timeout; the route still runs to completion. A client that half-closes and then waits longer than the timeout for its response is cut.
  • Paths are otherwise not normalized. .. segments are refused, but routing uses the rest of the path as sent: no percent-decoding, . segments kept. wild/trailing values can still name hidden files or carry encoded characters; validate them before building a FileOutput(localPath:). There is no built-in static file route.
  • A WebSocket handshake that carries a body still runs a webSocket(...) route twice when it isn't upgraded: once without the body to decide the upgrade, and again with it.
  • Requests pipelined behind a handshake that isn't upgraded are not served; the connection closes after the handshake's response.
  • A response header containing CR or LF closes the connection without a response (NIO's validator refuses it; the header is never split).
  • Ranges. Only the first range of a multi-range request is served, and suffix ranges (bytes=-500) are ignored (the whole file is served).
  • Wildcard routes match case-insensitively, while routes without */** match case-sensitively.
  • HTTP/1.1 only. No HTTP/2.
  • Missing Host. An HTTP/1.1 request without a Host header is accepted (two are refused).
  • The pre-macOS 13 upgrade pipeline (configureUpgradeFallback) is covered by unit tests only; CI's macOS runners take the macOS 13+ path.
  • Under Thread Sanitizer, the nine exit tests that set RLIMIT_FSIZE (SIGXFSZ, TempUploadFile, MimeReader size limit) abort with SIGABRT, on main as well. TSan reported no races in Perfect-NIO's code; one intermittent report inside NIO's pooled receive-buffer allocator, believed to be a TSan false positive.

Upgrade notes

  • From branch: "main": depend on from: "0.1.0" (or .upToNextMinor(from: "0.1.0")), and on Perfect-CRUD from: "3.0.1" if you use PerfectNIOCRUD. A version-pinned dependency can't resolve while a package depends on a sibling by branch.
  • Raise maxRequestBodySize if you accept bodies over 10 MiB.
  • If you hashed HTTPMethod through this package's conformance, add your own (see above).
  • Add & SendableMetatype to generic helpers that call decode, json, text or element-wise map.
  • Check routes that set a 2xx status with statusCheck: they now return it.
  • If a slow client or monitoring probe connects and waits before sending, it is now closed after idleTimeout.

Legacy

The pre-Swift-6 package ("Perfect 4 NIO", Swift 5, never tagged) is preserved on the legacy branch, cut from the commit the Swift 6 rewrite (#13) branched from. It is not maintained.