From f26218a77a04e7b4a0ca9c206800cedba4220020 Mon Sep 17 00:00:00 2001 From: jay23606 <4602056+jay23606@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:57:29 -0400 Subject: [PATCH] test(microvm): read the host-loopback HTTP request up to its blank line The fixture validated the first recv(4096) and rejected a valid request that TCP delivered in several reads. Read until the header terminator, within the timeout and a size cap. Add tests for a single send and a split request. Co-Authored-By: Claude Haiku 5.5 Signed-off-by: jay23606 <4602056+jay23606@users.noreply.github.com> --- scripts/nvx_tools/microvm_tests.py | 29 ++++++++++++++++++++-- scripts/test_microvm_tests.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 9b03bc92..267141f4 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -3008,6 +3008,32 @@ def run_ipv6_egress_policy( endpoint.close() +HOST_LOOPBACK_HTTP_REQUEST_LIMIT = 8192 + + +def _read_http_request(connection: socket.socket, timeout: float) -> bytes: + """Read a request up to its blank line, however TCP splits it. + + A request can arrive in several reads, so one ``recv`` is not enough. The + read stops at the header terminator, at the peer's close, or when the + request is too large or the deadline passes. + """ + deadline = time.monotonic() + timeout + request = bytearray() + while b"\r\n\r\n" not in request: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("host-loopback HTTP request did not complete in time") + connection.settimeout(remaining) + chunk = connection.recv(4096) + if not chunk: + break + request.extend(chunk) + if len(request) > HOST_LOOPBACK_HTTP_REQUEST_LIMIT: + raise RuntimeError("host-loopback HTTP request exceeds its size limit") + return bytes(request) + + def _http_server( listener: socket.socket, expected_path: bytes, @@ -3018,8 +3044,7 @@ def _http_server( try: connection, _ = listener.accept() with connection: - connection.settimeout(timeout) - request = connection.recv(4096) + request = _read_http_request(connection, timeout) if not request.startswith(b"GET " + expected_path + b" HTTP/1."): raise RuntimeError( f"unexpected host-loopback HTTP request: {request!r}" diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index 0ae6c9fa..6a073364 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -239,6 +239,46 @@ def _fresh_openvmm_command(*_args: object, **_kwargs: object) -> list[str]: return ["openvmm"] +class HostLoopbackHttpFixtureTests(unittest.TestCase): + def _exchange(self, pieces: list[bytes]) -> tuple[bytes, list[Exception]]: + """Send the request in pieces with a gap, and return the reply and fixture errors.""" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.addCleanup(listener.close) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + errors: list[Exception] = [] + server = threading.Thread( + target=microvm_tests._http_server, + args=(listener, b"/proxy", b"NVX-BODY", 2.0, errors), + daemon=True, + ) + server.start() + response = b"" + with socket.create_connection(listener.getsockname(), timeout=5) as client: + for index, piece in enumerate(pieces): + if index: + time.sleep(0.25) + client.sendall(piece) + while chunk := client.recv(4096): + response += chunk + server.join(5) + return response, errors + + def test_a_single_send_request_is_answered(self) -> None: + response, errors = self._exchange( + [b"GET /proxy HTTP/1.1\r\nHost: localhost\r\n\r\n"] + ) + self.assertTrue(response.startswith(b"HTTP/1.1 200 OK"), response) + self.assertEqual(errors, []) + + def test_a_request_split_across_reads_is_answered(self) -> None: + response, errors = self._exchange( + [b"GET ", b"/proxy HTTP/1.1\r\nHost: localhost\r\n\r\n"] + ) + self.assertTrue(response.startswith(b"HTTP/1.1 200 OK"), response) + self.assertEqual(errors, []) + + class MicrovmTestParserTests(unittest.TestCase): def test_parser_defaults_to_all_correctness_scenarios(self): args = nvx.parse_args(["test-microvm", "--backend", "mshv"])