1.0.0 is Garuda's first release. The tags v1.0.0 to v1.1.5 that were
in this repository predated Garuda and described a different project; they
have been deleted rather than left where they would be read as its history.
Nothing below them was ever published as Garuda.
Keeping this file. A change someone using Garuda would notice gets a line under Unreleased in the commit that makes it. When a version is cut, that section is renamed to the version and its date, and a new empty Unreleased section goes above it.
What CI runs. .github/workflows/ci.yml runs on every push to main and
every pull request:
- the release build and
swift teston Ubuntu 24.04 and macOS 15, and the handler code that must not compile; swift testagain against a real PostgreSQL and a real Redis, which is what turns the opt-in connector suites on, and the example applications with them;- the end-to-end suites, which drive the release binary over a socket;
- the protocol suites -- HTTP/2, HTTP/3, WebSocket, WebTransport, uploads --
against clients built from h2 and aioquic, and
pgfuzzunder AddressSanitizer.
The same runs nightly and on demand, with the fuzzer given ten minutes rather than one.
Before cutting a version. Run the lot against the release build:
swift build -c release
swift test # 1130 unit tests; GARUDA_REDIS and GARUDA_POSTGRES run the database ones
bash scripts/compile-fail-test.sh # 18
bash scripts/integration-test.sh # 36
bash scripts/static-test.sh # 105
bash scripts/compress-test.sh # 76, and garuda-conformance
bash scripts/cache-test.sh # 85, runs garuda-conformance
bash scripts/ratelimit-test.sh # 18
bash scripts/redirect-test.sh # 22
bash scripts/sni-test.sh # 11
bash scripts/resumption-test.sh # 7
bash scripts/acme-test.sh # 12, needs Pebble (PEBBLE_DIR)
bash scripts/request-id-test.sh # 12
bash scripts/trace-context-test.sh # 17
bash scripts/drain-test.sh # 14
bash scripts/reload-test.sh # 7
python3 scripts/feature-test.py # 62
python3 scripts/http2-test.py # 62
python3 scripts/http3-test.py # 76
python3 scripts/router-streams-test.py # 41
python3 scripts/handler-test.py # 143, runs garuda-conformance
python3 scripts/websocket-test.py # 104, runs garuda-conformance
python3 scripts/websocket-streams-test.py # 94, runs garuda-conformance
python3 scripts/webtransport-test.py # 46, runs garuda-conformance
python3 scripts/upload-test.py # 35, runs garuda-conformance
python3 scripts/broadcast-test.py # 36, runs garuda-conformanceThe Python suites need h2 and aioquic.
GARUDA_REDIS is host:port:password, and the password is not optional for a
full run: the suite has tests that a wrong password is refused and that an ACL
user is held to its keys, so it wants a server with requirepass set and a
user app with password app-secret allowed only keys under app:. Given
host:port with no password the suite still runs and those two fail, which
reads as a Redis bug and is not one. Give the run a server nothing else is
using -- a suite that finds a password it did not set, or does not find one it
expected, fails everywhere at once with an authentication error that names
nothing relevant.
- A handler that never waits -- one calling
Task.yield()in a loop -- no longer holds its worker. The worker ran ready handler tasks until none was left, which such a handler never allowed, so the route's deadline did not fire and every other connection on the worker waited until the handler stopped. A run now takes at most 64 task steps before the worker sees to its sockets and timers, and comes back for the rest without sleeping. This needs aviancore 0.7.2. - A route's deadline is kept when the worker's timer pool is full. It used to be dropped without a word, and a request dispatched then ran unbounded for good, exactly when the worker was under the most pressure. It is now armed as soon as there is room, or answered 504 when its time comes first.
- A request that ends while waiting for a pooled PostgreSQL or Redis connection leaves the pool's queue at once. It used to stay until a release reached it, so while every connection was held -- a long transaction, a parked session -- each client that hung up grew the queue.
- Redis Cluster pipelines and the upload store's expiry no longer do work quadratic in their size: a pipeline of thousands of keys to one node, and an upload directory with thousands of remembered answers, each scanned an array once per entry.
Mostly fixes, found by review and by measuring what one slow request costs
the others on its worker. Beside them: two new examples, the documentation
brought up to date, and a few additions -- onCreate and
CompletedUpload.metadata for resumable uploads, --static-listing-limit,
MemoryRefreshTokenStore.count, and replace and remove on SessionStore,
which have defaults, so a store of your own still compiles.
One change breaks source, which COMPATIBILITY.md keeps
for a major version: CompletedUpload.digest() is now async. A completion
handler that calls it must add await, and the compiler's error says exactly
that. It is in a minor release because the call it replaces hashed a whole
upload on the worker's thread, holding every other request there for as long
as that took, and the fix is one word. Nothing else that is covered changes.
-
Two new examples.
swift run uploadsis per-user photo uploads:resumableUploadsinsidegroup("/users/:user"), bearer tokens that guard the upload's own URL as well as the route that creates it,onCreaterecording whose upload it is, image types checked from the file's first bytes rather than the client'sContent-Type, and an answer replayed with itsLocation.swift run webtransportis a network probe over HTTP/3: datagram round trips, download and upload throughput on streams with backpressure, a stream the server opens, an extractor that refuses a session before it starts, and a browser page that trusts a self-signed certificate throughserverCertificateHashes. Nine tests cover them throughapp.test, and the probe was driven over QUIC with aioquic. -
The documentation catches up with this release. TRANSPORT.md says a Host beside
:authoritymust match it; MIDDLEWARE.md that a JWK Set with no usable key withdraws aged keys, and whatMemoryRefreshTokenStore.countreports; CONNECTORS.md that a pool hands a connection to its oldest waiter and ends a wait with its request, what the HTTP/2 client refuses, and that responses can already be streamed withclient.stream-- it had listed them as future work. README.md says a send on an ended WebSocket throwsWebSocketError.closedand that a completed upload's answer is replayed with itsLocation. EXAMPLES.md had left the files example out, and the test counts in README.md and here are those of a verified run. -
A
--static-listingpage shows at most 10,000 entries, and says when there are more;--static-listing-limit Nsets the limit, and 0 lifts it. A listing is built on the worker with astatfor every entry, so a directory of a million files held every other request on that worker while it was read, and answered with tens of megabytes of HTML. Past the limit the directory is not read further. -
A handler reading a streaming request body over HTTP/1.1 no longer holds up every other request on its worker while the client keeps sending. Each read refilled the body from the socket there and then, so the loop did not turn until the client paused -- for a fast client, the whole body. Two clients uploading 256 MB each to one worker held a cheap route on it for up to 0.8 s at a time. A handler now gets a few refills (about 1 MiB) before it waits for its socket's turn; upload throughput is unchanged.
-
Breaking:
CompletedUpload.digest()is nowasync, and a completion handler that calls it withoutawaitno longer compiles -- the error names the fix,await digest(). Resumable uploads hash a finished upload on the blocking pool, not the worker, when the client sentRepr-DigestorWant-Repr-Digest, anddigest()in a handler does the same. The overload that hashes on the calling thread is still there for code that is not async, and markednoasync, so a handler cannot pick it by accident. On a CPU without SHA instructions, two clients sending 15 MB files back to back took a worker from 2,000 answers a second to 500, the rest waiting seconds; with both changes it keeps 2,000 with p99 1.9 ms. An upload whose bytes all come in one request -- a photo, usually -- is hashed as they arrive when its digest is wanted, rather than read back from the file afterwards. -
EXAMPLES.md says that waiting on an external program in a handler is blocking work, to be run in
blocking { }with no lock of its own around it, and that decoding an image once in-process beats launching tools per request. -
A request waiting for an
SQLiteDatabaseconnection gets the next one given back. The connection was marked free and the waiter only woken, so whoever asked before the waiter ran took it first -- often the handler that had just given it back, starting its next statement -- and the waiter queued again at the back. A handler running statements back to back could hold the writer until it was done with all of them. Connections are now handed straight to the oldest waiter, as the PostgreSQL and Redis pools already did. -
The HTTP/2 client no longer leaves requests waiting for good when a shared connection ends while several are queued to write on it. Only the first of them was told; it failed without letting the others go, and the rest waited for as long as the worker ran -- a request's timeout does not reach a writer queued for the lock. Each now fails at once with
closed. The lock also goes straight to the next writer in line, so a request cannot lose its turn to one that arrived later. -
A JSON object decoded into a
Dictionarywhose values are ordinaryDecodabletypes -- not@JSONones -- is read in linear time. Each key was found by walking every member before it, so aBody<[String: Item]>of 8,000 keys, 150 KB, took 1.4 seconds on the worker's thread, stalling every other connection on it; 4,000 keys now take 5.5 ms rather than 480. Decoding a struct costs what it did. -
A pool acquisition ends when its request does. A handler waiting for a PostgreSQL, Redis or SQLite connection whose client hung up, or whose request passed its deadline, kept waiting until its own timeout, and then took a connection and ran its queries for nobody. It now throws
cancelledas soon as the request ends, and a connection released to it just before then goes to the next request in line. -
A timed wait begun while the worker had no room left for another timer -- the op pool is sized to
--max-connectionsand shared with every deadline and sleep -- still ends on time. It waited with no timer at all, so a pool acquisition could outlive itsacquireTimeoutMilliseconds, and a Redis Cluster or Sentinel retry's pause, or a scheduled job's sleep, had nothing to end it. It gets a timer once there is room, and ends at its deadline regardless. -
MemorySessionStoreis safe to share between threads, as itsSendableconformance said. Its dictionary was unguarded, so a store reached from the blocking pool or a thread of its own could crash the process; it is behind a lock now, asMemoryRefreshTokenStore's state already was. -
MemoryRefreshTokenStorelets a family and its tokens go once the family has expired. It kept every login and every refresh the worker had ever issued. Spent tokens of a live family are still kept, since presenting one again is how a stolen token is noticed.countsays how many families and tokens it holds. -
An HTTP/2 or HTTP/3 request carrying a Host field beside
:authorityis served. Clients and proxies may send both, and RFC 9113 and RFC 9114 only ask that they match; the request was rebuilt as HTTP/1.1 text with a Host line from:authorityand then the client's own, and the parser refuses a second Host, so every such request was reset as malformed. There is now one Host line, and a Host that differs from:authority, ignoring case, is refused as the RFCs say -- an empty:authoritybeside a Host included. -
A response head too large for one request no longer ends an HTTP/2 connection that other requests share. The encoded block was held to the limit of the request it answered while it was assembled, and past that limit the connection ended -- so on a connection shared by clients with different
maxHeadBytes, the tightest one failed every request on it. The block is now held to the largest limit of any request on the connection, and one past its own request's limit fails that request alone. -
The HTTP/2 client sends
te: trailers, the one connection-specific field RFC 9113 lets an HTTP/2 request carry, and which gRPC needs; it was refused with every other. It refuses a 101 response, which RFC 9113 makes malformed, rather than waiting for a final response after it. -
The HTTP/2 client refuses a response whose pseudo-fields are out of place: a
:statusafter other fields, a second:status, a request pseudo-field such as:path, one it does not know, or a:statusin trailers. RFC 9113 makes each malformed; a second:statusreplaced the first. -
A request still holding a session that another request has destroyed can no longer bring it back. Each request loads its own copy of the session, and a change was written under the copy's ID whether or not the session was still there: a logout in one tab, then a
setfrom a request another tab had started before it, stored the signed-in data under the old ID again, and the cookie the logout was meant to end worked once more.renewfrom such a request did the same under a new ID. A change to a session the request loaded is now written only if the session is still live, checked and written in one step by the store, andrenewmoves the session, and a change that empties it deletes it, only if it was still there; otherwise the call throws 409 Conflict and the request's copy is left empty, with no Set-Cookie: the cookie is left to the request that ended the session, which may have sent a renewed one.SessionStorehas two new requirements for this,replaceandremove. Both have defaults, so a store of your own still compiles, but the defaults check and write in two steps; the memory, Redis and SQLite stores do it in one. -
CSRF protection no longer takes an origin on another port for the site itself. Without Sec-Fetch-Site,
Originis compared withHost, and:80and:443were both dropped from each before comparing, whatever the scheme:https://example.com:80passed for Hostexample.com:443, though they are different origins and a page on one could post to the other. An origin's port is now read against its own scheme, and a Host without a port stands for that scheme's default. -
An upload created at a pattern with an empty segment, such as
/files/or/files//new, now gets a URL that works. The URL is worked out by taking the pattern off the end of the request's path, and the pattern's segments were counted with the empty ones dropped, though the router keeps them: insidegroup("/api"), a POST to/api/files/was sent to/api/files/uploads/<id>, which answers 404, rather than/api/uploads/<id>. A pattern of/outside any group was sent to//uploads/<id>. UndertrailingSlash(.ignore), a POST to/api/files/for the pattern/filescounted the slash the router had ignored, and was sent to/api/files/uploads/<id>too. -
Resumable uploads mounted inside a group work.
resumableUploadsregisters its own routes through the builder it is called on, so a group's prefix went in front of them, and two things were then wrong at once: theLocationit advertised left the prefix out, so a client that followed it to resume was answered 404 -- and passing the prefix in asuploadsonly registered it twice -- while the handlers for HEAD, GET, PATCH and DELETE read the upload's id as the route's first parameter, which undergroup("/users/:user")is the user. Inside a group, resuming an upload could not work at all. An upload's URL is now worked out from the request that created it, so it is the path the client used withuploadson the end, and the id is read as the last parameter, after any a group captured.uploadsis relative to where the routes are mounted, which is what it already was for an application outside any group. -
An answer to a completed upload whose body is streamed is no longer remembered as an empty one. The answer is kept by a hook that runs as the response goes out, and a streamed body is written after that, so a handler returning
StreamingBodyhad an empty body stored with its status: a client that lost the answer and asked for it again with GET was given a 200 with nothing in it, in place of the receipt it was owed. Nothing is remembered for a streamed answer now -- the GET answers as it does for an upload nothing is remembered about, and says so withUpload-Complete-- and the log says which upload it was. -
resumableUploadstakes anonCreatehook, and what it returns is kept on the upload asmetadatafor the handler that completes it. The request that creates an upload is authenticated by whatever guards the routes, and the one that completes it is a different request arriving later, so an application had nowhere to put who an upload belonged to:UploadInfocarried onlyContent-TypeandContent-Disposition, which come from the client and can say anything. The alternative was a second, authenticated route to commit each upload, which leaves the thing being uploaded non-existent until that second call arrives, and leaves bytes behind formaxAgewhen it never does.onCreateis given the creating request, so identity and whatever else the application knows go on the upload where no client can reach them andCompletedUpload.metadatafinds them; throwing from it refuses the upload before anything is written,HTTPErrorwith the status it names. Uploads written by a build without metadata still read. -
An append to a resumable upload reads the upload's state again once it holds the lock, and goes by what it reads. The checks it made -- that the upload is still going, and how long it was declared to be -- came from a read taken before the wait, and the request it waited for is the one most likely to have changed them. Two requests completing at the same offset both passed, so the completion handler ran twice for one upload; and an append that was never told a length ran past one declared while it waited, since nothing it held said where to stop. An append that waited out a completion is now the 409 it would have been had it arrived a moment later, and one that waited out a length is held to it.
-
The answer a completed upload was given is replayed with its
Location. A handler that moves the bytes somewhere and answers303was replayed as a303with nowhere to go, which is worse than not replaying it at all. The stored answer now carries the field, and is written in a layout that says which one it is, so an answer a previous build wrote still reads. -
A send on a WebSocket whose connection has ended is refused before it is written. The engine gives a released slot to the next connection, and the check the send made was of the slot rather than of the socket, so a handler still holding an ended socket -- a room or a broadcast list holding everyone's -- wrote its message to whoever had taken the slot. One connection's message reached another connection's client. The send now reads the socket's own state, and throws
WebSocketError.closedas it does for a socket closed while the handler runs. -
A
multipart/form-dataparameter keeps a;inside its quotes. RFC 2183 givesfilenamea quoted-string, sofilename="report;2026.txt"is a name a browser may send; the split into parameters was made on every;, which cut that name to"reportand read the rest as a parameter of its own. The split is made outside quotes now, and a quoted value's\escapes are undone, sofilename="say \"hi\".txt"arrives as the name it says. -
RedisRefreshTokenStore.createFamilyfails a login whose family the subject index did not take. The commands go in one pipeline, which answers each on its own and throws for none, and the replies were discarded: aSADDthe server refused -- an ACL without it, anOOM-- left the family good and the subject's index without it.revokeSubjectwalks that index, so signing out everywhere reported success and left the session running. The replies are read, and a refused or empty index fails the login before a token is issued. -
A
multipart/form-datapart ends at a boundary delimiter, not at bytes that merely look like one. RFC 2046 gives the delimiter a line of its own, and the parse did not ask for that framing: a file holding--boundary--mid-line was cut there and the rest of it thrown away, silently and with a 200. An occurrence that does not open and close a line is content. One that does open and close a line is the same bytes in the same place as a real delimiter and still ends the part, which is why RFC 2046 makes choosing a boundary absent from the content the sender's job. -
trailingSlash(.redirect)refuses a path whose trimmed form would begin/\as it already refused//. A browser reads the backslash as the second slash, soLocation: /\evil.examplesent the client to another host. -
A request that waits on an HTTP/2 connect another request started is bounded by its own timeout. Joining the connect is right -- it saves a second connection to the same origin -- but the joiner was woken only when that connect finished, so a request given 30 ms sat through the 500 ms the other request was prepared to wait. The connect itself is left to run: whoever started it keeps its own patience.
-
A JWK Set that arrives holding no key the verifier can use now withdraws keys that have aged past
maxAgeSeconds, instead of being treated as a fetch that failed. A provider pulling a compromised key publishes an empty set, and that answer used to leave the old keys verifying tokens for as long as the process lived. Keys still inside their age are kept, since an unknownkidfrom any client can prompt the fetch and a provider blinking should not cost the hour the operator asked for; they are not carried past it. A fetch that genuinely fails still keeps what is in hand. -
A handler under
concurrencyLimitthat returns aStreamingBodyor anEventStreamkeeps its place until the body has been written. The producer runs after the handler returns, and the place was given back at the return, so a limit of one admitted the next request while the first was still writing -- and a streamed body is the long-running work such a limit exists to bound. A body written inline withresponse.stream()always did hold its place; the two now behave alike. -
JSONReader.decode, the public whole-document entry point, proves its input is JSON before reading it. The reader is written for a document the coder has already scanned -- it steps over a separator where it finds one rather than requiring one -- so calling it directly could return a value out of bytes that were not JSON:[1 2,]read back as[1, 2]. A request body is not scanned twice; the fast path takes an entry point that skips it. -
A directory redirect keeps exactly one leading slash. Under
--static-dir /=DIR, a request for//namewalks to a local directory calledname-- the empty segment is skipped -- and the 301 copied the path in as it arrived, makingLocation: //name/: a protocol-relative URL, which a browser reads as another host rather than a path. Backslashes are stripped with the slashes, because a browser reads one as a separator.scripts/static-test.shalso covers a route at the root, which nothing exercised before. -
@JSONand@PostgresRowrefuse aCodingKeysthat renames or omits a member, and@JSONrefuses anencode(to:)written by hand, instead of ignoring them. One inside a#ifis refused in every branch, since a macro is expanded before the branch is chosen. Both attributes take precedence overCodable, so adding one to a type that had either used to change what the type sent -- a renamed key reverting to the member name, an omitted member starting to be published, a renamed column quietly not being found. The promise is that a type keeps itsCodableconformance and cannot tell the difference; an attribute that cannot keep it now says so at compile time. -
The HTTP/2 client no longer trusts what a response says about itself. A
:statusof many digits was accumulated before it was counted, so a peer could overflow the arithmetic and trap the worker, taking every other request on it; a head is now held tomaxHeadBytesboth as it assembles across CONTINUATION and as it decodes, which is where HPACK can turn a small block into a large list; each stream keeps its ownmaxBodyBytesrather than whichever request happened to be reading the shared connection; and a body shorter or longer than theContent-Lengththe head declared is refused instead of being reported as a whole response, as HTTP/1.1 already did. -
DEPLOYMENT.mdalso covers the-benchmarker submission: that it goes toweb-frameworksrather thanwebsite, the three-route contract, what a Swift entry contains, and that their CI builds and route-checks but never benchmarks.benchmarks/web-frameworks/swift/garudais the entry itself, built against the published tag and route-checked. -
benchmarks/ntexadds an ntex arm, written here because the suite has none, andbenchmarks/fw-arms.shruns the framework comparison with each server in its own invocation and the arms rotated. It now stops at the first arm that fails to start, rather than finishing a rotation that is missing one: such a sweep runs faster than a working one, which reads like progress. -
The README opens with two charts: what Garuda speaks and what it does not (
scripts/feature-chart.py), and the six servers' throughput at 256 connections (benchmarks/chart.py, drawn from the sweep log kept inbenchmarks/results/, so the picture cannot drift from the figures). BENCHMARKS.md gains the rotated run behind the second, with every round.
Documentation only. Sources/ and Package.swift are byte-identical to
1.0.0, so the library and the binaries are unchanged; this release exists
because 1.0.0 shipped documentation written before it was released. It told a
reader that nothing had been released, to depend on branch: "main", and to
install Swift 6.1 where the manifest requires 6.2 -- which fails the build.
DEPLOYMENT.mdsays how Garuda is published: what the Swift Package Index needs, how a release reaches it, what its compatibility matrix will and will not show, and the release checklist..spi.ymlasks the index to build DocC for the four library targets.- The kernel-TLS costs quoted in 1.0.0's notes are withdrawn, and that entry now says so. A rotated seven-round re-measurement put the difference at 1 MiB and 16 MiB inside run-to-run variation; no magnitude replaces the 8% and 14% that were there. What the same run does establish is that static files are 23 to 45% faster on BoringSSL than on OpenSSL without kernel TLS.
- The documentation says what 1.0 made true. It had claimed nothing was
released, told readers to depend on
branch: "main", asked for Swift 6.1 where the manifest requires 6.2, and described TLS as OpenSSL with working kernel TLS. TRANSPORT.md also contradicted itself on whether HTTP/3 selects a certificate by SNI -- it does. COMPATIBILITY.md is rewritten for a released project, the kernel TLS sections say what is now true in a fraction of the space, and the test counts match a verified run. - The greedy BIO is ruled out as the cause of HTTP/2's 3% deficit against
OpenSSL. Measured one binary against itself with aviancore's new
AVIAN_NO_GREEDY=1, five rotated rounds with the first discarded: switching the BIO off movesh2from 78,961 to 77,967 requests a second and CPU a request from 38.5 to 40.0, the wrong direction for the hypothesis. The cause of the deficit is unknown and nothing is queued to test. The same run corrects the BIO's own worth to about 1% onuser, against the nine points implied by the figures either side of when it landed; BENCHMARKS.md marks that attribution unconfirmed. scripts/resumption-test.shchecks that a TLS session ticket shortens the next connection, over TLS 1.3 and TLS 1.2, and that a resumed connection still serves its request -- a server can resume a session and then fail on it. A connection offering no ticket must report a full handshake, which is what separates real resumption from a server that claims it for everything.
Applicationregisters handlers by method and pattern: literal,:paramand trailing*restsegments, at most 8 parameters, compiled into a byte trie. HEAD falls back to GET. A path routed under other methods is answered 405 withAllow.app.run()parses the command line and runs the supervisor.app.run(configuration:)takes aServerConfig, checked the same way.onWorkerStartandonWorkerShutdownhooks run in each worker.app.group("/api") { … }mounts routes under a prefix, and groups nest.--root-pathis the mount every route is matched within.Routerholds routes, middleware, groups, deadlines and fallbacks built on their own.app.nest("/users", router)mounts one under a prefix andapp.merge(router)registers it where the call is, inside the open group, whose middleware runs in front of the router's. Routers nest, and one router can be mounted twice. Every registration API, typed routes, WebSockets, WebTransport and resumable uploads included, works on both.app.fallback { request, response in … }answers requests no route matches, in place of 404, with the middleware of its scope. Inside a group or a nested router it answers only under that prefix, and the most specific scope wins. A path routed under other methods is still 405.app.testserves the application from a worker in the test process over a socket pair:try app.test.get("/user/42")returns the status, headers and body. A request that times out says on the log where it stood: what was sent and received, the request's state and what it was waiting on, and whether the loop kept turning.- The
garudabinary serves the-benchmarker's contract through this API.garuda-conformanceholds the routes the end-to-end suites need.
- The JSON coder writes and reads the standard library's scalars directly
when they arrive through a generic call, which is how
ArrayandOptionalhand over every element: a[String]no longer costs a path, an encoder and a boxed container per string. The writer's own state is exempt from the runtime's exclusivity checks, and a JSON answer no longer copies the whole worker to ask whether a content type was set. Decoding a small body went from 2.6 to 1.8 µs and encoding one from 1.8 to 1.1 µs; the JSON workload inbenchmarks/workloads.shwent from 88,995 to 126,569 requests a second on the bench box, alongside axum's 124,704. - The JSON coder builds a value's coding path only when something reads it:
an error, or a
Codableconformance that looks. It was an array for every value in every document, and for an array element a number formatted as a string too. A key is found by comparing bytes rather than by making a string of every member name passed on the way, an integer's type name is only formatted for an error, integers are written without a heap allocation, and a string with nothing to escape is copied whole. Errors name the same paths as before, and there are tests for the nested ones. RequestandResponseare~Copyable. The request lends its bytes to closures asSpans (withPath,withHeader,withBodyand others), and the compiler refuses a handler that lets one escape.path,header(_:),bodyand the rest return owned copies.request[context: Key.self]holds typed values for the rest of the request.- Every response goes through one path. It frames 204, 304 and HEAD, adds the
server's headers unless the handler set its own, and holds the body to a
declared
Content-Length. A handler that throws or never answers gets a 500. HTTPStatusnames statuses and still takes an integer literal.send(json:),send(text:),send(html:),send(bytes:contentType:)andredirect(to:status:)set the content type they imply.Digest.sha256(_:)hashes bytes or a file,SHA256Digesthashes what arrives a piece at a time, andDigest.field(_:)andDigest.sha256(field:)write and read thesha-256=:...:of RFC 9530'sContent-DigestandRepr-Digest. A field naming an algorithm this does not check reads as nil, so a caller sees "nothing to check" rather than a check that passed.JSONCoderencodes and decodesEncodableandDecodabletypes without Foundation. Decoding reads only the keys a type asks for. Nesting is bounded at 64, a number that does not fit its type is an error, and the coder is fuzzed.
-
A handler declares extractors and returns an answer:
app.get("/person/:id") { (id: Path<Int>) in JSON(person(id.value)) }.Path<T>,Query<T>,Body<T>(JSON),Form<T>,Multipart,State<T>andContext<Key>are built in, andRequestExtractormakes more. A value that will not decode is a 400 saying what was wrong. A body of the wrong kind is a 415. -
Handlers return
JSON,HTML,Text,Bytes,Redirect, aString, anHTTPStatus, or an Optional whose nil is a 404. -
A thrown
ResponseErroris the response:HTTPError(.conflict, "the name is taken")answers 409 with a JSON body. Other errors are a 500 and a log line. -
Rules beyond a type's shape are a
Validatedconformance, andBody<T>,Query<T>andForm<T>hold whatever they decode to them before the handler runs.validate(_ check: inout Validation)states the rules field by field --check.range("quantity", quantity, atLeast: 1),notEmpty,length,email,oneOf,count,nestedandeachfor values inside this one,requirefor anything else -- and a field named "" is a rule about the value as a whole. Every broken rule is answered at once, as 422: the type was right and what it asks for is not allowed, where 400 stays a body that is not the type at all.try value.validated()checks a value that came from a queue or a file rather than a request. -
A type can read and write its own JSON instead of going through
Codable, by conforming toJSONReadableandJSONWritableand walking the bytes with aJSONReaderand aJSONOutput. Nothing at a call site changes: the coder asks once per type whether it has them and takes whichever path the type has, soBody<Order>andJSON(receipt)are written the same either way, and a type that has none is decoded and encoded exactly as before. What a type with them sends is byte for byte what Codable sent, down to how aDoubleis written and which members are left out when they are nil. Codable decides everything at run time -- containers are existentials, and the metadata for each is fetched again for every document -- which measured 3.8 microseconds to decode a small body and 2.5 to encode a small answer, on a request that is otherwise 8. Reading and writing them directly, thejsonworkload's request went from 15.6 to 13.2 microseconds on one worker, 64,000 requests a second to 75,900. -
@PostgresRowwrites the conformance that lets a type read itself out of a result row instead of going throughCodable, the same bargain@JSONmakes for a request body:import GarudaSQL @PostgresRow struct Item: Codable { let id: Int32 let name: String let price: Int32 }
Nothing at the call site changes: the same
first(Item.self, "select ..."). A property is read from the column of its own name, and what that column decodes to is whatCodabledecoded it to -- the same binary and text paths, the same errors, a NULL into a non-optional property refused rather than read as zero. An optional property keeps meaning whatdecodeIfPresentmeant, so a column the result leaves out is nil rather than an error. The pool asks once per result whether the type wants this, and a type without the attribute is decoded exactly as before. It comes from the same plugin as@JSON, in its own product,GarudaSQL.What it is worth, measured: on the
dbworkload -- one row of three columns -- nothing that can be told from noise, because whatCodablecosts there is a fraction of a percent of a request that is mostly a round trip to PostgreSQL. The containers do leave the profile. Where it should pay is a result of many rows, where the container is built and torn down for each one; that is not measured here. -
A result's rows are decoded without asking the runtime the same question once a row. What a result is -- a
[UInt8]scalar, one array column asked for as a list, or properties by name -- depends on the type asked for and on the columns, never on which row, so it is worked out once for the result and used for every row of it. Asking whether a type conforms costs about half a microsecond whether the answer is yes or no, and a thousand rows asked it a thousand times; the answer is now remembered per type, as it already is for validation rules. -
HTTPS costs less per request: aviancore 0.6.7 clears OpenSSL's error queue on the way out of a failure rather than before every read and write, which profiling put at 1.37% of the server's whole CPU -- more than three times what encrypting the data cost. One pinned worker serving HTTPS went from about 76,600 requests a second to 79,100 over three alternating pairs.
-
@JSONwrites those two conformances, so a type opts out ofCodable's cost with one line rather than two methods:import GarudaJSON @JSON struct Order: Codable { let id: Int let name: String let tags: [String] }
It reads the stored members off the declaration and writes out the reading and the writing longhand, in the order the members are declared, under their own names. What it generates is what Codable sent, byte for byte: a missing key is an error unless the member is optional, a nil member is left out of the object rather than written as null (a nil inside an array is still null), and a
letthat already holds a value is written and not read, which is what Codable does with one too.static, computed andlazymembers are no more part of the JSON than they are for Codable. It comes as its own module,GarudaJSON, because it is the only part of Garuda that needs swift-syntax: an application that never imports it never builds the plugin. Where the macro cannot be sure of the bytes -- a dictionary or a set member, whose order nothing fixes; a generic struct; an initializer in the body, which would have replaced the memberwise one it reads through -- it stops the build and says what to write instead, rather than falling back to Codable quietly and leaving someone wondering why the type is slow. -
Whether a decoded type has rules is asked once for that type rather than once a request. Asking the runtime --
value as? any Validated-- costs about half a microsecond whether the answer is yes or no, andBody<T>,Query<T>andForm<T>asked it every time they decoded; the answer cannot change while the process runs. A type with rules is still found by its dynamic type, so a subclass with rules of its own is not missed. Thejsonworkload went from 62,400 requests a second on one worker to 66,000, which is what leaving the check out altogether measures. -
An error that knows which fields are at fault says so: an answer carries
"fields":[{"field":"email","message":"must look like an email address"}]besideerror, so a form can put every message where it belongs. Validation fills it, and so do JSON and query decoding, from the path they already report --items[0].skufor a body, the item's name for a query string. An error with nothing to add there answers with the one key it always did.ResponseErrorhas afieldsrequirement with an empty default, so an error of your own can fill it too. -
A route whose input has rules documents the 422 it can answer, and the body it carries, in the OpenAPI document.
-
app.state { worker in … }builds a value once in each worker, after the fork and before it reports ready. A factory that throws stops that worker's start-up. An optionalshutdown:tears the value down. Registering the same type twice is refused: it used to run both factories in every worker, so one value was reachable by nobody and shut down never, while the other was shut down twice. -
app.documentProblems(info)says what the OpenAPI document cannot promise, for a team generating clients from it: a schema read from a type whose decoding stopped early, which is a schema that may be missing whatever came after, and two routes sharing anoperationID. Reading a type used to discard the failure, so an incomplete schema was silent. -
The test client takes
json:onpost,put,patchandrequest, encoding with the coder the server decodes with and setting the content type unless the test sets its own, and haspatchand theStringforms ofputandpatchit was missing. -
app.problems()says what about the routes cannot work, before anything is served: a handler taking morePathextractors than its pattern has parameters, and a handler asking for aState<T>noapp.stateregistered. Both used to be a 500 for whoever sent the first request that reached them.run()prints every problem and exits 2 rather than serving, and a test can ask for them without starting anything --#expect(app.problems().isEmpty), which the starter does. An optional extractor asks for nothing, sinceE?is nil whereEwould have refused, and a middleware'srequest.state(T.self)is a call rather than a declaration and cannot be checked this way. -
Multipartgives each part's name, filename, content type and bytes, up to 1,000 parts.
- A closure that awaits registers as an async handler under the same names.
app.onAsyncis the raw form. Handlers run on long-lived tasks reused from a pool on each worker's own executor, on the worker's thread. After warm-up an async request allocates nothing. app.deadline(milliseconds:)answers 504 for a request still unanswered at its deadline, and unwinds a handler waiting on the engine. It bounds waiting, not computing.- A closed connection or reset stream cancels a handler waiting on the engine.
- A request body over its limit is answered 413 on HTTP/2 too, then its stream
is reset with NO_ERROR, where it used to get only a reset. A declared
Content-Lengthover the limit is refused before the body is read, as on HTTP/1.1. An answer to a request that has gone is dropped rather than written into the next request on the slot. - Handlers registered from
main.swifttakesendingclosures, so they run on the worker rather than being isolated to the main actor. - A typed async route takes the extractors that do not await synchronously, before calling the handler, where each one used to be an async call of its own: a frame and a task switch per extractor, per request.
try await blocking { … }runs a call that would block the worker -- a C library, disk I/O, a long computation -- on a thread of the worker's blocking pool, and resumes the handler on the worker with what it returns or throws. Threads start as work arrives, up to--blocking-threads(16) per worker; work beyond them waits, up to--blocking-queue(1024), and past that is refused withBlockingPoolError.full, a 503. The closure is@Sendable, andPath,QueryandBodyareSendablewhen their values are.
app.useruns middleware before every route in its scope, whether it is called before or after the routes. It returns nil to carry on or an answer to send instead, and may be async.response.onSend { outgoing in … }sees the final response, whoever answered, and can change its status, headers and body. Hooks run last-added first. They do not run for static files, cache hits, or answers given before a route is chosen.app.cors(CORSPolicy(origins: […]))sets the CORS policy of a scope: the application, a group or a router, the innermost winning. Origins are any, a list, or a closure's decision; methods and request headers are a list or what the path is routed for and the preflight asks for; exposed headers, credentials and max age are set with it. Any origin with credentials is refused when the policy is made.- The policy answers a preflight 204 whether or not the path has an OPTIONS
route, so a path routed only for GET no longer answers a preflight 405. It
runs in front of the scope's middleware, so a preflight skips
authentication, and a refusal or thrown error from an allowed origin carries
Access-Control-Allow-Origin.
Varynames what the answer depends on. app.authenticate(bearer: Key.self) { token in … }andapp.authenticate(basic: Key.self, realm:) { username, password in … }read the Authorization header, keep what the closure returns underKeyin the request's context, and answer 401 with the WWW-Authenticate challenge for a missing, malformed or refused one. The closure may be async, and a throw answers as a handler's does.Policyis a named rule about who may reach a route, andapp.authorize(CurrentUser.self, .admin)requires it of every route in the scope, after theauthenticatethat says whose request it is.and,orandaboutcombine rules and their names; a policy is a plain(Value) -> Bool, so a test calls it without a request. A rule that does not hold is 403 saying what would have been enough --{"error":"this route needs an administrator or the billing role"}-- and a request with nobody under the key is 401 instead, since signing in may be the answer.authorize(_:needs:_:)writes a rule where it is used, and itsasyncform may ask a database;authorize(jwt:_:)reads the claimsauthenticate(jwt:)checked, withPolicy.scope("orders:write")for claims conforming toScopedClaims. A handler with a rule about one row throwsAuthorizationError(needs:)for the same answer.app.authenticate(jwt: Claims.self)guards a scope with the verifierapp.jwtVerifierregistered, found in the worker rather than passed in, so aRouterbuilt on its own can require a token without being handed the keys.- A scope says what it can answer in the OpenAPI document:
authenticateadds its 401 and its security scheme to every route in its scope, andauthorizeits 403.app.describeRoutes { operation in … }does the same for a middleware of your own. A note applies to every route of the scope wherever in it the call is, asusedoes, and leaves alone what a route said about the same status itself. BearerTokenandBasicCredentialsextract the same credentials in a handler, andconstantTimeEqualscompares a secret in time that does not depend on where it differs.app.authenticate(bearer:state:)andapp.authenticate(basic:realm:state:)hand the check whatapp.statebuilt for a type in the worker, such as the database sessions are kept in.request.state(T.self)reads it in any middleware.Passwords.hashstores a password as$pbkdf2-sha256$i=600000$salt$hash, PBKDF2-HMAC-SHA256 with a random salt, computed on the blocking pool.Passwords.verifycompares in constant time, andPasswords.needsRehashsays when a stored hash used fewer iterations than asked for now.Tokens.random()is 32 random bytes in base64url, andTokens.digesttheir SHA-256 in hex, to store and look up instead of the token.request.logwrites the application log at debug, info, warning or error, held to--log-level. Each line carries the request's method, path, request ID and trace context, and fields given as["order": "\(id)", "cents": 1250].log.with([…])adds fields to every line, and a logger outlives anawait.AppLogwrites the same lines outside a request.--log-format jsonwrites those lines as one JSON object each, and sets the access log's format unless--access-log-formatis given. A line is one write of at most 4096 bytes: a longer one is cut and markedtruncated. Nothing in a message or field can start a new line.app.onResponse { done in … }is called with every request once its response head is settled: a route's answer, a 404, a 429, a static file, a cache hit or a WebSocket's 101.done.routeis the matched pattern, safe as a metric label.done.failuresays why a route answered 5xx on its own account: a throw, a handler that returned without answering, or its deadline.app.tracing()traces through swift-distributed-tracing, with the tracerInstrumentationSystemholds in each worker;app.tracing { worker in … }makes one per worker instead. Each request is a server span named for its method and route pattern, continued from the trace its headers carry, and ended when its response is answered -- a streamed response's with its last byte. A handler runs with that span asServiceContext.current, sync or async, so the spans it starts are its children. Beneath it: a client span for eachrequest.clientcall, which carries the trace on in its headers and, forclient.stream, ends with the body; a span per PostgreSQL statement, the wait for a pooled connection included, with its SQLSTATE when refused; and a span per Redis round trip, named for its command or PIPELINE, with the error code a refused command gets. Attributes follow OpenTelemetry's HTTP and database conventions; bound values, Redis arguments and query-string values are never recorded.app.maxBodySize(bytes) { … }holds the bodies of the routes registered inside it tobytesinstead of--max-body, larger or smaller. A declared length past it is 413 before the body is read, and a chunked, HTTP/2 or HTTP/3 body is refused as it grows past it. Nested scopes apply the innermost, and a streaming route keeps its ownmaxBodySize:.app.concurrencyLimit(max) { … }lets at mostmaxhandlers of its routes run at once in each worker process, and answers one more 503 without running it. Middleware is not counted, so a request authentication refuses takes no place. A streamed response holds its place until it is written, a request that goes away gives its place back, and nested limits all apply. Both work on aRoutertoo.request.cookie(name),request.cookiesand theCookiesextractor read every Cookie header, HTTP/2's one-per-cookie included, and take the first value of a repeated name.response.setCookie(Cookie(name, value))adds a Set-Cookie header withPath=/,HttpOnly,SameSite=Lax, andSecurewhen the request came over HTTPS, directly or through a trusted proxy. Max-Age, Expires, Domain and Partitioned are there to set.SameSite=Noneis alwaysSecure. A name that is not a token, a value outside RFC 6265's cookie characters, or a path or domain holding;is refused rather than sent.response.removeCookie(name)expires one.response.setCookie(cookie, key: key, .signed)signs the value with HMAC-SHA256, and.encryptedseals it with AES-256-GCM. The cookie's name is bound in, and either way the value may be any string.request.cookie(name, key: key, .signed)returns nil for a cookie that was changed, moved from another name, or made with another secret.CookieKey(secret:previous:)takes a secret of at least 32 bytes and the ones it replaced, derives separate signing and encryption keys with HKDF, and reads cookies made with any of them.CookieKey.randomSecret()makes one.app.sessions(store:)gives every route in its scope aSession, which a handler takes as an extractor. A request whose cookie names a live session has it loaded before the handler runs, and its idle timeout (idleTimeoutSeconds, a day by default) starts again. A request without one costs the store nothing.session["key"]andsession.value(T.self, "key")read it.session.set,session.set(_:json:)andsession.update { … }write the change to the store before they return, so the client's next request sees it. The first change makes the ID, 32 random bytes, and the cookie that carries it goes out with the response: change the session before answering.- An ID the server did not make is never used: a cookie naming no live session
is ignored, and the first change makes a fresh ID.
session.renew()moves the data to a new ID, for a login.session.destroy(), or emptying the session, deletes it and expires the cookie. SessionConfigurationnames the cookie (idby default) and sets its path, domain, SameSite and the rest. With a Max-Age the cookie is sent again each time the session loads, so it lasts as long as the session does.- Stores:
MemorySessionStore, for--workers 1and tests, since each worker is a process with its own memory.RedisSessionStorekeeps each session as JSON with a PX expiry, extended with GETEX (Redis 6.2 or later).SQLiteSessionStorekeeps a table (createTable(), orschemain a migration) and moves a session's expiry once half of the timeout has gone, so reads rarely write.deleteExpired()clears expired rows. A store of your own implementsSessionStore's load, save and delete. app.csrfProtection(trustedOrigins:)refuses cross-site request forgery in its scope without tokens. A request other than GET, HEAD or OPTIONS is answered 403 when Sec-Fetch-Site sayscross-siteorsame-site, or, from a browser that sends no Sec-Fetch-Site, when Origin names a host other than the request's Host or:authority(:80and:443aside) or isnull.same-origin,none, and a request with neither header, which no browser page made, pass. An origin intrustedOriginspasses whatever the headers say. This is the check Go's net/http added in 1.25.app.securityHeaders(SecurityHeaders())adds, to every answer in its scope including errors and middleware refusals,X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: no-referrer, andsame-originCross-Origin-Opener-Policy and Cross-Origin-Resource-Policy.Strict-Transport-Security: max-age=31536000; includeSubDomainsgoes only on answers to HTTPS requests, a trusted proxy's included. Each is a field to change or set to nil;contentSecurityPolicyandpermissionsPolicyare there to fill. A header the response already carries is not replaced.app.trailingSlash(.redirect)answers a path whose trailing slashes no route has, such as/users/when/usersis routed for any method, with 308 and the path without them, query kept..ignoreserves it from that route instead, a streaming body's own limit included..strict, the default, leaves routes matching exactly. A path that matches as it came is never touched, so this costs a matched request nothing, and a redirect whose Location would start with//is not sent.app.requestDecompression()decodes the request bodies of its scope whose Content-Encoding is gzip, deflate, br or zstd, several stacked included, before the middleware after it and the handler read them. The decoded size is held toapp.maxBodySizeor--max-bodyas it grows: 413 past it, 400 for bytes the coding did not make, and 415 with Accept-Encoding for a coding that cannot be decoded. A streaming route's body is left as it came.app.allowedHosts(_:)answers 400 to a request in its scope whose Host or:authority, port aside, is not listed, or that has none. An entry is a host, an IP literal ([::1]for IPv6), or*.example.comfor the names under a domain, which does not include the domain itself.app.addressFilter(allow:deny:)answers 403 to a client whose address is ondeny, or is not on a non-emptyallow; deny is read first. Entries are addresses, CIDR blocks,unixand*. The address isrequest.remoteAddress, a proxy's forwarded one when--forwarded-allow-ipstrusts the peer, and an IPv4 client on an IPv6 socket (::ffff:a.b.c.d) matches as IPv4.app.openAPI(OpenAPIInfo(...), path: "/openapi.json")serves an OpenAPI 3.1 document of every route, built once when the application compiles, andapp.openAPIDocument(_:)andapp.openAPIJSON(_:)return it for a build step.app.swaggerUI(path: "/docs")serves Swagger UI, loaded from jsDelivr, reading the document by a relative URL so it works under--root-path.- A typed route describes itself:
Path<T>is a typed path parameter,Query<T>a query parameter per field,Body<T>a JSON request body,Form<T>andMultipartform bodies,BearerTokenandBasicCredentialssecurity schemes with a 401,LastEventIDa header, andJSON<T>,String,HTML,Bytes,EventStreamand an optional (with its 404) the response. A raw route is listed with its path parameters; a WebSocket route with 101. Routes in groups and nested routers carry their full paths. - Schemas come from decoding each
Decodabletype once with a decoder that records what it is asked: properties, which are required, integers and numbers with their formats,UUIDandTimestampasuuidanddate-timestrings, arrays, sets, string-keyed dictionaries, and enums withCaseIterableraw values. A named type is a component under$ref, and a type that contains itself refers to itself.OpenAPISchemaDescribinglets a type write its own. - Route methods (
get,post,on,webSocketand the rest) now return the route'sOpenAPIOperation, discardable, withsummary,description,tags,operationID,deprecated,hidden,response(with or without a JSON type) andsecurity. An extractor or response type of your own conforms toOpenAPIExtractorDescribingorOpenAPIResponseDescribing.RouteBuildergains adocument(_:)requirement. AsyncRequestExtractoris an extractor that awaits: a database, a session store, another service. Async handlers await it in order with the others, and a request that ended while it waited stops before the extractors after it read the request. A synchronous handler, WebSocket or WebTransport route that takes one stops the program when it is registered, not on a request.- An optional extractor,
E?, is nil whereEwould have refused the request, andResult<E, any Error>gives the handler the error to answer as it likes. Either way the path parametersEwould have taken are left for the next extractor, and the OpenAPI document describesE. - The auth example adds
SignedInUser, an async extractor over its sessions table, andGET /whoami, which takes it asSignedInUser?. --metrics-portalso reports each route:garuda_route_requests_totalby method, route pattern and status class, and agaruda_route_request_duration_secondshistogram by method and pattern. The label is the registered pattern (/users/:id), so series are bounded by the number of routes; unmatched requests and fallbacks have a series each. Counted in shared memory mapped before the fork, a row per worker, with no locks; nothing is counted without--metrics-port.--spa-fallback PREFIX=FILEserves a single-page application's page for a browser navigation under PREFIX that no static file, route, 405 or scope fallback answered:GETorHEADwhoseAcceptnamestext/html. Other requests, such as a missing asset or an API call, keep their 404. The file goes out as a static file does, with its ETag and 304.- JSON Web Tokens:
JWTKeyssigns and verifies HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA, with keys from HMAC secrets, PEM (public keys, certificates, private keys), JWK, or generated. Verifying checks the signature,expandnbfwith leeway, andissandaudwhen asked. It refusesalg: none,crit, tokens over 16 KiB, RSA keys under 2048 bits and short HMAC secrets. Each key is bound to one algorithm, which rules out HS256 signed with an RSA public key. Signatures use the system's libcrypto through a small C target; tokens interoperate with PyJWT in both directions for every algorithm. JWT<Claims>extracts a verified bearer token with its claims decoded, from whatapp.jwtVerifierregistered.app.authenticate(jwt:verifier:)requires one of every request in a scope. Failures are 401 withWWW-Authenticate: Bearer error="invalid_token".keys.publicJWKSis the public key set to publish.JWKSVerifier(url:validation:)verifies tokens from an identity provider against its JWK Set. The set is fetched on first use and kept formaxAgeSeconds. An unknownkidfetches it again, and requests during a fetch share it. Fetches happen at most once perminimumRefetchSeconds, and keys in hand survive a failed fetch; with none, the answer is 503. Only asymmetric keys foralgorithmsare trusted:octanduse: enckeys are skipped. Google's and Microsoft's sets load.TokenIssuerissues short-lived JWT access tokens with rotating refresh tokens.issue(subject:)answers a login with aTokenPair, encoded as an OAuth 2.0 token response, andrefresh(_:)spends a refresh token for a new pair in the same family, with claims rebuilt by your closure. A spent token presented again revokes its family, the reuse detection RFC 9700 recommends, except withinreuseGraceSeconds, where it is only refused. The family is checked again once the new pair is built, so a logout that lands while the claims are being made does not hand back a working access token.- Refresh tokens are 32 random bytes stored as SHA-256 digests. They expire
after
refreshTokenSecondsunused, and families aftermaximumSessionSeconds.revoke(_:)logs out one family andrevokeAll(subject:)every family of a user. A refused refresh is 400invalid_grant. - Stores:
MemoryRefreshTokenStore,RedisRefreshTokenStore(spending withSET NX), andPostgresRefreshTokenStoreandSQLiteRefreshTokenStore(spending with a conditionalUPDATE), each atomic across workers. The memory store locks its state, so it is safe to reach from the blocking pool or a thread of your own. The Redis store's index of a subject's families only ever has its expiry pushed out, so two logins arriving at once cannot leaverevokeAll(subject:)blind to a family that is still good. - A fifth example, the starter (
swift run starter, Examples/STARTER.md): a whole application rather than one feature. PostgreSQL, accounts with JWT access tokens and rotating refresh tokens, an append-only migration list run both bystarter migrateand by every worker at start-up, configuration read from the environment and checked once with every problem reported at once, cursor paging, ownership answered as 404, OpenAPI with Swagger UI, health and readiness, tests throughapp.testagainst a real database, and a Dockerfile and systemd unit in Examples/deploy/. String.trimmingWhitespace()is public: validating a field someone typed starts with it, and Swift without Foundation has no such method.PostgresClientError.isConstraintViolationsays whether the server refused a statement for breaking a constraint, asSQLiteClientErroralready did.- PostgreSQL types for what had none:
PostgresDate(date),PostgresTime(time),PostgresInterval(interval, months, days and microseconds kept apart),PostgresNumeric(numeric, exact, kept as its digits) andPostgresJSON<T>(jsonandjsonb, decoded into your type). Each reads the binary form and text as a fallback, and binds as text the server accepts whatever itsDateStyleorIntervalStyleis. The interval parser reads every style PostgreSQL writes, ISO-8601, the default andsql_standard.PostgresClientError.isConstraintViolationjoinssqlState. - PostgreSQL arrays are Swift lists:
[String]fortext[],[Int]forint[],[String?]where NULLs are among the elements, and a list of any other type the driver reads,[[UInt8]]forbytea[]included. A list binds as an array literal, so$1 = any(tags)takes a list as readily as a column gives one, and every element -- a comma, a quote, a brace, an empty string, the word NULL -- survives the round trip.[UInt8]is still abytearather than a list of numbers. Arrays of more than one dimension are refused rather than flattened. One array column asked for as a list --pool.first([String].self, "select tags from notes where id = $1", id)-- is that column rather than a row of columns, as bytes already were.PostgresType.elementType(of:)andPostgresArrayTextare public for a driver of your own. COPYin both directions:pool.copyIn(sql, rows:)loads rows without a round trip each,pool.copyIn(sql) { ... }streams whatever bytes the statement's format asks for, andpool.copyOut(sql) { chunk in ... }reads a table out a chunk at a time, withcopyOutRowsfor the text format's rows.PostgresCopyTextwrites and reads that format -- tabs,\Nfor a null, and a backslash before anything that would be punctuation. AcopyInwhose closure throws sendsCopyFail, so the server keeps none of the load and the connection stays usable; acopyOutwhose closure throws closes the connection, since a copy out cannot be stopped politely. Both are onPostgresTransactionas well, where a load belongs to the transaction.- Composite types are
PostgresRecord: the fields in order, since the wire carries no names, read from(a,b,"c,d")and bound back as one. An enum needs nothing of its own -- it arrives as its label, so a Swift enum backed byStringreads it -- which is now tested rather than assumed. - PostgreSQL over a unix socket:
PostgresConfiguration(unixSocketPath:user:), or?host=/var/run/postgresqlin a URL, or the path percent-encoded where the host goes. The path may name the socket or the directory holding it, as libpq's does. TLS is off for a socket, and asking for it anyway istlsUnavailablerather than a quiet fallback. - SASLprep's mapping step is applied to a password before SCRAM, as the server applies it before storing a verifier: a non-ASCII space becomes a space and a soft hyphen goes. A password that was pasted with a non-breaking space in it now authenticates. NFKC normalisation is still not done, and CONNECTORS.md says so.
- The macOS CI job prints its descriptor limit and runs the unit tests a
second time one suite at a time. That split the four failures it alone
has: three pass serially, so those interfere with one another through
something the suites share, and
aClosedDatabaseRefusesStatementsfails either way, so that one is the platform. It is the only test whose database is never written to, and a read-only connection cannot create a WAL database's -shm file, so it now says which of those files the writer left behind. The SQLite tests also no longer share a plainIntbetween suites that run beside one another. - The broadcast suite waits until an event stream is demonstrably subscribed before it publishes to it, rather than the instant its head arrives: the head of a stream goes out before its handler has subscribed, so the two orders are not the same. Where that check has passed and the stream is still sent nothing, the suite now says which worker the stream landed on and which workers the WebSockets landed on. A CI runner has failed here once, and the cause is not yet known; this is what will name it.
- The test that an idle outbound connection is swept away no longer sets the idle time down to a millisecond before it has checked the connection is there. A sweep landing in that gap failed the test by finding the very thing it was about to ask for, which reads as the opposite of what went wrong. A parallel run has done it.
- Two unit tests that want a second loopback address skip where the machine
has not got one, and say what to do about it, instead of failing. Linux
routes the whole of 127.0.0.0/8 to
lo; macOS configures 127.0.0.1 and nothing else. Both tests are worth keeping -- they are what proves the address a resolver returned is the address connected to, and that a certificate is checked against the address actually reached. - A handler resumed from a thread that is not its worker's no longer crashes the worker. An executor is entitled to be given a job from any thread -- the runtime resumes a task wherever it resumed whatever the task was waiting on, and for a wait the engine does not own that is a thread of its own choosing -- and the worker's executor took that as a fault to be refused loudly. Such a job now goes on a list under a lock, and a byte down a pipe the poller watches wakes the worker to run it on its own thread. A handler's code still runs only there, and the worker's own thread still enqueues without a lock or a syscall. The fault was found by CI on a macOS runner and could not be reproduced on any other machine; the test that now covers it resumes a handler from a thread of its own, which does not depend on luck.
response.cancellable { … }gives up on a wait the engine does not own. A handler suspended on a library's own continuation was not woken when its request ended -- nothing knew to wake it -- and held a handler task until that wait finished on its own. The body now runs in a task of its own, raced against the request ending, so the handler throwscancelledat once. The body is cancelled in the ordinary Swift way, which works because that task is a fresh one: the pooled task running the handler cannot be cancelled, since a task cancelled in Swift's sense stays cancelled. A typed handler asks for the same thing as aCancellationextractor.- What that does not do is stop work which ignores cancellation, so the worker
counts it, logs once past
ServerConfig.maxAbandonedWaits(256) and answers the health check 503 until it has caught up. Out of rotation rather than failing requests that have nothing to do with whatever is stuck. - Swift 6.2 builds and tests Garuda again, and the package and README say 6.2
rather than 6.1, which is where
Spanarrived. Five places handed anonisolated(unsafe)local to asendingclosure, which 6.3 allows and 6.2 does not;Unsafelysays the claim once instead. - CI checks every change instead of waiting to be started by hand. Build, unit tests and compile-fail run on each push and pull request, and so do two things that were not in CI at all: the connectors against a real PostgreSQL and a real Redis, which is what makes the opt-in suites run, and the end-to-end suites that drive the release binary over a socket. The protocol suites and the sanitizer fuzz run nightly. The Swift toolchain setup is one composite action rather than four copies.
- Two races in the tests themselves, which each failed about one full run in five: the resolver's fakes took a TCP port because a UDP one of that number was free, and the SPA fallback tests named their site directory from an unsynchronised counter.
- Every producer writing a streamed response waits while the client is behind, not only the first to find the backlog full. A second producer used to be let through on the ground that someone else was already waiting, which left it free to queue as fast as it could produce while the client read nothing -- the mark bounded one writer and nothing else. WebSocket sends had the same shape and are fixed with it. The ceiling is now the mark plus one write from each producer, which TRANSPORT.md states, since a write is queued before it is waited on and so can cross the mark by its own size.
- A Redis retry cannot repeat a write it may already have done. A failure
with the command's bytes already written is
RedisClientError.unknownOutcome, wrapping theclosedortimedOutunderneath --error.causereads it back anderror.mayHaveRunis true. A failure before the first byte throws plainly, as before, because the server never saw it.RedisClusterandRedisSentinelPooltakereplay::.readsby default, which sends only commands that read to another node,.anythingfor a cache where a second write costs nothing, and.nothing.RedisReads.only(_:)is the table behind.reads, and treats anything it does not recognise as a write. Before this, a lost reply toINCRorEXECwas a retry. - A Redis batch that fails part-way says which part:
RedisClientError.incomplete(replies:_:)carries the answer to each command that was answered and nil for each that was not, with why the rest failed inside, andmayHaveRunis true whenever anything answered ran. Three paths lost this. A connection that answered the first commands of a pipeline and then went threw onlyunknownOutcome, with the answers it had read thrown away. A cluster pipeline over several slots, whose later slot's node could not be reached, threw a plainconnect-- which says nothing ran -- after the earlier slots' writes had run, so a caller retrying on it repeated them; its slots also went in no set order, and now go in the order their first command comes. A sentinel pool whose master failed over in the middle of a batch lost what the old master had answered if the new one then failed. A transaction is never part-answered: it stays the plain error orunknownOutcome. - A cluster follows a redirect per command rather than per batch. A slot in
the middle of migrating answers the keys it still has and redirects the keys
it does not, so a pipeline comes back part answered and part redirected:
what was answered is kept and only what was refused goes again, each behind
its own
ASKING. Before this, the whole batch went again and every write in it already answered happened twice. A transaction is still treated as one thing, which is right: a command refused while it was being queued makes Redis abort all of it. - A sentinel pool retries only the part of a pipeline the old master refused.
A failover in the middle of a batch answers the commands before it and
refuses the writes after it with
READONLY; the refusal is proof they did not run, and the answers are proof the others did. - Redis Cluster:
RedisCluster(seeds:)is a pool per node and a map of which node owns which of the 16,384 slots, read withCLUSTER SLOTS. It is aRedisCommandSender, so every typed command a pool has it has too, aimed at the node that owns the key.MOVEDcorrects the map and sends the command again;ASKsendsASKINGand the command to the node taking a slot on, without touching the map;TRYAGAINandCLUSTERDOWNare waited out; a node that has gone is dropped and the map loaded from another. So a map that is behind costs a round trip, not a wrong answer.pipelinesends one write per slot and returns the replies in order;transactionandsession(for:)are one slot's, as they must be;subscribeShardedandspublishreach the shard that owns a channel.RedisSlotsandRedisKeysare public for a driver of your own. - Redis Sentinel:
RedisSentinelPool(RedisSentinelConfiguration(sentinels:master:server:))asks the sentinels where the master is instead of being told, and checks what they name withROLE-- a sentinel can be behind and name a node that has been demoted. A lost connection, or theREADONLYa demoted master answers a write with, means asking again and trying the new master.masterAddresssays where it is;refresh()asks on demand. RedisSessionStoreandRedisRefreshTokenStoretake anyRedisCommandSender, so they work on a cluster and behind sentinels as well as on one server.pipelineis part of that protocol now, andRedisValuehas anintegerbesidestring,bytesandarray.LISTENandNOTIFY:app.listen("jobs") { notification, start in ... }hears notifications in each worker, on the worker's thread with the worker's state, on a connection of its own that the pool does not count. A connection that goes is logged and made again with the same channels afterreconnectAfterseconds;whenListening:runs each time the channels start listening, which is where an application picks up what was sent while it had no listener -- the server keeps nothing for a session that is not connected. A throw from the handler is logged and the next notification is handled as usual. Underneath,pool.listen(channels)gives aPostgresListenerwithnext(timeoutMilliseconds:),listen,unlisten,channels,isOpenandprocessID, andpool.notify(channel, payload)sends one throughpg_notifyso neither the channel nor the payload is part of the statement.tx.notify(channel, payload)sends one when its transaction commits, and not at all if it rolls back. A notification that arrives in the same read as the reply to the statement that subscribed is kept for the listener rather than refused as a reply nobody asked for.app.every(interval, jitter:firstAfter:onWorker:) { start in ... }runs work on a timer in each worker: clearing what has expired, refreshing a cache. It runs on the worker's own thread with the worker's state, from the moment that worker serves until it drains, and a throw is logged and retried at the next turn.jitter(a tenth of the interval by default) keeps workers from firing on the same tick, andonWorker: 0restricts a job to one worker -- which is one process group's worker 0, not the cluster's, so work that must happen once takes a lock in the database.app.testruns the jobs too.AppEnvironmentreads an application's own settings -- where its database is, what signs its tokens, which features are on -- and collects every problem instead of failing at the first:string,intwith a range,bool,choiceover aCaseIterable,secretandsecretOrFile(which reads<NAME>_FILE, as a mounted secret arrives), andurl, whose password is held back.problem(_:)records a check of the application's own, andcheck()throwsAppEnvironmentErrorlisting all of them.modereadsAPP_ENV, anddefault: production ? nil : valueis how a setting has a default in development and is required in production.summary()prints what was read for amyapp envcommand, with secrets held back and passwords taken out of URLs. Garuda's own settings stay on the command line.app.run(arguments:)parses a list of arguments as thegarudaexecutable parses its command line, so an application with commands of its own can hand Garuda the flags that are Garuda's:starter serve -- --port 8080.app.runOnce { start in ... }runs one piece of async work against an application's state with nothing served: a migration, a backfill or a seed from the command line. A worker is built in the process with no listening socket, the work runs on its thread, and the state is torn down afterwards. It reportsRunOnceError.failed,.timedOutor.workerCouldNotBeBuilt, so a command can exit non-zero.app.prepare { start in ... }runs async start-up work in each worker -- a schema to migrate, a cache to warm -- after its state is built and before its listening socket is watched, so a connection that arrives meanwhile waits in the backlog instead of reaching a worker that is not ready.start.state(_:)gives the hook whatapp.statebuilt, andstart.indexsays which worker it is. A hook that throws or outstaystimeoutMilliseconds(30 seconds by default, per hook) stops the worker.app.testruns the hooks too.PostgresConfiguration(url:)reads apostgres://connection URL, which is how a deployment usually passes one: credentials percent-decoded, an optional port and database, IPv6 in brackets, and thesslmode,sslrootcertandconnect_timeoutparameters.sslmode=preferandalloware refused, because falling back to plaintext lets the path decide.pool.migrate(_:table:)brings a PostgreSQL schema up to date from an ordered list of migrations, each the statements it needs, applied in one transaction and counted in a version table. Workers starting together serialise on an advisory lock, and a database ahead of the build isPostgresMigrationError.unknownSchemaVersion.- COMPATIBILITY.md: what an application may depend on -- the public Swift API, the flags, what goes over the wire, the schemas Garuda writes -- and what a release may change. Before 1.0 a minor release may break what is covered; a patch release may not. Deprecations warn for a release before anything is removed, and what must be true before 1.0 is listed.
- MIDDLEWARE.md: how middleware and
onSendrun, the order to add it in, every middleware Garuda ships with its options and answers, the server flags that act as middleware, and writing middleware and extractors of your own. - EXAMPLES.md: the four runnable applications, and recipes for a first server, a JSON API, errors, routers, databases, sign-in with sessions, custom extractors, OpenAPI, server-sent events, WebSockets, testing and production flags, each compiled against the public API.
- A streamed body's writes go out together when the handler next waits,
rather than one system call each. A handler writing 64 KiB in 4 KiB pieces
made sixteen writes to the socket and woke the reader sixteen times; it now
makes one, and
benchmarks/workloads.shstreams twice as many such bodies a second (17,364 to 36,005 on the bench box). A backlog past the low-water mark still goes out at once, a write from outside a handler -- a server-sent events keep-alive -- goes at once, and a handler that throws straight after a write still has that write sent before the connection closes. response.stream(contentType:)or a returnedStreamingBodywrites a body as it is produced: chunked on HTTP/1.1, close-delimited on HTTP/1.0, DATA frames on HTTP/2 and HTTP/3. AContent-Lengththe handler sets is kept and enforced.- A write waits while more than
writeHighWaterMark(512 KiB) is queued, until the backlog is underwriteLowWaterMark(128 KiB).queuedBytessays how far behind the client is, including what QUIC holds unacknowledged. A client that stops reading is closed after--request-timeout. - Returning or
finish()ends the body. A throw part-way closes the HTTP/1.1 connection or resets the stream, so a client never takes half a body for a whole one. EventStreamandresponse.eventStream()send server-sent events. Data is split into onedata:line per line, a line break cannot start a field in an event name or ID, andcache-control: no-cacheis set unless the handler set its own.- A quiet event stream is sent a comment every
--sse-keep-aliveseconds (15), by the worker, whatever the handler is waiting on.EventStream(keepAlive:)sets it per stream. Topic("name").publish(...)sends a message to every subscriber on every worker, through a ring the workers share (--broadcast-size, 4 MiB).events.subscribe,ws.subscribeandresponse.subscribehear it, andevents.forward(topic, after: lastEventID)sends each message as an event whoseidis its number. A client reconnecting withLast-Event-IDis sent what it missed from the ring, on whichever worker it reaches. What cannot be sent -- written over, or more than--broadcast-queuebehind -- arrives as.missed.
app.onStreamingBody(.post, "/upload", maxBodySize:) { request, response, body in … }runs when the request's head arrives.body.read(maxBytes:)returns the next piece, or nil at the end, andreadAll(maxBytes:)the rest.maxBodySizereplaces--max-bodyfor that route and is unlimited if left out.- Reading is flow control. Unread bytes hold back an HTTP/1.1 connection's reads, the HTTP/2 stream window and the QUIC stream window, so a handler that writes to a slow disk slows only its own client.
- A body that ends short throws
RequestBodyError.incomplete, but only after everything that did arrive has been read.body.cancel()ends the request. Two reads at once throwconcurrentRead. response.sendInterim(status:headers:)sends a 1xx before the answer, such as 103 Early Hints, on HTTP/1.1, HTTP/2 and HTTP/3. It does nothing on HTTP/1.0 or once the answer has started.- A new module,
GarudaUploads, implements resumable uploads (draft-ietf-httpbis-resumable-upload-12, interop version 9):app.resumableUploads("/files", store: FileUploadStore(directory:)) { upload in … }. A client that is cut off asks for the offset with HEAD or GET, and appends the rest with PATCH. Creation can be POST, PUT or PATCH.- Only a client that sends
Upload-Draft-Interop-Version: 9gets 104 responses, as the draft requires. UploadLimitssetsmax-size,min-size,max-append-size,min-append-sizeandmax-age, advertised inUpload-Limitand enforced as the body arrives, including bodies with no declared length. A client that declares a size under a minimum is refused before anything is stored; one whose body turns out to be short is refused the completion and keeps what arrived, so it can send the rest. Creating an upload is not held tomin-append-size, since starting with an empty body is what the draft describes. Too small is a 400 carryingUpload-Limit, HTTP having no opposite of 413.- Integrity, as RFC 9530 has it.
Content-Digeston a request is checked against the bytes that arrive; bytes that are not what it says were corrupted on the way, so they are dropped and the upload stays at the offset that request began at.Repr-Digeston the request that creates an upload is checked when the last byte is in, however many requests later that is; an upload that is whole and wrong cannot be mended by appending, so it is removed.Want-Repr-Digestasks for the digest in the answer, andupload.digest()gives the handler the same one to store beside the bytes, computed once. A digest field naming an algorithm the server does not check is refused rather than ignored. - The answer to the request that completed an upload is remembered as it is
sent, and given again to a
GETof the upload's URL untilmax-age: a client whose connection died before the answer arrived can ask for it rather than be told only that the upload is complete. It outlives the bytes, so a handler that files them away and callsupload.remove()still answers.HEADis left exactly as the draft describes, andDELETEtakes the answer away with the upload. - A wrong offset or inconsistent length is answered with the draft's problem documents.
- Only one request appends to an upload at a time, across worker processes, through a file lock. A client that resumes on the same worker while its old request is still open ends the old one, keeping what it received.
- Only a client that sends
scripts/upload-test.py(35 checks) covers streamed bodies, flow control, per-route limits, interim responses and uploads over HTTP/1.1, HTTP/2 and HTTP/3, including resuming after a dropped connection and a reset QUIC stream.
app.webSocket("/chat/:room", subprotocols: ["chat.v2"]) { (ws: WebSocket, room: Path<String>) async throws in … }serves RFC 6455 over HTTP/1.1, cleartext or TLS. Middleware and extractors run before the upgrade and can refuse it with an ordinary status; headers middleware adds go out with the 101. A plain request to the route is 426.- The same route serves WebSockets over HTTP/2 (RFC 8441) and HTTP/3
(RFC 9220), on by default. HTTP/2 connections advertise
SETTINGS_ENABLE_CONNECT_PROTOCOL, and an extended CONNECT is answered 200. A handler that is not reading holds back flow-control credit for its stream alone. The peer's END_STREAM or FIN is 1006, and an abandoned WebSocket resets only its own stream. --websocket-protocols http1,http2,http3chooses which protocols carry WebSockets. The others refuse them: 501 for an HTTP/1.1 upgrade or an HTTP/3 CONNECT, and no advertisement on HTTP/2.- The handler sees whole messages:
receive(), orfor try await message in ws, andsendof text or bytes, which waits while the peer reads slowly.close(code:reason:)starts the close handshake,closeCodeandcloseReasonsay how the peer ended it, andsleep(milliseconds:)waits on the worker between sends. - The engine joins fragments, checks UTF-8 as frames arrive, answers pings and
closes, sends keepalive pings, and refuses protocol violations with the close
code RFC 6455 gives them, whether or not the handler is reading. With
--ws-compress, permessage-deflate is agreed and a message that inflates past--ws-max-messageis refused. - Messages a handler has not read wait in a queue bounded by
--ws-max-queueand--ws-max-queue-bytes; past that the socket is not read. - A handler that returns closes with 1000, one that throws with 1011, and a draining worker closes its WebSockets with 1001.
- Each WebSocket's handler runs on a task of its own, so open WebSockets do not take tasks from the pool ordinary requests use.
- The Autobahn testsuite's 517 cases pass: none fail, and the three marked non-strict (6.4.2 to 6.4.4) check UTF-8 inside a frame, which the engine checks once the frame has arrived.
app.test.webSocket("/path")opens a WebSocket in a test:send,receive,pingandclose, with the 101 or the refusal inresponseand the server's close incloseCodeandcloseReason.
app.webTransport("/room/:id") { (session: WebTransportSession, id: Path<Int>) async throws in … }serves HTTP/3 extended CONNECT. Middleware and extractors can refuse a session with an ordinary status. A session accepts and opens streams in both directions, sends and receives datagrams, and closes with a code and reason.- Stream bytes stay in the transport until the handler reads them, and writes
wait above
writeHighWaterMark. At most 32 streams are held for a session not yet accepted. Datagrams a handler is slow to read are dropped oldest first.
-
client.streamreturns aClientResponseStreamonce the response head is in, and the body is read from it as it arrives:next()a piece at a time,collect()the rest,nextEvent()the body as server-sent events, andcancel()to give up the rest. For a large file relayed on, an upstream's events, a model's tokens -- anything that should not be held whole. A streamed body is not held tomaxBodyBytes, and a caller that stops reading stops the upstream rather than filling memory: over HTTP/1.1 the bytes wait in the kernel, and over HTTP/2 the stream's window is opened only as the caller takes what came. Read to its end, the connection is kept; given up on, it is closed or its stream reset. No compression is asked for, so a relay gets the body as the server sent it. The events follow the HTML standard's format: any line ending, a leading byte order mark, comments, anidthat carries over,retry, and an event cut off by the end of the body not returned. -
client.totalTimeoutMillisecondsbounds the whole exchange, redirects included -- the connect, the request and the response to its end -- wheretimeoutMillisecondsbounds each wait and a peer answering slowly but steadily was never cut off. For work no route deadline covers: a job, a call at startup, a retry loop. Forstream, it bounds everything up to the head. -
Each HTTP/2 stream's silence is measured with its own request's
timeoutMilliseconds. The request reading the shared connection used to renew every stream's deadline with its own timeout, so a patient request's stream could time out on an impatient neighbour's clock, or the reverse. -
request.clientmakes HTTP requests from a handler:get,head,postandsend. It speaks HTTP/1.1, and HTTP/2 over TLS when the server offers it. An HTTP/2 connection is shared by every request to the same origin. -
Connections are opened on the worker's poller and kept for reuse, only for the same destination and verification identity, and only when the previous response was read whole.
-
Outbound TLS verifies the certificate against the name or address asked for.
-
Names are resolved on the poller from
resolv.conf: UDP with TCP fallback, cached for their TTL. Every address in an answer is tried in order. -
Headers that could split a request are refused, and so are URLs with userinfo. A response to HEAD and a 204 carry no body whatever they declare.
-
The client sends Accept-Encoding with the codings it can decode (gzip and deflate, brotli and zstd when their libraries load) and decodes the body, removing Content-Encoding and Content-Length. The decoded body is held to
maxBodyBytesas it grows, and a corrupt one isundecodableBody. A coding it cannot decode is returned as it came. Withdecompress = falsethe caller may send its own Accept-Encoding. -
client.redirectsfollows redirects:.none(the default),.sameOrigin(),.any()or.matching { url in … }, each with a limit (10), past which istooManyRedirects. 303, and 301 or 302 after a POST, become a GET without the body; 307 and 308 repeat the request. Leaving the origin drops Authorization, Cookie and Proxy-Authorization, and https is never left for http. A redirect the policy does not follow is the response.ClientResponse.urlis where the request ended up.
- A database request makes 3.3 task switches in the concurrency runtime
rather than 8.7, each of which took a lock to look up the task's executor
preference. The pool's and the connection's async functions are
nonisolated(nonsending), so they run where the handler runs instead of first asking to be moved; an idle connection is taken without an await; the statement is written without one when the socket takes it all; and the replies are handled as they are buffered, all at once, so the one await left is for the server to answer. On one pinned core that measured 27.2k requests a second against 26.7k before, within run-to-run noise: the switches were a smaller share of the time than they looked. - A database request allocates 40% less: 4.1 objects rather than 7.0 on
the bench box, and no longer asks the runtime about protocol conformances
at all. A result's arrays grow
once rather than cell by cell, a row's scratch space is sized once, the
column count is checked before the runtime is asked whether a type is an
array column, a route no longer asks the runtime on every request whether
each extractor awaits (
RequestExtractor.awaitsExtraction, answered by the conformance), and a worker keeps one JSON writer for its answers instead of making a writer, a buffer and their bookkeeping for each. - A released connection goes straight to the oldest request waiting for one. It used to go back to the idle list with that request woken, and whoever asked next before the woken task ran -- a new request, or the one that had just released it -- took it; the woken request joined the back of the queue with nothing, and could lose its turn again. With 64 clients on 8 connections the slowest 1% of answers took 4.6 ms, twice axum's; now 2.9 ms against its 2.5.
- A statement costs less on the client. The RowDescription that comes back with every run is compared with the last one byte for byte, and the same one reuses the columns read from it then instead of making a string per column again; a renamed column still makes a different description, and is read by its new name (there is a test). The statement cache is looked up once rather than hashed up to three times, the formats to ask for are kept with the statement, the buffers a statement is written into and read from are kept by the connection, and a row's columns are found by name without building a dictionary for each result. One worker answering the database workload went from 24,015 to 26,025 requests a second.
- A statement costs two fewer system calls. A connection the worker made
used to be given read interest for every wait on a reply and have it taken
away after, which was an
epoll_ctleither side of each statement. Read interest now stays between waits; input or a hangup that arrives while nobody is waiting takes the connection out of the poller until its owner next waits. The HTTP client and Redis connections get the same. - A native driver on the worker's poller, with no libpq. A
PostgresPoolper worker runsquery,firstandexecute. Rows decode intoDecodabletypes by column name, values are bound as parameters, and a refused statement's SQLSTATE iserror.sqlState. - SCRAM-SHA-256, verifying the server's signature. MD5 is refused, and cleartext passwords are refused unless allowed and never sent without TLS. TLS is required by default.
db.transaction { tx in … }commits when the closure returns and rolls back when it throws, or when a statement inside it failed. A connection left inside a transaction is closed, not pooled.- A statement waiting for a connection gives up after
acquireTimeoutMillisecondswithPostgresClientError.poolTimedOut. - Each connection keeps up to
statementCacheCapacity(256) statements prepared. A statement the server dropped is prepared again and retried outside a transaction. Repeated statements read booleans, integers, floats and bytea in binary. [UInt8]binds asbytea.UUIDandTimestampbind asuuidandtimestamptz, and decode from text or binary, without Foundation.- Sessions start with
client_encodingUTF8 andDateStyleISO.
- A released connection goes straight to the oldest command waiting for one, as the PostgreSQL pool's now does: woken and left to take it from the idle list, a waiting command could lose it to one that arrived after it, again and again. The two pools share the queue that does this.
- A native driver on the worker's poller. A
RedisPoolper worker, built byapp.state, with an acquire timeout like PostgreSQL's. - HELLO 3 with the credentials and client name, so replies are RESP3; a server
without HELLO is spoken to in RESP2 with AUTH. Valkey works the same. TLS is
required by default and verifies the server's name; ACL users, a database
other than 0 and unix sockets are configured on
RedisConfiguration. - Replies are parsed as they arrive, resuming where the last read ended, and
held to
maxBulkBytesandmaxReplyElements. A reply past either, or one that is not RESP, closes the connection. - Typed commands:
get,getBytes,setwith expiry and a condition,del,exists,pexpire,pttl,incr,hset,hget,hgetall,hdel,lpush,rpush,lpop,rpop,lrange,sadd,srem,smembers,publish,getJSONandsetJSON.sendruns any command, and a refusal throwsRedisClientError.serverwith its code. pipelinesends commands in one write and returns every reply, refusals among them.transactionruns MULTI, the commands and EXEC in one round trip.sessionholds one connection, for WATCH followed by a transaction that returns nil when a watched key changed.- A connection left in MULTI or WATCH, on another database, or changed by CLIENT REPLY, TRACKING or SETNAME is closed rather than handed to the next request. An idle connection with input waiting is replaced before use.
subscribe(channels:patterns:)listens on a connection of its own;next(timeoutMilliseconds:)returns each message, and more channels and patterns can be added and removed.- The reply parser is a
pgfuzztarget,resp.
-
A database that has never been written to can be read on macOS. A pool's reader opened the file read-only, and a read-only connection cannot create the shared-memory index a write-ahead log is read through, so it cannot be the first to open a WAL database: on Darwin's SQLite the first read of a fresh database failed with "unable to open database file". A reader now opens the file read-write -- which the pool has just opened for writing anyway, and still without CREATE, so a reader never brings a database into being -- and is held to reads by
PRAGMA query_only, which SQLite enforces with the same SQLITE_READONLY it gave before. A test states that, since it used to be the open flag's to keep. -
SQLiteDatabase, built byapp.state: the system's libsqlite3, loaded at run time, so building needs no SQLite headers. Where it cannot be loaded, opening a database throwsSQLiteClientError.unavailable. -
Every statement runs on the worker's blocking pool, prepared or taken from the connection's cache, stepped to the end and copied out; rows decode into
Decodabletypes on the worker.query,first,executeandrows. -
Each worker has one writer and up to
maxReaders(4) readers, opened as they are needed. A statement moves to a reader once SQLite has said, on the writer, that it cannot write.:memory:uses the writer alone. -
Files open in write-ahead-log mode with
synchronous=NORMALand foreign keys enforced, each configurable. Another worker's lock is waited for up tobusyTimeoutMilliseconds(5000) on the blocking thread. -
transactionholds the writer from BEGIN IMMEDIATE to COMMIT, so a transaction that reads before it writes never fails to upgrade its lock. A statement outside it that leaves a transaction open is rolled back and refused withtransactionLeftOpen. -
migrateruns the scripts past the database'suser_versionin one transaction, once across every worker, and refuses a database migrated by a newer program. -
Workers opening a new file at the same moment all start: switching it to WAL is retried while another holds the lock, for up to the busy timeout, where SQLite itself does not wait.
-
SQL holding more than one statement is refused, and results are held to
maxRowsandmaxResultBytes. A refusal carries SQLite's extended code assqliteCode, andisConstraintViolationsays whether a constraint failed. -
Boolbinds as 0 or 1,UUIDas text, andTimestampas UTC text of one width,2026-09-17 06:19:31.123456, which sorts in order and which SQLite's date functions read.UIntandUInt64do not bind.
Examples/is a package of applications on the public API, each with tests throughapp.test: a todo CRUD API on SQLite; sign-up, login and sessions with hashed passwords; server-sent events, a streamed CSV export and uploads written to disk as they arrive; chat rooms over WebSockets and event streams, heard across every worker; and a file service.swift run filesis that file service, and the shortest path through what uploads and downloads need:app.resumableUploadsfor an upload that survives a dropped connection,UploadLimitsat both ends,Repr-Digestchecked before the handler sees the bytes, a name fromContent-Dispositionheld to what a file name may be, and--static-dirserving the store withsendfile,ETag, byte ranges and a listing -- the download side without a line of code. Its tests cut an upload off at 120 KB of 200 KB and resume it.- The starter application has roles: an account is a
memberor anadmin, the role rides in the access token, and/admin/accountsandPUT /admin/accounts/:id/roleare guarded by two lines --authenticate(jwt:)andauthorize(jwt:, .admin)-- with no handler checking anything about who is asking.ADMIN_EMAILSnames the first administrators, since a new database has none and a route that promotes whoever asks is not a route; the list only promotes, because one that demoted would undo an administrator's work at each restart. A change of role ends that account's sessions, so the next refresh mints the new role rather than waiting out the token. The last administrator cannot be demoted, which is a question for the database and answers 409. - The starter application states its input rules on the types, so a refused
request names the field, and mounts its notes feature as a
Router. STARTER.md says how a feature is assembled as an application grows: routes as aRouterwhen that is all it has, a function onApplicationwhen it also needs per-worker state, start-up work or a timer, one migration list, and one value for the shared services.
-
Workers share connections by load. Until now each worker had its own
SO_REUSEPORTsocket and the kernel placed a connection by hashing its addresses, blind to load and to cost: 64 keep-alive connections on 8 workers landed 3 on one and 13 on another, and quick requests on a worker that happened to hold a slow client waited behind it. Now, by default (--balance adaptive), every worker accepts from one socket, and a worker ahead of the others -- busier, or holding more connections -- leaves new ones to them. And a worker where quick requests would wait behind slow ones hands idle HTTP/1 keep-alive connections to a worker where they would not, passing the socket over a unix channel between requests, so the client never notices. The wait is estimated from how long the worker's loop turns take, cheapest connections move first, and the costliest never moves. When every worker holds a slow connection there is nowhere better for a quick one to go, so the slow ones -- connections whose requests hold the loop 500 µs or more -- are gathered onto fewer workers, always toward the one holding the most, and the quick ones move to the workers freed. Slow requests keep at least half the workers, and get them back once the quick load is gone. With 8 slow connections among 64 on 8 workers, that took the quick requests from 20,000–24,000 requests a second to 70,000 when the slow ones came first, and from 51,000–56,000 to 121,000–130,000 when they came a second in, where axum served 49,000 and 64,000. With slow requests starting on workers that already held quick connections, the quick ones' p99 on the bench box went from 2.9–3.7 ms to 0.9–1.1 ms, level with axum's work stealing, at higher throughput than axum; on uniform load nothing changed.--balance acceptplaces connections without moving them, and--balance reuseportis the old behaviour. Workers stay processes, so a trap still ends only the worker it happened in. ARCHITECTURE.md has how it works and how it compares with Tokio. -
Under
--ktls, idle HTTPS connections move between workers too, where the kernel carries TLS both ways: the OpenSSL session is given up to the kernel, and the next worker reads and writes the socket while the kernel encrypts, closing it with aclose_notifyof its own. OpenSSL 3.0 hands the kernel only TLS 1.2's receiving side, so under it TLS 1.3 connections stay where they are; 3.5 hands over both.--ktlsstays off by default: without a NIC that offloads TLS, large HTTPS responses measured 22 to 28% slower with the kernel encrypting.--no-ktlssays so explicitly, andServerConfig.ktlsis aKernelTLS(.off,.on,.auto) rather than aBool. -
The p99 of small requests is level with axum's. It was twice the median for two reasons, both measured. A loop turn took every ready event, up to 256, and answered all of them before looking again, so a request that just missed a turn waited for most of two: a turn now takes at most 32. And a worker owns its connections, so when the kernel gave its CPU to another process every one of them waited out a whole scheduler slice, 2.8 ms on the bench box: each worker now asks for 300 µs slices (
--sched-slice, Linux 6.12 and later;0keeps the kernel's). On 8 workers, JSON's p99 went from 2.7–3.3 ms to 1.7–2.1 (axum 1.7), a JWT-checked request's from 3.8–4.1 to 1.8–2.0 (axum 1.8–1.9), and a path parameter's from 1.2–1.9 to 1.1–1.3 (axum 1.3), at the same throughput. -
garuda_connections_handed_off_total,garuda_connections_taken_over_totalandgaruda_accepts_deferred_totalcount what balancing did. -
JWT<Claims>checks a token without awaiting whenever the keys are in hand, and a synchronous route may take it:app.get("/me") { (jwt: JWT<UserClaims>) in ... }. Checking a token no longer copies it into arrays, looks up its key once per distinct header rather than per request, and readsexp,nbf,issandaudstraight from the claims' bytes instead of decoding them twice. On one pinned worker an HS256/mewent from 41,300 to 62,600 requests a second as a synchronous route, and to 49,300 as an async one; axum's measured 64,000 to 67,000. A synchronous route with aJWKSVerifierthat has not fetched its keys yet answers 503 and starts the fetch; an async route waits for it, as before.JWTVerifyinghas a new requirement,verifyNow, with a default that always defers toverify. AnAsyncRequestExtractorthat can also answer synchronously says so withextractsSynchronously. -
An HTTPS request costs one read rather than two: OpenSSL used to read each TLS record's 5-byte header and then its body with separate system calls, where it now reads what the socket has at once. Not under
--ktls. Needs aviancore 0.6.6. -
Garuda's TLS record layer and handshake are BoringSSL's, through aviancore, which vendors it.
avian_crypto.c, ACME and QUIC's primitives go on calling OpenSSL, which is still linked and still required; a build withoutAVIAN_TLS_BORINGSSLputs the record layer back on OpenSSL and is the only way to compare the two. Measured over HTTPS against the same tree on OpenSSL, CPU a request falls on all fourteen workloads and throughput rises on thirteen.churn-- a full handshake a request -- gains 40.5% with its CPU cut from 1,010 to 574 microseconds, which is what the standalone library probe predicted to within a tenth of a point. Thenspike+12.5%,db+10.0%,relay+9.8%,overload+8.7%,json+8.1%,recovery+7.0%,skew+6.7%,me+5.6%.h2is the exception at -3.0%, for reasons not yet understood: it spends less CPU a request than OpenSSL and still reads lower. BENCHMARKS.md has the table and the method.What it gives up is kernel TLS, which BoringSSL does not have, so
--ktlsis accepted and ignored and every build encrypts in the process. What that costs could not be measured. A rotated seven-round run puts the difference between BoringSSL and OpenSSL-with-kernel-TLS inside run-to-run variation at 1 MiB and 16 MiB, after earlier runs suggested 8% and 14%; five different values have now come back for nominally one quantity, so no magnitude is quoted. At 64 KiB BoringSSL is ahead, and over HTTP/2 kernel TLS was always the slower of the two. Against OpenSSL without kernel TLS, static files are 23 to 45% faster. -
benchmarks/tls-probesmeasures what the TLS library itself costs, with no server in the way: one source compiled against OpenSSL and against BoringSSL, a client and a server in one process over a socketpair, plus the same round trip with no TLS for the syscall floor. The group and the cipher suite are pinned, because the two libraries choose differently by default and an unpinned run measures that choice. BENCHMARKS.md records the result. -
benchmarks/tls-arms.shruns the HTTPS sweep with the measurement order taken out of it: each arm in an invocation of its own so that every one is the server measured first, and the arms rotated between rounds so each takes each position. This matters because a server measured second reads lower on the bench box, and not by the same amount for every server, so the default order was flattering Garuda. The HTTPS figures in BENCHMARKS.md were re-measured this way and the previous ones withdrawn. -
benchmarks/workloads.shpassesGARUDA_FLAGSon to Garuda, and withTLS=1runs every workload over HTTPS: Garuda with OpenSSL, axum with rustls through axum-server, the same self-signed P-256 certificate for both.h2then negotiates HTTP/2 by ALPN. -
benchmarks/workloads.shruns Garuda under a balancing mode asgaruda:adaptive,garuda:acceptorgaruda:reuseport, and has two new workloads:skew, quick requests on 56 connections while 8 more hold the CPU for about 2 ms a request, andspike, the same with the slow requests starting a second into the run. -
Workeris~Copyable. Nothing in Garuda meant to copy one, and nothing did in the source, but calling a method that does not mutate it -- through the pointer every handler holds, or from inside one that does -- had the compiler take a copy of the whole struct first, retaining every reference in it and releasing them after. Profiles of JSON answers and database statements both showed it. Now the compiler cannot. On the bench box the PostgreSQL workload went from 34,126 to 37,150 requests a second and the streamed one from 36,005 to 39,967. Tests that handedworker.pointee's properties straight to#requiretake them out first: the macro wants a copyable base. -
benchmarks/workloads.shmeasures requests that do work against axum: a path parameter, JSON in and out, a PostgreSQL row, and a streamed 64 KiB body, from two applications that answer each with the same bytes. Each answer is checked before it is measured. BENCHMARKS.md has the first run. -
HTTP/3 serves the certificate the client asked for.
--tls-certand--tls-keyrepeat for SNI, and until now only TCP followed them: the QUIC handshake is written from the primitives rather than driven by OpenSSL, so nothing was choosing, and every HTTP/3 client was served the default pair whatever name it asked for. A browser reaching a second domain over HTTP/3 got a certificate warning where HTTP/1.1 and HTTP/2 were fine. The handshake now picks a certificate as soon as it has read the ClientHello, by the same RFC 6125 rule the TCP path uses and over the same names, with the first pair the default for a client that sends no name or asks for one nothing covers. aviancore 0.4.0 carries the selection and the matching rule, which both paths now share rather than each having a copy. -
--static-dirserves byte ranges.Rangeis answered 206 withContent-Rangeon HTTP/1.1, TLS, HTTP/2 and HTTP/3 alike,Accept-Ranges: bytesgoes on every answer, and a range outside the file is 416 carrying the file's size. The kernel copy takes an offset and the paths that read take a seek first, so a range costs what the whole file costs. One range per request: a request for several is answered whole, which RFC 9110 section 14.2 allows, rather than interleavingmultipart/byterangesboundaries with a file being handed tosendfile.If-Rangesends the range only while the client's copy is still current, and the whole file when it is not. -
--static-indexanswers a request for a--static-dirdirectory with itsindex.html, and--static-listinglists one that has no index. Both are off by default: a directory that is not answered falls through to the routes, as an unserved path always has, and a listing tells whoever asks every name in the directory. A directory asked for without its trailing slash is a 301 to the slash, keeping the query. A listing shows regular files and directories, directories first, leaves out names beginning with., and carriesCache-Control: no-store. It walks one segment at a time withO_NOFOLLOW, so it cannot leave the tree and a symlinked directory is not listed. -
--compresscompresses handler responses, whoever answered them, with the coding the client rates highest. A whole body states its compressed length, and a streamed one is compressed as it is written, flushed with each write. It says Vary, sends a strong ETag weak, and leaves alone images, event streams, bodies the handler encoded,no-transform, HEAD and bodies declared under--compress-min-size. -
--cache-sizestores handler responses marked fresh in memory every worker shares, and answers later GETs and HEADs from them, compressed afresh for each client, with 304 for a matching validator. A successful unsafe request retires a URL's copies. Responses with cookies,private,no-storeor an unusual Vary, and requests with credentials, stay out. -
--reloadwatches the executable. When a rebuild holds still and answers--version, the supervisor execs it with its listening sockets open and replaces the workers one at a time. A changed--tls-certor--tls-keyreplaces the workers without an exec. -
--no-websocketsrefuses an upgrade with 501 before anything else answers it. -
--request-start-headeris read by handlers asrequest.requestStart, and--schemeis the schemerequest.schemefalls back to. -
No target sets unsafe build flags, so Garuda can be depended on by version.
-
The protocol and systems layers moved to aviancore, a package of their own:
CAvian,AvianCore,AvianHTTPandAvianQUIC, in place ofCGaruda,GarudaCore,GarudaHTTPandGarudaQUIC. Code that imported those modules imports the Avian ones, and the C functions are namedav_instead ofpg_. Their unit tests run in aviancore. -
GARUDA_UDP_GSOandGARUDA_NO_OPENAT2are nowAVIAN_UDP_GSOandAVIAN_NO_OPENAT2. -
The executor async handlers run on is aviancore's
LoopExecutor, which needs aviancore 0.5.0. It was Garuda's own; it moved so that every server on aviancore's loop runs its async code the same way.
- The end-to-end harness stopped leaking servers between suites.
scripts/serverlib.shkept oneSERVER_PID, so a script that started a second server while the first was still up lost the only handle to the first:static-test.shleft a supervisor and two workers behind on every run. They accumulated over a long sequence untilbroadcast-test.pylost cross-worker delivery assertions at the end of a 23-suite run and passed every time on its own -- which reads exactly like a bug in whatever was changed most recently, and is not. The library now tracks every server it starts,server_stop_allstops all of them, and the traps use it. - A QUIC stream reset before it had sent anything could be forgotten before its RESET_STREAM went out, keeping its stream credit until the connection closed. A reset side now counts as finished once the reset is sent.
- An HTTPS/1.1 client request to a TLS 1.3 server could fail as
closedwhen the server's session ticket arrived before its response. - A closed connection frees the buffer its request head was copied into, as it frees the others. The slot kept it instead, sized to the largest head it had ever held -- HTTP/2 and HTTP/3 streams and chunked uploads copy every head there -- for as long as the worker ran.
- A worker readies a connection slot when it first needs one, not all of
--max-connectionsat start: an idle worker held 6.7 MiB and holds 1.4, and eight workers under load use 16 to 20 MiB in all rather than 60. - Checking a bearer JWT costs about half what it did: the token is read from
its bytes rather than split and decoded as strings, a key set keeps the
headers it has decoded, and an HMAC key is set up once rather than for
every token. A route that takes
JWT<Claims>serves 59% more requests. - An optional member of a JSON object is found in one walk of the object,
not three: the decoding container answers
decodeIfPresentitself rather than leaving it tocontains,decodeNilanddecodein turn. - A body of 64 KiB or more answered from an array over HTTP/1.1 is written from that array, in one writev with the head, instead of being copied into the connection's write buffer, which for a large body was fresh pages from the kernel every response. A 1 MiB download costs half the CPU it did.
- A worker's connection tables release what their slots still reference when the worker is destroyed, rather than freeing the memory under it: handlers, contexts and protocol state were never released, once per worker at exit and once per test that makes one.
- Middleware cannot wrap a handler's run. The server's own log lines stay
text under
--log-format json. - WebTransport is HTTP/3 only.
- Resumable uploads have no
min-sizeormin-append-sizeand no digests, and a completed upload is not replayed. A request still appending on another worker is waited for briefly, then the new request gets 409 with Retry-After. Expired uploads are removed when uploads are created. - PostgreSQL has no
date,time,interval,numericorjsontypes of its own (they read as text), noLISTEN, and no SASLprep for non-ASCII passwords. - SQLite statements cannot be interrupted once on a blocking thread: a request
cancelled meanwhile finds out when the statement returns. No
sqlite3_backup, custom functions or extensions. - Redis Cluster and Sentinel are not supported: the driver talks to one server. RESP3's streamed strings and aggregates are refused, and no command the driver sends is answered with them.
- What each connector does not support yet, Redis Cluster and Sentinel among them, and the plan for each, is in CONNECTORS.md.
- TLS over TCP is OpenSSL.