Coming next
Show the 25 entries waiting for the next release
Added
- Load balancing across workers, without moving a task once it has started (so tasks still need not be
Send):Handle::spawn_balanced(f):fwaits in a queue shared by the workers, and the first worker with nothing else to run takes it; busy workers also take one now and then, so no job waits for ever. 64 jobs of uneven CPU cost started from one worker finish in 5.7 ms on 4 workers (median of 15 rounds), against 6.9 ms placed round-robin and 16.8 ms on the worker that started them; the slowest round took 6.5, 10.4 and 19.7 ms (bench/spawn-balanced).TcpListener::bind_balanced(addr): listeners of the same runtime on the same address form a group, and a worker that looks clearly busier than another member hands each new connection it accepts to that member.SO_REUSEPORTalone spreads connections by a hash of their addresses, so with 6 connections on 3 workers running a CPU-heavy route the result depends on where the hash puts them: over 10 runs plain listeners gave 37,000 to 54,000 requests per second (median 51,600, p99 448 µs), balanced ones 47,000 to 59,000 (median 53,000, p99 408 µs) (bench/balance.sh).Handle::least_loaded_worker(), from hints each worker publishes between batches of tasks (live tasks, runnable tasks, asleep or not), plus work sent to a worker that it has not taken in yet. Publishing them costs about 0.1% of the instructions per request of a small HTTP response.
giong-web, a new crate: routing, middleware and JSON for HTTP services on giong-http.- A handler is an
async fn(Req<S>) -> Result<Response, Error>, and?works on any error.Router::new().get("/users/{id}", user).nest("/teams/{team}", members) .layer(m).build(state)builds anAppper worker, with no locks and nothingSend. Patterns have parameters ({id}) and a catch-all ({*path}); a literal segment wins over a parameter, a parameter over a catch-all, with backtracking.HEADis answered by theGETroute,OPTIONSand405withAlloware answered for every path, and a404for/users/names/userswhen that exists. req.param::<u64>("id")?andreq.query_param::<u32>("page")?parse into anyFromStrtype: a value that does not parse is a400(GIONG-E1205) naming the parameter and the expected type, and asking for a parameter the route lacks is a500(GIONG-E1206) whose cause names the route. With theserdefeature,req.json::<T>()(415,400or422:GIONG-E1208,E1207),req.query::<T>()andjson(status, &value).- Every error answer is an RFC 9457 problem+json body with a stable code (
{"status": 404,"code":"GIONG-E1203","title":"Not Found","detail":"no route for GET /x"}), and the request id when there is one.Error::bad_request(code, detail)and its siblings make one with your own code; an error that is not one becomes a500(GIONG-E1210) whose cause is logged and never sent, or a503withRetry-After(GIONG-E1211) when it is a giong error for a database or service that cannot be reached. A 5xx noLoggerreported goes to stderr. Two routes for the same requests, or an invalid pattern, panic at startup with a code and the line that added them. - Middleware is a trait with one
async fn call(&self, req, next); they run in the order added, and those of a nested router only for its routes. Included:RequestId(X-Request-Id, incoming ids trusted only if asked),Logger(one JSON line per request with its route, status, duration, request id and, for errors, the code and cause; written by a background thread, never on the worker),Cors(nothing allowed until named; a preflight that is refused says why,GIONG-E1212),Compression(gzip at level 1 for bodies from 1 KiB, in 64 KiB slices that yield to other tasks) andTimeout(503,GIONG-E1209). Settings that cannot work, such as any origin with credentials, panic withGIONG-E1213. App::handle(Request)answers without a socket, so routes and middleware are tested with plain unit tests (Request::from_bytes).- On one core against axum 0.8 with the same serde handlers (
bench/web.sh, median of 5 rounds): 152.5k requests per second on/jsonagainst 118.3k, 28.0k on a list of 100 users against 15.2k, 140.5k on/users/{id}against 107.1k; with request ids, gzip and a timeout, 126.2k against 65.8k on/jsonand 12.0k against 5.2k on the gzipped list (tower-http for axum). The router, parameters and serde cost about 5% against giong-http's bare handler. - Two fuzz targets (
giong-web/fuzz, 17 in all): the route table against a slow reference matcher, and any bytes as a request to an app with every middleware, whose answer must be well formed (status and headers, problem+json with a code for every error, gzip that decodes to what the handler wrote, one JSON log line that agrees with the response, the request id sent back).
- A handler is an
giong-http:Handler, the trait the server calls; everyFn(Request) -> impl Future<Output = Response>is one, and a type can implement it to borrow from itself while it answers (giong-web'sAppdoes).Responsegainsheaders(),get_header,remove_header,body_bytes,take_bodyand typed extensions (with_extension,extension);Requestgainsfrom_bytes, to make one in a test, andwith_peer_addr.reason_phrase(status).giong::try_worker_id(): the index of the worker running the caller, orNoneoff the runtime. 13 new error codes,GIONG-E1201toE1213, for giong-web; 81 in all.giong-http: what the client lacked for production traffic.- HTTP proxies:
ClientBuilder::proxywithProxy::all,Proxy::httporProxy::https, orProxy::from_envforHTTPS_PROXY,HTTP_PROXY,ALL_PROXYandNO_PROXY, read the way curl reads them and only when asked.NO_PROXYtakes host names (and every name under them), addresses and ranges (10.0.0.0/8,fd00::/8), ports and*.httpURLs go to the proxy in absolute form;httpsgoes through aCONNECTtunnel, so the proxy sees only the host name. Credentials in the proxy's URL go to the proxy only, never through the tunnel, and never appear inDebugoutput or errors. Connections are pooled per route, so direct and proxied ones never mix. Plainhttp://proxies only: no SOCKS and no TLS to the proxy itself yet. - gzip and deflate bodies: responses are asked for with
Accept-Encoding: gzip, deflateand decompressed as they arrive. The size limit applies to what comes out, so a small "zip bomb" fails at the limit instead of filling memory. The gzip checksum and length are checked, multi-member gzip and deflate with or without its zlib wrapper are read, and a body cut short is an error, never a shorter body.ClientBuilder::decompress(false)turns it off, a request that sets its ownAccept-Encodinggets the bytes as sent, andbrandzstdbodies arrive untouched. New dependency:miniz_oxide(pure Rust, the inflater flate2 uses), in theclientfeature only. - Streamed bodies:
RequestBuilder::send_streamingreturns aStreamingResponseonce the head has arrived, and itschunk()gives the body piece by piece (64 KiB at most, decompressed), whatever its size. The request timeout covers everything up to the head, then each piece. A body read to its end gives its connection back to the pool; one dropped part way closes it. As withsend, an HTTPS body that ends with the connection needs the TLSclose_notify. ErrorKind::Proxy(GIONG-E1110) for a proxy that refuses a request or a tunnel: a 407 says how to give credentials, a 403 suggestsno_proxy, and 502 and 504 are transient. A proxy that cannot be reached isConnect(GIONG-E1102), and its message names the proxy. 68 error codes in all.
- HTTP proxies:
- Verification: coverage-guided fuzzing of every parser that reads bytes from the network, with 12 new cargo-fuzz targets (15 in all): the client's response heads, URLs and gzip/deflate decoder in
giong-http; the PostgreSQL messages, SCRAM exchange and URLs (giong-postgres/fuzz/); the MySQL packets, framing, rows and URLs (giong-mysql/fuzz/); RESP3 replies and URLs (giong-redis/fuzz/). CI fuzzes each target for 10 minutes every night, keeping the corpus, and for 60 s on pull requests that touch these crates. The randomised tests run the same checks undercargo test. - Verification: a
serversCI job runs the database crates against PostgreSQL 14, 15 and 17, MySQL 8.4 and 9.1, Redis 6.2 and 7.4 and Valkey 8.0, besides the PostgreSQL 16, MySQL 8.0 and Redis 7.0 of the other jobs.
Changed
giong-http:Server::serve,serve_unixandserve_tlstake anyHandler. A closure passed to them needs its argument's type written (|req: Request| async move { .. }): Rust no longer infers it.giong-http: the client asks for compressed responses and decodes them by default (see Added); a decoded response no longer hasContent-EncodingandContent-Lengthamong its headers. A request that setsProxy-Authorizationitself is refused (GIONG-E1101): the client sets it, for the proxy only.giong-http: the client runs 41% fewer instructions per request (18,795 to 11,115 under callgrind,json). Each request's future held, unused, the future that opens a connection, TLS handshake included, and copied its 8 KB or so at every await; that future is now boxed and lives on the heap only while a connection opens. The response head is no longer copied out of the buffer it was read into either. On one core, requests per second rose by 3 to 16%, and with 64 connections the client sends 1.6 to 2.0 times as many as reqwest (bench/http-client.sh, median of 5 rounds).
Fixed
giong-httpserver: more than two blank lines before a request line start the request timeout. A client sending\r\nonce a minute kept a connection open for ever.giong-httpclient: a kept-alive connection that received anything while idle (a408 Request Timeoutthat some servers send before closing, the rest of a body longer than itsContent-Length) is no longer used. The bytes were read as the response to the next request.giong-httpclient: aHostheader set by hand is not sent to another origin after a redirect, likeAuthorizationandCookie.giong-httpclient: idle connections of an origin that is not asked for again are closed after the idle timeout, and the origin is forgotten. Before, they stayed open, with their entry, for as long as the client lived.giong-postgresandgiong-mysql: transactions no longer run on the connections a pool shares.Pool::query,query_one,query_optandexecute(andinsertin giong-mysql) refuseBEGIN,START TRANSACTION,COMMIT,ROLLBACK,SAVEPOINTand the other transaction statements (MySQL alsoXA,LOCKandUNLOCK) before sending anything, with the new error codesGIONG-E3011andGIONG-E3111(ErrorKind::Statement). Before,pool.execute("BEGIN", &[])left a connection that other tasks share inside a transaction: their statements ran in it and were never committed, though they had been told they succeeded. APool::batch_executethat leaves a transaction open (or, in MySQL, autocommit off) now closes its connection, which rolls the transaction back, and fails withErrorKind::Closed, as do the statements pipelined behind it.giong-postgresandgiong-mysql: aPooledClientgiven back while a statement is still in flight (its future dropped by a timeout) queues aROLLBACKfirst. If that statement wasBEGIN, the connection went back to the shared pool inside a transaction, because the last answer still said there was none. giong-mysql closes a connection given back with autocommit off.giong-postgresandgiong-mysql:Pool::getwaiting for a connection is woken when a statement whose future was dropped frees it. It waited out the wait timeout and failed withPoolTimeout.giong-postgres: rows without columns (SELECT FROM t) are counted;queryreturned none.giong-mysql: aTIMEwith more days than MySQL allows no longer overflows when it is read as text.giong-redis: awatchcancelled while waiting forWATCH's reply sendsUNWATCH. Before, the watch stayed on the connection, and once a pool shared it, another task's atomic pipeline could be skipped byEXECwhile reporting success.giong-redis: an atomic pipeline whoseEXECanswers nil (a watched key changed, outside aWatch) is an error, "the transaction did not run", instead of a value.giong-redis: a push message larger thanConfig::max_reply_size(a client-side caching invalidation) is passed over. Before, it was counted as the reply to the oldest command, and every later reply went to the wrong command. An attribute count out of range at the front of such a message (|9223372036854775807, or a negative one) is no longer multiplied: it overflowed, a panic in a debug build. Found by therepliesfuzz target.giong-redis: a task waiting inPool::takeis woken when a shared command whose future was dropped frees the connection. Before, it slept until the wait timeout and could fail withPoolTimeoutwhile the connection was free.
Security
giong:localhostand names under.localhostresolve to the loopback addresses without asking DNS (RFC 6761). Before, they went through the search list first, so a wildcard record in a search domain, or a forged answer, could pointlocalhostat another machine, whichgiong-postgresandgiong-mysqlthen trusted as local: no TLS, the password sent in the clear, MySQL's RSA key fetched unverified. A hosts file entry for such a name is used only if it is a loopback address.giong-http: chunk extensions are limited to 16 KiB per body, on the server and in the client. They counted toward no limit, so a peer could send about 4,000 bytes per byte of body, all of it held in memory until the body ended (the flaw of Node.js CVE-2024-22019). Such a request is answered with 400; such a response is an error.