Open and answer this issue on GitHub.
libraries, ready · opened 2026-09-30 · updated 2026-10-08 · 16 comments
The minimal server (ADR-194 D5) handles one connection at a time (examples/hello-http). Serving many at once is named as a wall on the status page; no record yet says how it is built (spawn per connection over the executor at both settings of user_parallelism is the obvious shape). Needs a record first.
How it came up. The owner wants the server to serve small microservices: one route, a page of API documentation, or a protocol of its own, without a dead end for other protocols.
What was checked:
examples/http takes one connection at a time (main.nika:130), with no spawn.std::net already yields on accept/read/write, and the executor runs many tasks at both settings (ADR-303). spawn exists.connections on http::listen is a total count, not a limit.user_parallelism = no aborts the process (Part III A.2).crates/nikaia-std/src/lock.rs:325 reads through poison (unwrap_or_else(|e| e.into_inner())), contrary to the spec.net/http, tokio/hyper/tonic, Netflix concurrency-limits (exchangeable Limit), Envoy adaptive concurrency, BEAM max_heap_size (off by default), Zig’s fallible allocators.Decisions, one at a time:
net::serve in std, and http stands on it. (Option B.)std and the ecosystem can improve it. A fixed limit read from a file is just one more strategy.SharedMut::supervised(fn { … }) value is rebuilt in place, and the holders keep their handle.Shared is explicit in the source.
Vec.Records:
std::net, http), Part III 15.2 and Part III A.2;Still open: timeouts, supervision’s base model (#95, next), routing/OpenAPI, graceful shutdown, and the measurements in ADR-327 §6.
lock.rs: never read through poison (ADR-327 D1-D2).
SharedMut panics in the opening task.update; a second task’s get panics, and a third task not touching the value runs on.no (rt/exec.rs) and the pool at yes (rt/pool.rs) catch a task’s panic at the task boundary (Linux builds use panic = "unwind").TaskHandle::join() of a panicked task reports it.main still ends the program.net::serve(at) fn(conn) { … } in std::net: an accept loop that spawns a task per connection. http::listen is rewritten on top of it. Tests:
500 and the next request succeeds.net::Admission, net::Load, net::Decision:
decide;std’s default (start simple, e.g. limit from descriptors and the ready queue’s length) and a Fixed(n) strategy as the test strategy.Fixed(1), a second client waits until the first ends;ulimit -n lowered in the test) makes accept pause, not fail.SharedMut::supervised (ADR-327 D3): rebuild on poison, keeping the handle. Test: after a panic in update, the next get sees the rebuilt value.examples/hello-http to show two clients at once.Generated by Claude Code
How it went:
net::serve/protocol), a slow task (already possible with select + time::sleep), and a child that will not stop (supervisor, still open). The slow peer came first.; timeout: …). It was accepted with a precision: a total time for the whole head, not a gap between bytes, since Slowloris sends a byte every few seconds.MinRate idea; it handles slow uploads, which a total time would cut off.IdleTimeout, Node 5 s, Caddy 5 min (from memory).Connection: close on a response.Rules (ADR-326 D6-D9, Part III std::net and http; ADR-289 D13/D15 changed):
head_timeout: time::Duration = 10.seconds(): the whole head, counted from the accept (first request) or the first byte (later ones). Then 408 and close. Replaces head_wait.body_bytes_per_second: i64 = 500: the average since the body’s first byte, checked after a start-up period. Then 408 and close.idle_timeout: time::Duration = 75.seconds() between requests; connections are kept alive.net::serve offers a deadline per read and no idle limit; a WebSocket after its upgrade is its own protocol’s.std::net passes a deadline to readiness::waiting (rt/readiness.rs already supports per-registration deadlines). A read past it fails with a timeout error.http, head_timeout. Replace head_wait. Test: a client that sends one header byte per second gets 408 after about 10 s; a normal client is unaffected.http, body_bytes_per_second. Test: a body sent at 100 B/s is cut off with 408 after the start-up period; a 2 MiB body at 10 KiB/s completes (with body_cap raised for the test).idle_timeout. Connection: close from the client ends the loop. Test: two requests over one connection are both answered; an idle connection closes after idle_timeout (use a short value in the test).Connection: close. Test with a Fixed(1) strategy: a waiting second client is accepted after the first connection’s next response closes it.Generated by Claude Code
How it came up. The owner asked for routing and OpenAPI for microservices. Reflection gives only .name and .of(value) (ADR-304 D4).
meta::Type did not exist yet.Rules (ADR-329, Part II 10.3, NK1180):
.ty: a reflected field answers its type as a meta::Type, a build-time constant.
Record of its fields.enum, is Named..doc: a reflected field answers its /// text. It is empty when the build has no sources for the type’s package..ty in the shape walk:
meta::Type constant per unrolled turn.Record, using Named for recursion and enums.struct User { name: String, email: String?, address: Address } gives Text, Maybe(Text) and Record([...]).struct Node { next: Node? } (through a box) ends with Named("Node")..doc:
/// run in the AST.NK1180 now lists .ty and .doc.Generated by Claude Code
How it came up.
meta::Type, the owner said: the enum should only say what kind a type is (a struct, an enum …), never which struct. Whoever wants more looks into the details.Info value beside it.T off its first argument (ADR-304 D6). An API description has no value, so the first call already needs the type.schema(Req) over schema[Req](); f[T]() stays open.fn schema[T: Struct](T) (option B): the bound stays in the brackets.T::fields in the Struct arm when T = String, although that arm never runs for String.
.ty (match field.ty { I64 => total += field.of(value) … }).match on an enum checks each arm under what its variant carries. Adopted for kinds; unlike Zig’s inline switch, no arm stays unchecked.meta::Type, .ty and T::kind. The result:
T::kind is a meta::Type whose parts are types.std types. A std type counts as a struct if it is one. It is Opaque only where std reserves the right to change its inside. Checked: time::Duration is today a Rust type the compiler cannot walk (ledger constant).Named("Mode") for a type of the user’s program, because its package cannot see that type; only types the grammar can name are written as types.Rules: ADR-330; Part I 4.6; Part II 10.3, 10.5; Part III NK1230-NK1232. ADR-329 D1, ADR-323 D1 and ADR-304 D6 are changed.
Not decided:
f[T]();scalar, ref and Shared (NK1232 until then);fn f[T](T).T from a type written at the call (f(User)), a type parameter, field.ty, or a type bound by a match arm.NK1230 for a value where the list holds a type, and for a type where it holds a value.schema(User) lowers; schema(user) is NK1230 with help schema(User); hand(User) for fn hand[T](x: T) is NK1230.T::kind, T::doc, T::name.
meta::Type in std with I64 … Bytes, Maybe(A), List(A), Map(K, V), Struct(T), Enum(T), Opaque(T), plus Record and Named for grammars.T::doc keeps a declaration’s /// run in the AST.String, i64?, Vec[Address] and HashMap[String, i64] give Text, Maybe(i64), List(Address) and Map(String, i64).T::kind of a tuple is NK1232.match on a kind narrows.
Struct(s), s: Struct reaches s::fields.schema from Part II 10.3 over struct User { name: String, tags: Vec[String], address: Address } yields the full JSON text as a constant; a typo inside the Enum arm is reported although no enum is passed..ty is the field’s type. schema(field.ty) and field.ty::kind work; NK1180 is unchanged..doc on a field: step 15 of the earlier list, unchanged.NK1231: a meta::Type stored in a run-time value or passed to run-time code is refused. Test: let k = T::kind used in a println is NK1231.meta::column takes the new meta::Type. A type crosses out of the grammar run as its full name. Named and Record work as before.
Opaque in std’s ledger. Each std type entry says whether it is Opaque, by ADR-330 D3. Rust-crate types and C opaque type are always Opaque.Generated by Claude Code
Named is gone (ADR-330 D7)How it came up.
Named.dsl …(types: [Mode])), did not convince the owner. If a grammar ever needs a type of the program, that is decided then.Rule: ADR-330 D7 and Part II 10.5. ADR-323 D1 and D3 are changed.
meta::column takes the new meta::Type, without Named.meta::Type built inside a grammar names only std types and the grammar package’s own types.Generated by Claude Code
What was decided.
Array and tuples are added as kinds.scalar (one Unicode code point) gets its own kind, Scalar.ref A and Shared/SharedMut two ways were weighed:
A: a describer notices nothing, but a value builder (a JSON reader) would try to build what it cannot.Rules (ADR-330 D3, Part II 10.3):
| Kind | Notes |
|---|---|
Array(A, N) |
|
Tuple(parts) |
the parts are walked as T::fields is |
Scalar |
|
Ref(A) |
|
Shared(A), SharedMut(A) |
NK1232 is now only for a function type.
meta::Type:
Scalar;Array(A, N);Tuple(parts), with an unrolled loop over parts;Ref(A);Shared(A) and SharedMut(A).Tests:
Array[u8, 4] gives Array(u8, 4);(i64, String) gives a Tuple whose parts are I64 and Text;ref String gives Ref(String);SharedMut[Cache] gives SharedMut(Cache);fn(i64) -> i64 is NK1232.Generated by Claude Code
Why the earlier decision changed. The owner pointed out that ref, Shared and SharedMut are properties of A rather than kinds: the data is the same, and only the way it is reached differs.
Re-checked. The argument for separate kinds was that a value builder would otherwise build the wrong thing. That does not hold: building an A where a ref A belongs is refused at that unrolled turn, like any wrong type.
Rule (ADR-330 D8, Part II 10.3):
ref A, Shared[A] and SharedMut[A] have the kind of A.T::access is a meta::Access: Owned, Ref, Shared or SharedMut.A? stays the kind Maybe(A).meta::Type gains Scalar, Array(A, N) and Tuple(parts). No Ref, Shared or SharedMut kinds.meta::Access and T::access are added.ref String gives kind Text and access Ref;SharedMut[Cache] gives kind Struct(Cache) and access SharedMut;String gives access Owned;Array[u8, 4] gives Array(u8, 4);(i64, String) gives a Tuple;fn(i64) -> i64 is NK1232.Generated by Claude Code
f[T]() with a warning; no full names or aliases yet (ADR-330 D9, §6)What was decided.
f[T]() is allowed. Every use draws the warning NK1233, which asks for the use case at #514. A form that may be needed is allowed, and its real uses decide later whether it stays, goes or narrows. It is a function’s form; a method’s is not decided. Changes ADR-323 §3.Page[Address]);The owner’s concern from experience: OpenAPI generators that switch to full names on a collision carry package structure into the interface, an unnoticed need-to-know breach. T::name is the name the declaration writes.
f[T]().
NK1233 (warning) at each such call; its help links #514.NK1101.hand[i64](5) lowers with one NK1233;hand[i64, i32](5) is refused;xs[0]() over a list of functions stays an index and draws no warning.Generated by Claude Code
How it went.
dsl block in the call reads badly.http::Route"…") was too long; the grammar in the signature is enough, and an IDE shows it.=~ for now.{name} placeholders matched to parameter names (Rocket-like).Header("X") as a type clashes with constructor calls and with ADR-330’s types-as-arguments. Brackets Header["X"] were then agreed, but are no longer needed (see below)..value, because a wrapper may carry more than the value (the owner remembered PHP renaming X-Api-Key to HTTP_X_API_KEY).Json[T] ties a handler to one format. So Body[T]: the server names the formats, and Content-Type/Accept choose (Spring, ASP.NET).http::Extract read req.header("Authorization"). The owner saw that this breaks the same principle: text names, and OpenAPI blind.Rules: ADR-332; Part II 10.3 (F::params); Part III 17.1 (http). ADR-330 D3 changed: a function’s kind is Fn(F), and NK1232 is retired.
Not decided (ADR-332 §6):
route’s signature says its path is read while building;.layer);F: Fn and F::params.
Fn, met by a lambda or a named function passed as a value.F::params is unrolled like T::fields. Each parameter has .name, .ty, .default and .attribute(X).F::result is available.T::kind of a function is Fn(F), and NK1232 is retired.fn(id: i64, fields: Vec[String] = []) -> User yields id/i64/null and fields/Vec[String]/[], and the result type User.route reads {name} placeholders while building. A placeholder without a parameter is refused./users/{id} with fn(id: i64); /users/{idd} is refused; ?id=abc gives 400.http::Extract.
extract is reflected like a handler, and extractors nest.http::Reject carries the status.http provides Header/Cookie extractors driven by @http::Header("…") and @http::Cookie("…") on a struct, plus the standard header types (http::Authorization and others).AuthUser from http::Authorization: no header gives 401 and the handler does not run.Body[T] and formats.
formats: on the server.Content-Type and Accept select the format; an unnamed format gives 415..format on the wrapper.T.form enabled is refused while building.request. req: http::Request is allowed when declared. Extra request inputs are ignored./me with AuthUser lists the bearer Authorization header.Generated by Claude Code
Why the revision.
Header["X-Api-Key"] with values in a type’s brackets. My write-up turned this into one declared type per header (@http::Header("X-Api-Key") struct ApiKey), claiming it avoided typos. The owner pointed out that it only moves the typo to another place. The real problem in my first draft was the raw request inside extract, and Header["…"] as a parameter solves that equally well.;. The owner caught http::Server(; formats: …) in ADR-332 D4 and Part III. Part I 5.1 refuses a ; before an empty zone.Decisions this round:
?expand, and ?page-size={page_size} for a different name on the wire. A plain parameter that the path does not name is now refused.enum.{…}.{id} for a whole segment and {year}.pdf for part of one.{path...} for the rest. Another AI proposed it (Go, Ktor), and ... already means the rest in Nikaia (...args). Its ten rules were adopted with three changes:
String, Vec[String], fs::Path);%2F is refused only in a rest;.get, .post, … and .route(methods, path) for several.Decimal[10, 2]), with options after ;. Array[T, N] was the first.F: Fn and F::params, unchanged from above.http reads {name}, {name}.ext, {name...}, ?name and ?wire={name} while building..get / .post / .put / .patch / .delete, plus .route(methods, path).{x...} not last;/orders/{id}?expand with fn(id: i64, expand: bool = false);/files/{path...} gives a/b/c from /files/a/b/c, "" from /files/, and no match for /files;/files/../x and %2F in the rest give 400;/users/{id} beside /users/{name} is refused.struct S[name: T; opt: U = d].Decimal[10, 2] and Decimal[10, 2] are one type; Decimal[10, 3] is another.Header[name], Cookie[name] and http::Extract.
http.Session from Cookie["session"] answers 401 without the cookie;Header[http::USER_AGENT]? gives null when the header is absent.Body[T] and formats, as the earlier step 26 (http::Server(formats: …), without ;).examples/fortunes.nika still writes .route("/fortunes"); it becomes .get(…) when http::Server is built.Generated by Claude Code
comptime parameters (ADR-333 revised); route’s signatureHow it went.
get say its path is read while building? Three options were offered:
comptime on the parameter;get.[path: String] told apart from a bounded type parameter [T: Summarize]? Only by looking up what follows :. That same flaw was already in ADR-333 as written.comptime as the marker: [comptime precision: i64].comptime directly on the parameter. This reverses ADR-330 §3’s rejection, whose reason (comptime marks bindings only) no longer holds.Rules (ADR-333, Part I 4.6, Part II 10.2, Part III 17.1):
struct Decimal[comptime precision: i64, comptime scale: i64].comptime parameter: fn get[F: Fn](self, comptime path: String, handler: F), called .get("/orders/{id}").
comptime initialiser. A runtime value is refused with NK1127/NK1152 at the argument.comptime too.comptime parameters.
comptime name: T; identity by the arguments; the ledger carries the values.comptime initialiser;.get("/a") and .get("/b") give two copies;.get(config.path) is NK1127 at config.path;.get("/orders/{idd}") fn(id: i64) is refused at the string;fn crud(comptime base: String) passing base + "/{id}" on builds.Generated by Claude Code
comptime; a function names an array’s length (ADR-333 D4)How it went.
comptime on a type’s value parameter (struct Decimal[comptime precision: i64]) is not the same thing as on a function parameter. In a type’s brackets everything is known at build time anyway, so there the word says value, not type rather than when. const (Rust) was proposed for types.Array shows. The owner asked what Array can do today:
Array[T, N] has its length in the type;Array[T] as a parameter takes its length from the call;Array[T] (NK1182).comptime as the one word, and allowed naming the length.Rule (ADR-333 D4, Part I 4.6, NK1182’s help):
fn f[comptime N: i64](xs: Array[i64, N]) -> Array[i64, N].
comptime parameter in a function’s brackets is read off the argument types, as T is.Array[T] as a parameter is such a length, unnamed.comptime parameter in the parameter list is written by the call (get(self, comptime path: String, …));25b. comptime parameters in a function’s brackets.
reversed([1, 2, 3]) returns Array[i64, 3];dot([1.0, 2.0], [3.0, 4.0, 5.0]) is refused;NK1182’s help names the new form.Generated by Claude Code
comptime value (ADR-334)How it came up.
http::Routes value mounted under a prefix (Express, axum, FastAPI). The owner asked whether that really works.if, under a prefix from a file) is unknown while building. A handler capturing db cannot exist at build time. The options were whole-tree checks at start-up, one block, or a restricted composition.State[T] (like axum’s State, Rocket’s &State). The tree then becomes a comptime value, compiled ahead the way Phoenix compiles its router into pattern matching.Rules (ADR-334; Part II 10.2; Part III 17.1):
pub comptime ROUTES = http::Routes().get(…)….
.mount(prefix, tree; formats: …) works across files and packages.http::Server(ROUTES; formats: …, state: [db]) takes a tree and has no .get of its own.State[T] is filled from state:, one value per type. A missing value and a duplicate type are refused while building.comptime may hold. A comptime may hold a function that captures nothing. One that captures is NK1127, and the help names State[T].http::Routes(). The mapped page arrives as State[fs::Mapped].comptime functions. A comptime holds a function without captures. Test: a lambda capturing a let from main in a comptime is NK1127 with help State[T].http::Routes as a build-time value.
.get/.post/… build it, and .mount(prefix, tree; formats:) nests trees.http::Server(tree; formats:, state:).form under one mount with a nested body is refused only there.State[T] as an extractor from state:.
State[Db] with no Db in state:, and two Db values.Generated by Claude Code
How it went.
next (Express, ASP.NET, Go, tower) and separate before/after hooks (Spring).befores can wait together.500 that the client retries.std layer.befores run in written order by default, because concurrency would let a rate limit count requests that authentication refuses;after fails open or closed as the layer declares;observe is bounded, metadata only;afters (CORS);Rules (ADR-335, Part III 17.1):
.layer(x) on a tree takes a value whose kind implements http::Layer, with any of before(…typed inputs…) -> R throws Reject, after(r: R, response) -> Response and observe(exchange).next.befores in written order, at once only where nothing can tell;afters innermost first, each failing open or closed as its layer declares;observe after the send, bounded.std’s layers: timeout, body limit, and a panic’s 500.afters writing one header, and a layer whose inputs a route cannot give.http::Layer and .layer(x) on trees.
before order and allowed concurrency, R handed to after, after order including error responses, observe after the send.befores with effects run in written order, and a refusal stops the later ones;after adds headers to a 401;observe leaves the response unchanged;500.std layers: Timeout (cancels the handler’s task, cleanups run, 503), body size limit, Compress, Cors, RateLimit(key:), AccessLog. Test: a handler sleeping 3 s under Timeout(2.seconds()) answers 503 and its cleanup ran.afters; a layer’s inputs against every route below it; declared statuses in the API description.observe queue with a budget (ADR-327): when full, drop and count. Test: a stalled observer does not delay responses, and the drop count rises.Still open: the syntax for a layer’s failure mode and its statuses; which other layers std offers.
Generated by Claude Code
693f702, 0.0.657 7e1e593)Step 1, poison (ADR-327 D1, D2). A door to a value a panicking task held now panics, on both lock shapes. Before, Crossing read through the poison and Local had no poisoning at all.
Local marks the value for as long as a block runs, and only a block that returns clears the mark. A release update still counts 5 instructions in callgrind. A guard asking thread::panicking on the way out had made it 26.Crossing’s owner mark is now cleared on unwind too. Before, the next door read as a re-entry.lock.rs a_poisoned_value_is_never_read, both shapes.Step 2, panic isolation (ADR-326 D4). A task’s panic is caught at the task’s edge (task::Cancellable) at both settings. The body is dropped, so its cleanups run, and the program goes on. The hook prints src/main.nika:4: a task stopped: … there; in main it still prints the program stopped. Part III A.2 says so now.
yes, a task’s panic used to kill its worker thread and leave the program spinning at 160 % CPU forever. That is gone with the catch.join of the crashed task throws task::Crashed (ADR-328 D8, #95 step 1).tests/language/src/task_panics.nika, at both settings: one task panics and another finishes.Next: step 3, net::serve.
Generated by Claude Code
net::serve is built (0.0.658 b82525d)net::serve("127.0.0.1:7000") fn(mut conn) { echo(conn) } binds, accepts, and starts a task per connection:
no it runs on the one thread. At yes the emitter writes net::serve_on_pool, the pool’s twin, as spawn has one.EMFILE/ENFILE), it pauses accepting instead of ending (D3).sockets.rs net_serve_runs_each_connection_in_a_task_of_its_own, at both settings, checked by order rather than by time. A client that connected and sent nothing does not hold up the next one. A message that makes the handler panic closes its connection, and the next client is answered.Two compiler changes came with it:
std entry whose keeps names a function parameter takes the program’s Kept shape.mut parameter is now a mut binding (|mut conn|) instead of a dereferenced address.http::listen on serve: two things the records do not sayWhat http::listen does today. It binds, prints http: listening on <address>, and answers one connection at a time. Its tests bind port 0 and read the printed line to learn the port (project.rs the_address_it_printed). connections: n stops it after n connections (ADR-326 D5: a test’s tool, not a limit).
Why that doesn’t carry over. net::serve(at) binds inside itself and never returns. So:
serve cannot learn the port it got when it asked for :0. http::listen cannot print it, and its tests cannot find the server.serve cannot stop after n connections, so D5’s connections: has nothing to stand on.Nothing needs this except http and its tests, but http is the issue’s own target (step 6, examples/hello-http).
Options:
serve takes options, ; connections: i64 = 0 (D5’s test tool, moved down a layer) and bound: fn(String) (called once with the address it got). Small, but two more names in the one place a protocol accepts.serve also takes a bound net::Listener: net::serve(listener) fn(conn) { … }. The caller binds, reads listener.address(), and then serves. connections: stays on http only if serve returns after n, so (b) still needs that one option.http keeps its own accept loop and spawns a task per connection. That contradicts D1 (every protocol stands on serve).Recommendation: (b) plus ; connections: on serve. Binding and serving are two steps in every socket API (Go’s net.Listen then http.Serve(l, …), Rust’s TcpListener::bind then axum::serve(listener, …)), and port 0 needs the step in between. The connections: option is D5’s test tool, at the layer that counts. Until this is decided, http::listen stays as it is, and steps 4 (net::Admission) and 9 (deadlines) can be built on serve meanwhile.
Generated by Claude Code