Unreleased [Not released yet]
Part 3 of 11. Previous part
Changed (61)
- An io_uring worker lends 28 receive buffers at start, not 32. When they run out, it lends a quarter more, at least 16. After the start proof, the server gives free malloc pages back to the system (
malloc_trim, glibc only). The native-rankwispapp got 1000 keep-alive requests on/. Its VmRSS went from 4344 kB to 4288 kB (median of 20, 2 workers on CPUs 1-2, WSL 6.8). 256 keep-alive connections of 50 requests each had 0 errors. They had 201 receive rearms, from 193 (a counter in a test build, 2 runs). The work Ir per request does not change: 2183.0 on/and 2389.0 on/json(bench/native-rank/ir.sh, 3 runs, the build of commit 93335e0e6). wisp devcompiles the app crate with-C opt-level=0 -C codegen-units=16in every build that it runs (the first build and each save) when the profile hasopt-level0 or 1 (the scaffold has 1). A higheropt-levelstays as the app sets it. The dependencies keep the profile, andwisp buildand a cargo build are not changed. A save of a Rust edit shows in about 0.6 s, from about 1.5 s with the scaffold profile (opt-level = 1). Method: an app fromwisp newon Windows (4 CPUs, MSVC), 6 saves that change a function body. The "Rebuilt in" line ofwisp devshows 0.5 s after and 1.5 s before (a poll of/every 10 ms until the new text shows: median 0.6 s after, 1.5 s before). Onerustccall takes a median of 303 ms after and 1213 ms before, and the link takes 90 ms and 120 ms of that. The median time of a request to/in the dev server goes from 0.015 ms to 0.027 ms. Before, the app crate used theopt-levelof the profile.- On the epoll and io_uring drivers, a page body or an owned body (
Body::Bytes) of 16 KiB or more goes to the socket after the head with no copy into the write buffer. Only the last response of a read does this. A response before another one in the same read is copied as before. If the socket takes only part of the head on the epoll, the body is copied behind it. - The release workflow fails while the elan pin (
ELAN_COMMIT,ELAN_SHA256) is not set. Before, it skipped the proofs step. The CI workflow still skips it. Thecargo-denycheck in the release and scheduled workflows matches the version line exactly. Before,0.20.2also matched0.20.20. - The wasm build for Deno and Bun keeps error, redirect and WebSocket answers out of line, as the request-only build did. Deno compiles each wasm function on its first call. A table in memory skips its store check at start, so its code waits for its first use. The first
GET /of the bench app compiled 121355 body bytes before, and 108863 after (local Deno 2.9.7 trace,--trace-wasm-compilation-times). Cold start, user instructions (Paris b103, base 7733f3e04): Deno raw 143.00M -> 140.19M (-2.0%), Deno.serve 155.94M -> 153.21M (-1.8%), workerd 116.80M -> 114.83M (-1.7%). Bun and every warm request are flat. Native builds are the same. - The type of plain text is
text/plain;charset=UTF-8on every target (native, edge, static), nottext/plain; charset=utf-8. This coversResponse::text, the built-in error and health answers,robots.txtand.txtfiles. At load, the bridge checks ifnew Response(string)has this type (Deno and Node do, Bun does not). Where it does, the bridge sends a plain text 200 with no headers object. cx::decode(query and path parameters) checks UTF-8 without the lossy walk and keeps its decoded buffer, with no second copy. Deno raw/params/42?q=hello%20world&x=128.28k -> 27.94k, Deno.serve 40.47k -> 40.14k user instructions per request (Paris b54, 11 reps, disjoint, Wisp a76824924 on b45c83338).- Edge builds:
Cx::set_cookie_withkeeps one body for all value types, not one per type. The gate in the worktree measuredtests/edge-fullat 804978 bytes on 08b1505ea and 800353 bytes on 30cb4ae0f (-4625).tests/edge-sizechanged from 376120 to 376233 bytes (+113). - A
Passwordin a job or input_jsonis stored as its hash. A plain one is hashed when the job is pushed or stored. - Rows that
Sql::all/first,Kv::get_jsonand a job read are not checked against#[validate]. - Edge builds: an app without
Response::websocketlinks no WebSocket handshake code. The wasm of such an app is smaller. The gate in the main checkout measuredtests/edge-sizeat 376385 bytes on 61663dda7 and 370402 bytes on 57403acb5 (-5983).tests/edge-full, an app that uses WebSockets, changed from 801471 to 801498 bytes (+27). - Edge builds: an instance keeps the
Cxof an answered request and gives it, clean, to the next request. Before, each request made a newCxon the heap and freed it. A request that starts while the keptCxis in use gets a new one. The native server does not change. On Deno.serve/(Paris, Wisp 20e72be83 with the edgefull2 patch, median of 11, A/A runs equal), it runs 19.35k user instructions per request, not 19.62k. Raw Deno does not change. The edge-size wasm grows by 3051 bytes. - Edge builds: through the fetch handler (
serve), the bridge does not read the body of a GET or HEAD request. The app gets these requests with no body. On Deno 2.9.7 and Bun,request.bodyof a GET withcontent-length: 5or a chunked body isnull(raw-socket run). So the change does not change what an app gets on these two hosts. The native server still gives a GET body tocx.body(). On Deno.serve/(Paris, Wisp 8cbd05281 on 7e9c4e1d1, median of 11), it runs 290 fewer instructions per request (20.78k to 20.49k). Raw sockets do not change. - Edge builds: the header cache of a lazy request is behind one pointer. A request that reads no header makes no cache. A request that reads one uses the cleared cache of the last request.
- Edge builds:
setupruns once per instance, inprepare. It does not run for each request. The setup change alone is inside the spread of the runs. The two changes above together, on Deno.serve/(Paris, Wisp 4f7230291 on 17eff84f0, median of 11), run 340 fewer instructions per request (21.12k to 20.78k). Raw sockets do not change. Native routes do not change. - The edge build answers a request to a sync
+server.rsarm (App::handle_now) in the call of the host, with no task, as the epoll driver of the native server does. An error page, an arm that waits, a stream, a WebSocket and changes not yet saved go on through the task. An app with abeforehook has no such arm. Raw sockets of Node, Bun and Deno answer such an arm the same way. The wasm build leaves out the async shim of such an arm. On/(Paris, Wisp 01c3222cf on 2280dd0a5, median of 11), Deno.serve runs 890 fewer instructions per request (20.51k to 19.62k) and raw sockets run 300 fewer (21.34k to 21.04k). - Pure routes on the edge. A sync
+server.rsgetthat takes noCxand no input, on a path with no param, can be pure (App::pure). It is pure when no hook, guard,CACHE,COMPRESS,CORSorTIMEOUTis on it. The bridges that answer through the host'sfetchhandler areDeno.serve,Bun.serve, Workers, Netlify and Vercel Edge. A GET of such a path with no query can send a 200 with only a staticcontent-type. Then these bridges keep the path and host. Later GETs of them run the handler in one call, with no request bytes and noCx. Any other result goes the whole way with that result, so the handler runs once. Other results are another status, a header, an error, a stream or changes not yet saved. An app with no pure route has noApp::pure. A bridge that finds no pure call at startup does not use it. Raw sockets of Node, Bun and Deno do not use it. User instructions per request on/(Paris b86, base 7733f3e04): Deno.serve 19.37k -> 15.91k, Bun.serve 17.54k -> 15.77k, workerd 232.13k -> 205.57k. - Node's
node:httphandler (WISP_NODE_HTTP=1, Vercel's Node function, Firebase) uses pure routes too. The first GET of a path goes as a lazy request, as onDeno.serve. That request can make the path pure. Later GETs of a pure path run the handler in one call. Only a GET with no query, no body header, onehostthat is not empty and each header name once uses this path. Each value must be Latin-1 with no control byte but a tab. All other requests go whole, as before. A whole request hascontent-length: 0, and so has the lazy one. - Edge builds: a task poll uses one kept waker. It does not clone and drop the waker's
Arcfor each poll. A task that keeps a waker still wakes, and the next poll gets a new waker. A poll inside a poll gets its own waker. On Deno.serve/(Paris, median of 21) the change is inside the spread of the runs: no gain is measured. Native routes do not change. - Edge: the parser writes each request into a
Cxon the heap in place. The request path does not copy theCxfrom frame to frame. On Deno.serve/(Paris, Wisp d9d973b0 on 0e25654f, median of 11) it runs 930 fewer instructions per request (22.25k to 21.32k). Deno raw sockets and native routes do not change. - Edge: a bodiless request through
serveof the edge bridge (Deno.serve) goes into itsCxwith no parse when its head has the shape that the bridge writes: a request line and onehostline. Wisp checks the same things as the parser: method, path bytes, host bytes and the 16 KiB head limit. Any other head goes to the parser as before. On Deno.serve/(Paris, Wisp 6d3a874c on d22cadb0, median of 11) it runs 290 fewer instructions per request (21.45k to 21.16k). Raw sockets and native routes do not change. - Edge builds: when a reply has only static headers at the same addresses as the last reply,
senduses the last head and does not compare its bytes. The sent bytes do not change. On Deno.serve/(Paris, Wisp fdbb0af9 on 27803028, median of 11) it runs 550 fewer instructions per request (22.94k to 22.39k). Deno raw sockets do not change. - Edge build: a request uses the request buffers of an answered request. It does not allocate them again. On Deno.serve
/(Paris, Wisp 7a9d0991, median of 11) it runs 160 fewer instructions per request (23.12k to 22.96k). Wisp raw does not change. - Edge builds: the request parser reads a header value eight bytes at a time and skips the scan of 16 bytes. Wasm has no vector compare, so that scan was a loop over each byte. A failed scan of 16 bytes also ran again after each next word. On Deno raw sockets
/(Paris, Wisp c4597060 on 77624185, median of 11) it runs 170 fewer instructions per request (20.70k to 20.53k). Deno.serve and native routes do not change. - Edge builds: a reply with the same head as the reply before it skips the head write and the search in the sent heads. It sends the same head number. A byte-by-byte check of status, flag and headers decides. On Deno.serve
/(Paris, Wisp 3038cd91, median of 11) it runs 390 fewer instructions per request (23.51k to 23.12k). With/and/jsonmixed (Paris, two oha runs at once, same code), the change (-0.21k) is below the run spread (3.9%): not resolved. - The edge wasm keeps
wisp.jsandlive.jsgzipped and unpacks each once, on the first request that sends it. The wasm is smaller, and the served bytes stay the same. - Deno raw sockets: a socket write is a plain promise chain, with no async function and no
catchpromise for each write. - The gate has a
proofsstep. It runslake buildinproofs/, which holds Lean 4 proofs of premises of the request path. With nolake, the step printsproofs: SKIPPED (no lake). Response::with_headertakes a value ofimpl Into<Cow<'static, str>>. A&'static strvalue is not copied, so.with_header("cache-control", "no-store")allocates nothing.Response::headersisVec<(Cow<'static, str>, Cow<'static, str>)>. A borrowed&strthat is not'staticneeds.to_string().- A table keeps its rows in one
Vecin the order of their adds, not in aBTreeMap. An index of ids in sorted chunks of at most 256 finds each row. An add with a new highest id is a push. An add at another id (a random-id table) moves at most one chunk of the index, and never a row. A load sorts the ids once. The edge wasm is smaller. wisp build --target deno: a read on a connection adds one to a counter. The idle check reads the clock, not each read. The idle time starts at the first check that sees the new read or write, not at the read. So a quiet connection can stay open up to one check interval (a quarter of the idle limit) longer than before.- The edge answer keeps the check of a
'staticheader pair, as the native server does. A thread checks such a pair again only when it is not one of its last 4 pairs. wisp openapi: a named action (?/send) is no longer a path key with a query, which OpenAPI 3.1 does not allow. It is an operation in thex-wisp-action-sendfield of the page's path item.fn defaultstays thepostof the page.- The TFB bench runs wrk on 4 CPUs, because 2 CPUs limit it. A cell where the median busy share of the busiest client CPU is 95% or more gets no rank. Actix Web sets its workers to the CPUs it can use, because actix-server 2.2.0 ignores
taskset. The numbers on the site are from a WSL2 VM on a Ryzen 7 7800X3D. - Benchmark validity is per cell.
bench/quiet/summarize.mjswritesvalid_cells,valid_fractionandpublishablein place of the run-levelvalid. A run is publishable when the gates pass, no cell is unclassified and at least 90 % of the cells are valid (PUBLISH_FLOOR). Reports show only valid cells and show each other cell as "noisy, not shown". Before, one noisy cell blocked the whole run. - Release builds send a smaller browser runtime. Its minify step drops each
;before}and joinsletstatements (let a=1,b=2). The meaning of the code stays the same. Gzip bytes (gatejsstep): wisp.js 10_250 to 10_186, live.js 12_539 to 12_457, extra.js 6_291 to 6_242. - The offline queue of
data-wisp-queuemoved from wisp.js to/_app/c/queue.js. wisp.js imports it while online on a page with such a form, and when a page starts with posts that wait. Gzip bytes (gatejsstep): wisp.js 10_180 to 10_178, queue.js 386. After a failed import, the next try asks forqueue.js#1,#2and so on, because a browser may keep the failure for that URL. A static export writes queue.js beside wisp.js. The runtime serves queue.js to every app, also an app withscripts = false. tweened,springandcrossfademoved from extra.js to/_app/c/motion.js, and the browser code oft('key')moved from live.js to/_app/c/i18n.js. Only a module that names one of the three loads motion.js. Only an app withsrc/localesloads i18n.js. Gzip bytes (gatejsstep): live.js 12_457 to 12_324, extra.js 6_242 to 5_305, wisp.js 10_186 to 10_180.versionofimport { version } from 'wisp'is now the id of the build (the same as<meta name="wisp-build">and/_app/version.json, an empty string inwisp dev). Before, it was the[package] version.- The generated Dockerfile builds with
--locked.wisp deploy init dockerstops with a message whenCargo.lockis missing. A missing wasm target error says to runrustup target addin the app folder, for the toolchain inrust-toolchain.toml. - Edge builds (Node, Bun, Deno, Cloudflare): the JSON writer writes a short string (8 bytes or fewer, nothing to escape) one byte at a time, with no
memory.copy./json-biguses 3.0% fewer user instructions a request on Deno and 5.6% fewer on Node (median of 7 reps, VPS). The wasm grows 570 bytes. Native code does not change. - Apps that use
wisp::Tableget a smaller wasm: the edge build has no store poll and notest::freshcode (14,523 B less in a test app with one saved table, 454,715 to 440,192 B, measured on one PC). - Apps that use
wisp::Tablewith more than one row type get a smaller wasm: the check for two saved tables with one name is one shared function for all row types (3,525 B less in the test app, measured on one PC). - A release edge wasm build has no
/_wisp/*dev endpoint. WithWISP_DEV=on, the app's router answers those paths (its own 404 page when no route matches), not a plain 404. The wasm is smaller. beforeNavigate:nav.tois nowURL | null(null forleave).- A template hole that is one
format!(..)call (${format!("Item <{i}> & co")}) writes into the page. It makes noStringfor the value. When the format string holds only text and captures ({name},{name:spec}), the build escapes the text between the captures once. A{name}capture writes as${name}does, so a number takes the integer path. Any other arguments (format!("{}", x)) format into the escaper. The text on the page is the same as before. When aDisplayimpl returnsErr, the page keeps the text written so far. Before,format!panicked. - The gate checks more. The
stestep reads the comments, docs and messages oftests/,examples/,bench/andeditors/too, with a baseline per file that can only fall. Theauditstep fails on two ledger lines for one file. Thestampsstep rejects a<Measured at="newest">block that names a group such asrankand not one suite ofbench/suites.tsv, because the hash binds only exact suite names. The testfast.rsfails in CI (CIis set) whennodeor thewasm32-unknown-unknowntarget is missing, and does not skip. The release workflow publishes the VS Code extension only after the binaries, the crates and the release job passed. The GitHub mirror of the workflows setspermissions: contents: readat the top. - The last line of the reference in the
AGENTS.mdof an app has no semicolon now:<!-- End of the Wisp reference. Notes for this app go below. wisp update-docs keeps them. -->.wisp update-docslooks for this exact line. An app made before this change has the old line, sowisp update-docssays that it finds no end line. Move your notes to another file, deleteAGENTS.md, and runwisp update-docsagain. - The
sitestep of the gate counts a changelog version page without its release body (the<article>element) under the HTML budget. The body has its own ceilings, 29,800 bytes and 11,450 bytes gzipped. They come from the page of1.0.0-pre.3. The pages ofchangelog/unreleased/have the same ceilings. The gate has no exemption for them. cargo run -q -p wisp-gate site --dist <dir>checks a site that is already built in<dir>against the speed budgets. It builds nothing and needs no docs checkout. Without--dist,sitebuilds the reference site as before.- A WebSocket upgrade on
WISP_LISTEN=unix:logswisp: WebSocket on "<path>" refused: WebSockets need a TCP listener, not WISP_LISTEN=unix:. The server still writes the 101 and closes the connection. Before, it closed with no log. - The onion page gives the reason for no
Securecookie on a plain-http onion: Tor Browser treats a.onionaddress as a potentially trustworthy origin, and Wisp did not check which other clients refuse aSecurecookie there. Before, the page said that the browser may refuse it.
Fixed (464)
wisp dev --target <host> --port 0is an error:--port 0 does not go with --target. Before, Wisp passedPORT=0to the host and waited 30 s for a host on port 0.- The error dialog of
wisp devnames the file and line of a template error when the path starts with a Windows drive (C:\app\src\routes\+page.wisp:4:1). Before, it named no place. wisp devcounts a save as a change after a build when a Rust file keeps its size and its time stamp but not its text. Before, it kept the code of the file from before the build.- The Pages warning about
static/entries that reach the worker uses the singular for a count of 1 (1 top-level name,1 entry reaches the worker, which serves it). Before, it said1 top-level namesand1 entries reach. const EARLY_HINTS_H1insrc/hooks.rsor in a+server.rsfails the build, asEARLY_HINTSdoes. Before, the build ignored it there.- The docs of
Browser::evalsaid that a value that is not JSON givesValue::NULL.undefinedgivesValue::NULL, and an object (an element too) gives its own fields. A browser may fail to send some values (for examplewindow). Thenevalfails with the browser's error. For such a statement, usevoid (...)or return JSON (el.id). - On a
CACHEorCACHE_PUBLICroute whose handler callscx.cors, the kept answer holds each header that the handler set, also those beforecx.cors. Withcx.cors("*"), a request with anyorigingets a hit. Whenaccess-control-allow-originechoes an origin, the kept answer holds one origin at a time. A request with the sameorigingets a hit, and alternating origins run the handler again. Before, a hit sent noaccess-control-allow-originand novary: origin.