workers en serve_app(app, port, workers) es cuántos pedidos se atienden AL MISMO TIEMPO, no cuántas conexiones retiene el servidor. Antes de este arreglo, un worker que terminaba una respuesta quedaba bloqueado leyendo el PRÓXIMO pedido de la conexión sin plazo — así que con N hilos, N pestañas de navegador quietas entre clics dejaban el servidor sin responder a nadie. Ahora, tras cada respuesta la conexión se estaciona en el event loop del runtime hasta que llega su próximo pedido, el mismo mecanismo que std/serve ya usaba para 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
Cómo funciona
Esta receta arranca un servidor con solo 2 workers y abre 5 conexiones keep-alive que piden una página cada una y después se quedan quietas, como 5 pestañas de navegador. serve_idle_connections() —un gancho de depuración, no algo que llame un handler— confirma que las 5 están estacionadas, más del doble de los workers, y que ninguno quedó atascado en alguna de ellas.
Un cliente nuevo se sigue respondiendo de inmediato: estacionar una conexión ociosa libera su worker por completo, no reserva uno "por si acaso". Y una conexión ociosa que después manda un pedido nuevo la atiende el worker que esté libre en ese momento — las conexiones no quedan fijadas al worker que las atendió la primera vez.
Dos variables de entorno que se leen al arrancar serve_app ponen el límite a todo esto: NYX_HTTP_KEEPALIVE_SECS (15 por omisión) cierra una conexión que queda ociosa ese tiempo, y NYX_HTTP_HEADER_SECS (10) es el plazo para recibir la cabecera completa de un pedido — pasado ese tiempo, un 408 y el cierre, que es lo que evita que un cliente estilo slowloris (uno que abre una conexión y gotea bytes para siempre) retenga una para siempre.