workers in serve_app(app, port, workers) is how many requests are handled AT THE SAME TIME, not how many connections the server holds. Before this fix, a worker that finished a response stayed blocked reading the connection's NEXT request with no deadline — so with N threads, N browser tabs sitting quietly between clicks left the server answering nobody. Now, after each response the connection is parked in the runtime's event loop until its next request, the same mechanism std/serve already used for SSE.
// std/serve: idle keep-alive connections don't hold worker threads // std/serve: las conexiones keep-alive ociosas no retienen hilos // // `workers` in serve_app(app, port, workers) is how many requests are handled // AT THE SAME TIME, not how many connections the server holds. After each // response the connection is parked in the runtime's event loop until its next // request, so a browser between clicks costs no thread. // // Limits (environment variables, read when serve_app starts): // NYX_HTTP_KEEPALIVE_SECS (15) an idle keep-alive connection is closed after this // NYX_HTTP_HEADER_SECS (10) time to receive a request's whole header -> 408 // NYX_HTTP_SEND_SECS (30) a response write stalled with no progress is cut // (a client that stopped reading frees its worker) // // This recipe opens more idle connections than there are workers and shows the // server still answers. It plays the clients with raw TCP sockets so it can run // (and finish) on its own. import "std/web" import "std/serve" fn hello(req: Request) -> Response { return response_text(200, "hola") } fn connect_retry(port: int) -> int { var fd: int = 0 - 1 var tries: int = 0 while fd < 0 and tries < 50 { fd = tcp_connect("127.0.0.1", port) if fd < 0 { sleep(100) } tries = tries + 1 } if fd >= 0 { tcp_set_timeout(fd, 5) } return fd } // One GET on an open connection; true if the answer is a 200. fn get_ok(fd: int) -> bool { tcp_write(fd, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") let resp: String = tcp_read_partial(fd, 4096) return resp.startsWith("HTTP/1.1 200") } fn main() -> int { let app: App = app_new() let port: int = 18212 app_get(app, "/", hello) // Only 2 workers, on purpose. The server runs in a detached goroutine and // dies with the process. spawn { serve_app(app, port, 2) } // Five connections (more than the workers): each asks for a page and then // stays open without asking for anything else, like a browser tab. var idle: Array = [] var i: int = 0 while i < 5 { let fd: int = connect_retry(port) if fd < 0 or !get_ok(fd) { print("could not open idle connection " + int_to_string(i)) return 1 } idle.push(fd) i = i + 1 } sleep(100) print("idle connections parked: " + int_to_string(serve_idle_connections())) // A new client is still answered right away: no worker is stuck waiting // on an idle connection. let fresh: int = connect_retry(port) if fresh < 0 or !get_ok(fresh) { print("the new client got no answer") return 1 } print("new client answered while 5 connections sit idle") // And an idle connection that comes back is served by whichever worker is free. let first: int = idle[0] if !get_ok(first) { print("the idle connection got no answer") return 1 } print("idle connection came back and was answered") tcp_close(fresh) i = 0 while i < idle.length() { let fd: int = idle[i] tcp_close(fd) i = i + 1 } return 0 }
nyx-serve starting on 127.0.0.1:18212 with 2 workers idle connections parked: 5 new client answered while 5 connections sit idle idle connection came back and was answered
How it works
This recipe starts a server with only 2 workers and opens 5 keep-alive connections that each ask for one page and then go quiet, like 5 browser tabs. serve_idle_connections() — a debug hook, not something a handler calls — confirms all 5 are parked, more than double the worker count, and no worker is stuck on any of them.
A brand-new client is still answered right away: parking an idle connection frees its worker completely, it does not reserve one "just in case". And an idle connection that later sends a new request is picked up by whichever worker happens to be free — connections are not pinned to the worker that first served them.
Two environment variables read when serve_app starts bound how long this can go on: NYX_HTTP_KEEPALIVE_SECS (15 by default) closes a connection that stays idle that long, and NYX_HTTP_HEADER_SECS (10) is the time allowed to receive a request's full header — past it, a 408 and a close, which is what keeps a slowloris-style client (one that opens a connection and trickles bytes forever) from tying one up indefinitely.