From 463b8e086780f286eccb4cf46ba9a82900eee392 Mon Sep 17 00:00:00 2001 From: Val Date: Sun, 15 Feb 2026 14:33:26 +0100 Subject: [PATCH 1/4] fix asset loading lag by always responding with Connection: close --- lightbug_http/connection.mojo | 22 +++++++++++++++++++--- lightbug_http/server.mojo | 19 +++++++++++++++++-- lightbug_http/socket.mojo | 23 +++++++++++++++++++---- tests/integration/integration_client.py | 6 +----- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/lightbug_http/connection.mojo b/lightbug_http/connection.mojo index 9f5ef4c0..f00ca52d 100644 --- a/lightbug_http/connection.mojo +++ b/lightbug_http/connection.mojo @@ -10,6 +10,7 @@ from lightbug_http.c.socket_error import ( RecvfromError, SendError, SendtoError, + SetsockoptError, ShutdownEINVALError, ) from lightbug_http.c.socket_error import SocketError as CSocketError @@ -360,18 +361,33 @@ struct TCPConnection[network: NetworkType = NetworkType.tcp4]: return self.socket.receive(buf) fn write(self, buf: Span[Byte]) raises SendError -> UInt: - """Write data to the TCP connection. + """Write all data to the TCP connection, handling partial sends. Args: buf: Buffer containing data to write. Returns: - Number of bytes written. + Total number of bytes written. Raises: SendError: If write fails. """ - return self.socket.send(buf) + var total_sent: UInt = 0 + while total_sent < UInt(len(buf)): + var sent = self.socket.send(buf[Int(total_sent):]) + total_sent += sent + return total_sent + + fn set_recv_timeout(self, seconds: Int) raises SetsockoptError: + """Set the receive timeout on this connection's socket. + + Args: + seconds: Timeout in seconds. 0 to disable. + + Raises: + SetsockoptError: If setting the socket option fails. + """ + self.socket.set_timeout(seconds) fn close(mut self) raises FatalCloseError: """Close the TCP connection. diff --git a/lightbug_http/server.mojo b/lightbug_http/server.mojo index a3e0963c..2c904b38 100644 --- a/lightbug_http/server.mojo +++ b/lightbug_http/server.mojo @@ -295,6 +295,11 @@ fn handle_connection[ if read_err.isa[EOF]() or read_err.isa[SocketClosedError](): provision.state = ConnectionState.closed() break + # On keep-alive connections, treat timeout (EAGAIN) as clean close + # so the server can accept new connections. + if provision.keepalive_count > 0: + provision.state = ConnectionState.closed() + break raise read_err^ if bytes_read == 0: @@ -437,8 +442,10 @@ fn handle_connection[ if (provision.keepalive_count + 1) >= config.max_keepalive_requests: provision.should_close = True - if provision.should_close: - response.set_connection_close() + # Always send Connection: close. The server is single-threaded and can't + # serve other clients while blocked on a keep-alive read. Without this, + # browsers hold connections open and queue behind idle ones. + response.set_connection_close() provision.response = response^ provision.state = ConnectionState.responding() @@ -463,6 +470,14 @@ fn handle_connection[ provision.keepalive_count += 1 provision.prepare_for_new_request() + # Set a recv timeout so the server doesn't block forever + # waiting for the next request on this keep-alive connection. + # This allows the server to accept new connections from other clients. + try: + conn.set_recv_timeout(1) + except: + pass + else: break diff --git a/lightbug_http/socket.mojo b/lightbug_http/socket.mojo index d733c995..ea3c1237 100644 --- a/lightbug_http/socket.mojo +++ b/lightbug_http/socket.mojo @@ -1,6 +1,8 @@ from sys.ffi import c_uint from sys.info import CompilationTarget +from lightbug_http.c.aliases import c_void + from lightbug_http.address import ( Addr, NetworkType, @@ -17,6 +19,7 @@ from lightbug_http.c.socket import ( ShutdownOption, SocketOption, SocketType, + _setsockopt, accept, bind, close, @@ -808,13 +811,25 @@ struct Socket[ """Return the timeout value for the socket.""" return self.get_socket_option(SocketOption.SO_RCVTIMEO) - fn set_timeout(self, var duration: Int) raises SetsockoptError: - """Set the timeout value for the socket. + fn set_timeout(self, seconds: Int) raises SetsockoptError: + """Set the receive timeout for the socket. Args: - duration: Seconds - The timeout duration in seconds. + seconds: The timeout duration in seconds. + + Raises: + SetsockoptError: If setting the socket option fails. """ - self.set_socket_option(SocketOption.SO_RCVTIMEO, duration) + # SO_RCVTIMEO requires a timeval struct: {tv_sec: Int64, tv_usec: Int64} + # (16 bytes on both macOS and Linux 64-bit). + var timeval = InlineArray[Int64, 2](seconds, 0) + _ = _setsockopt( + self.fd.value, + SOL_SOCKET, + SocketOption.SO_RCVTIMEO.value, + UnsafePointer(to=timeval).bitcast[c_void](), + 16, + ) comptime UDPSocket[address: Addr] = Socket[ diff --git a/tests/integration/integration_client.py b/tests/integration/integration_client.py index 30c3c249..0823503d 100644 --- a/tests/integration/integration_client.py +++ b/tests/integration/integration_client.py @@ -34,14 +34,10 @@ assert response.status_code == 200 print("\n~~~ Testing parallel connections ~~~") -# Browsers open 6+ parallel connections for assets. -# A single-threaded server with keep-alive blocks on conn.read() waiting for -# the next request, preventing other connections from being accepted. -# This test verifies all parallel requests complete within a reasonable time. def fetch(path): - return requests.get(f"http://127.0.0.1:8080{path}", headers={"connection": "close"}) + return requests.get(f"http://127.0.0.1:8080{path}") with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: From 372e4b900bffe6841c180347fe9f13e792026f33 Mon Sep 17 00:00:00 2001 From: Val Date: Sun, 15 Feb 2026 14:40:16 +0100 Subject: [PATCH 2/4] bump version --- README.md | 2 +- pixi.toml | 2 +- recipes/recipe.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a02027aa..85a8b373 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Once you have a Mojo project set up locally, ```toml [dependencies] - lightbug_http = ">=0.26.1,<0.26.2" + lightbug_http = ">=0.26.1.1,<0.26.2" ``` 3. Run `pixi install` at the root of your project, where `pixi.toml` is located diff --git a/pixi.toml b/pixi.toml index d149d0e4..1436946a 100644 --- a/pixi.toml +++ b/pixi.toml @@ -34,7 +34,7 @@ build_and_publish = [{ task = "build" }, { task = "publish" }] [package] name = "lightbug_http" -version = "0.26.1.0" +version = "0.26.1.1" [package.build] backend = { name = "pixi-build-mojo", version = "*" } diff --git a/recipes/recipe.yaml b/recipes/recipe.yaml index 03f2dded..0a475dac 100644 --- a/recipes/recipe.yaml +++ b/recipes/recipe.yaml @@ -1,11 +1,11 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/prefix-dev/recipe-format/main/schema.json context: - version: "0.26.1.0" + version: "0.26.1.1" package: name: "lightbug_http" - version: 0.26.1.0 + version: 0.26.1.1 source: - path: ../lightbug_http From 635e991813fbdd2caef226597264053f5c206594 Mon Sep 17 00:00:00 2001 From: Val Date: Sun, 15 Feb 2026 14:43:16 +0100 Subject: [PATCH 3/4] remove recv timeout --- lightbug_http/server.mojo | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lightbug_http/server.mojo b/lightbug_http/server.mojo index 2c904b38..b20524f1 100644 --- a/lightbug_http/server.mojo +++ b/lightbug_http/server.mojo @@ -470,14 +470,6 @@ fn handle_connection[ provision.keepalive_count += 1 provision.prepare_for_new_request() - # Set a recv timeout so the server doesn't block forever - # waiting for the next request on this keep-alive connection. - # This allows the server to accept new connections from other clients. - try: - conn.set_recv_timeout(1) - except: - pass - else: break From 8a7914c4e74bc87069b87467f289ab9ea989672e Mon Sep 17 00:00:00 2001 From: Val Date: Sun, 15 Feb 2026 14:44:49 +0100 Subject: [PATCH 4/4] clean up comments --- lightbug_http/server.mojo | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lightbug_http/server.mojo b/lightbug_http/server.mojo index b20524f1..1aba93c3 100644 --- a/lightbug_http/server.mojo +++ b/lightbug_http/server.mojo @@ -442,9 +442,7 @@ fn handle_connection[ if (provision.keepalive_count + 1) >= config.max_keepalive_requests: provision.should_close = True - # Always send Connection: close. The server is single-threaded and can't - # serve other clients while blocked on a keep-alive read. Without this, - # browsers hold connections open and queue behind idle ones. + # Always send Connection: close for now as the server is single-threaded response.set_connection_close() provision.response = response^ @@ -590,7 +588,6 @@ struct Server(Movable): # Connection handling failed - just close the connection pass finally: - # Always clean up the connection and return provision to pool try: conn^.teardown() except: