Issue #121: The server takes many connections at once

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.

Comments

keywan-ghadami, 2026-10-06

Decided (owner): ADR-326 and ADR-327

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:

Decisions, one at a time:

  1. Accepting is a layer of its own below every protocol: net::serve in std, and http stands on it. (Option B.)
  2. No connection count to configure, not even in a file. The owner’s reasoning: it depends on the load, so the runtime decides.
    • Measured beside the hot path, not per request, on a beat the strategy chooses.
    • The algorithm is exchangeable (Strategy pattern), so std and the ecosystem can improve it. A fixed limit read from a file is just one more strategy.
    • The runtime enforces the system’s ceiling and never ends the server for it.
  3. A panic ends only its task, at both settings. The owner was Elixir-minded: things may go wrong, but must not take everything down, and it should be much faster than BEAM.
  4. Poisoned shared state is never read.
    • A door to a poisoned value panics.
    • A SharedMut::supervised(fn { … }) value is rebuilt in place, and the holders keep their handle.
  5. Memory budgets. The owner wanted a budget per task, with shared values accounted separately since Shared is explicit in the source.
    • The owner pointed out that a reserved block makes the limit simple, and that Nikaia need not use Rust’s allocator.
    • The owner proposed views to Rust for free and an explicit copy for an owned Vec.
    • Recorded as the direction, with costs still to measure.

Records:

Still open: timeouts, supervision’s base model (#95, next), routing/OpenAPI, graceful shutdown, and the measurements in ADR-327 §6.

To build

  1. lock.rs: never read through poison (ADR-327 D1-D2).
    • Opening a poisoned SharedMut panics in the opening task.
    • Test: a task panics inside update; a second task’s get panics, and a third task not touching the value runs on.
  2. Panic isolation (ADR-326 D4):
    • The executor at 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").
    • Cleanups run and the hook runs first.
    • A TaskHandle::join() of a panicked task reports it.
    • A panic in main still ends the program.
    • Test at both settings: one task panics, another finishes and prints.
  3. 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:
    • two slow clients are served at once;
    • a handler that panics yields 500 and the next request succeeds.
  4. net::Admission, net::Load, net::Decision:
    • a side beat calling decide;
    • the hot path compares an atomic counter with the current limit;
    • 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.
    • Tests:
      • with Fixed(1), a second client waits until the first ends;
      • a descriptor ceiling (ulimit -n lowered in the test) makes accept pause, not fail.
  5. SharedMut::supervised (ADR-327 D3): rebuild on poison, keeping the handle. Test: after a panic in update, the next get sees the rebuilt value.
  6. Fix Part III’s old claim in examples/docs if any remains; update examples/hello-http to show two clients at once.
  7. Memory budgets (ADR-327 D4-D8): measurement first, in its own issue.

Generated by Claude Code

keywan-ghadami, 2026-10-06

Decided (owner): timers and keep-alive (ADR-326 D6-D9)

How it went:

Rules (ADR-326 D6-D9, Part III std::net and http; ADR-289 D13/D15 changed):

To build (add to the list above)

  1. Deadlines. 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.
  2. 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.
  3. 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).
  4. Keep-alive. The connection task loops: read a request, answer, wait up to 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).
  5. Early close under load. When the admission strategy asks for it, the next response carries 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

keywan-ghadami, 2026-10-06

Decided (owner): what an API description is built from (ADR-329)

How it came up. The owner asked for routing and OpenAPI for microservices. Reflection gives only .name and .of(value) (ADR-304 D4).

Rules (ADR-329, Part II 10.3, NK1180):

To build

  1. .ty in the shape walk:
    • Emit a meta::Type constant per unrolled turn.
    • Expand a struct into a Record, using Named for recursion and enums.
    • Test: struct User { name: String, email: String?, address: Address } gives Text, Maybe(Text) and Record([...]).
    • Test: struct Node { next: Node? } (through a box) ends with Named("Node").
  2. .doc:
    • Keep a field’s /// run in the AST.
    • Emit it as a constant string.
    • Test: a documented and an undocumented field.
  3. NK1180 now lists .ty and .doc.

Generated by Claude Code

keywan-ghadami, 2026-10-07

Decided (owner): describing a type without a value of it (ADR-330), which replaces steps 14-16

How it came up.

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:

To build (replaces 14-16 above)

  1. A type in the parameter list.
    • The parser accepts fn f[T](T).
    • The checker binds T from a type written at the call (f(User)), a type parameter, field.ty, or a type bound by a match arm.
    • Emitted once per type, like a shape walk; the type argument leaves no runtime parameter.
    • NK1230 for a value where the list holds a type, and for a type where it holds a value.
    • Tests: schema(User) lowers; schema(user) is NK1230 with help schema(User); hand(User) for fn hand[T](x: T) is NK1230.
  2. 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.
    • Test: String, i64?, Vec[Address] and HashMap[String, i64] give Text, Maybe(i64), List(Address) and Map(String, i64).
    • Test: T::kind of a tuple is NK1232.
  3. match on a kind narrows.
    • Each arm is checked under the kind its pattern names. In Struct(s), s: Struct reaches s::fields.
    • Each type’s copy holds only the arm it takes.
    • Tests: the recursive 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.
  4. .ty is the field’s type. schema(field.ty) and field.ty::kind work; NK1180 is unchanged.
  5. .doc on a field: step 15 of the earlier list, unchanged.
  6. 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.
  7. Grammars (ADR-323 D3): 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.
    • Test: an existing SQL example still builds.
  8. 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

keywan-ghadami, 2026-10-07

Decided (owner): Named is gone (ADR-330 D7)

How it came up.

Rule: ADR-330 D7 and Part II 10.5. ADR-323 D1 and D3 are changed.

To build (amends 20 above)

  1. Grammars (ADR-323 D3):
    • meta::column takes the new meta::Type, without Named.
    • A type crosses out of the grammar run as its full name.
    • Test: an existing SQL example still builds.
    • Test: a meta::Type built inside a grammar names only std types and the grammar package’s own types.

Generated by Claude Code

keywan-ghadami, 2026-10-07

Decided (owner): the remaining kinds (ADR-330 D3)

What was decided.

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.

To build (amends 15 above)

  1. Add to 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

keywan-ghadami, 2026-10-07

Decided (owner): access is a property beside the kind (ADR-330 D8)

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):

To build (amends 15 above)

  1. Kinds and access.
    • meta::Type gains Scalar, Array(A, N) and Tuple(parts). No Ref, Shared or SharedMut kinds.
    • meta::Access and T::access are added.
    • Tests:
      • 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

keywan-ghadami, 2026-10-07

Decided (owner): f[T]() with a warning; no full names or aliases yet (ADR-330 D9, §6)

What was decided.

To build

  1. f[T]().
    • The parser and checker accept type arguments in brackets at a call of a generic function, filled in order.
    • NK1233 (warning) at each such call; its help links #514.
    • A wrong count of type arguments is refused with NK1101.
    • Tests:
      • 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

keywan-ghadami, 2026-10-07

Decided (owner): typed routes and reflection over functions (ADR-332)

How it went.

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):

To build

  1. F: Fn and F::params.
    • The bound 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.
    • Test: a walk over fn(id: i64, fields: Vec[String] = []) -> User yields id/i64/null and fields/Vec[String]/[], and the result type User.
  2. Path and query.
    • route reads {name} placeholders while building. A placeholder without a parameter is refused.
    • Other plain parameters are query parameters; a default makes one optional; a value that does not convert answers 400.
    • Test: /users/{id} with fn(id: i64); /users/{idd} is refused; ?id=abc gives 400.
  3. 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).
    • Test: AuthUser from http::Authorization: no header gives 401 and the handler does not run.
  4. Body[T] and formats.
    • formats: on the server.
    • Content-Type and Accept select the format; an unnamed format gives 415.
    • .format on the wrapper.
    • A build-time refusal when a format cannot express T.
    • Test: the same handler accepts JSON and a form; a nested struct with form enabled is refused while building.
  5. No raw access. A typed handler has no request. req: http::Request is allowed when declared. Extra request inputs are ignored.
  6. OpenAPI from the route, the parameters and the extractor chain. Test: /me with AuthUser lists the bearer Authorization header.

Generated by Claude Code

keywan-ghadami, 2026-10-07

Revised (owner): route syntax, method, headers; value parameters of a type (ADR-332 revised, ADR-333)

Why the revision.

Decisions this round:

To build: the full list 23–29 (replaces 23–28 above)

  1. F: Fn and F::params, unchanged from above.
  2. Route strings.
    • http reads {name}, {name}.ext, {name...}, ?name and ?wire={name} while building.
    • The method calls .get / .post / .put / .patch / .delete, plus .route(methods, path).
    • Refused while building:
      • a name without a parameter;
      • a plain parameter the path does not name;
      • {x...} not last;
      • two equally specific routes.
    • Matching by specificity.
    • Tests:
      • /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.
  3. Value parameters of a type (ADR-333).
    • Parse struct S[name: T; opt: U = d].
    • Arguments are build-time values.
    • Type identity is by the arguments.
    • The ledger carries the values.
    • Test: Decimal[10, 2] and Decimal[10, 2] are one type; Decimal[10, 3] is another.
  4. Header[name], Cookie[name] and http::Extract.
    • Standard header names as constants in http.
    • A name not of a header’s form is refused while building.
    • Extractors declare their inputs as parameters.
    • Tests:
      • Session from Cookie["session"] answers 401 without the cookie;
      • Header[http::USER_AGENT]? gives null when the header is absent.
  5. Body[T] and formats, as the earlier step 26 (http::Server(formats: …), without ;).
  6. No raw access, as the earlier step 27.
  7. OpenAPI, as the earlier step 28.
  8. Examples: examples/fortunes.nika still writes .route("/fortunes"); it becomes .get(…) when http::Server is built.

Generated by Claude Code

keywan-ghadami, 2026-10-07

Decided (owner): comptime parameters (ADR-333 revised); route’s signature

How it went.

Rules (ADR-333, Part I 4.6, Part II 10.2, Part III 17.1):

To build (amends 25)

  1. comptime parameters.
    • In a type’s brackets: comptime name: T; identity by the arguments; the ledger carries the values.
    • On a function’s parameter:
      • the argument is checked as a comptime initialiser;
      • one copy of the function per value;
      • the parameter is a constant inside;
      • diagnostics point at the argument.
    • Tests:
      • .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

keywan-ghadami, 2026-10-07

Decided (owner): one word, comptime; a function names an array’s length (ADR-333 D4)

How it went.

Rule (ADR-333 D4, Part I 4.6, NK1182’s help):

To build (adds to 25)

25b. comptime parameters in a function’s brackets.


Generated by Claude Code

keywan-ghadami, 2026-10-07

Decided (owner): a tree of routes is a comptime value (ADR-334)

How it came up.

Rules (ADR-334; Part II 10.2; Part III 17.1):

To build

  1. 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].
  2. http::Routes as a build-time value.
    • .get/.post/… build it, and .mount(prefix, tree; formats:) nests trees.
    • http::Server(tree; formats:, state:).
    • Tests:
      • a tree from two files and a package mounted under prefixes;
      • a conflict across two mounted trees is refused while building;
      • form under one mount with a nested body is refused only there.
  3. State[T] as an extractor from state:.
    • Refused while building: a State[Db] with no Db in state:, and two Db values.
    • Test: the mapped-page example answers with the shared buffer.
  4. The matcher generated from the tree, a decision over segments. Count instructions against a linear search, per CLAUDE.md, before claiming a gain.

Generated by Claude Code

keywan-ghadami, 2026-10-08

Decided (owner): layers with phases (ADR-335)

How it went.

Rules (ADR-335, Part III 17.1):

To build

  1. http::Layer and .layer(x) on trees.
    • The phase scheduling: before order and allowed concurrency, R handed to after, after order including error responses, observe after the send.
    • Tests:
      • two befores with effects run in written order, and a refusal stops the later ones;
      • a CORS after adds headers to a 401;
      • a panicking observe leaves the response unchanged;
      • a failing compression passes the body uncompressed;
      • a failing signer gives 500.
  2. 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.
  3. Build-time checks: header conflicts between afters; a layer’s inputs against every route below it; declared statuses in the API description.
  4. The 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

keywan-ghadami, 2026-10-08

Steps 1 and 2 are built (0.0.656 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.

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.

Next: step 3, net::serve.


Generated by Claude Code

keywan-ghadami, 2026-10-08

Step 3, first half: 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:

Two compiler changes came with it:

Second half, http::listen on serve: two things the records do not say

What 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:

  1. A caller of serve cannot learn the port it got when it asked for :0. http::listen cannot print it, and its tests cannot find the server.
  2. 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:

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

Back to Open Issues