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/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..1aba93c3 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,8 @@ 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 for now as the server is single-threaded + response.set_connection_close() provision.response = response^ provision.state = ConnectionState.responding() @@ -583,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: 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/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 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: