diff --git a/cloud/chv/api_client.py b/cloud/chv/api_client.py index 0ad0365..754222f 100644 --- a/cloud/chv/api_client.py +++ b/cloud/chv/api_client.py @@ -33,6 +33,15 @@ def _read_header(sock): return header, buf[header_end + 4:] +def _parse_status(header: str) -> int: + """从响应状态行取状态码(解析失败即 0,与 + _parse_response 的口径一致)。""" + try: + return int(header.split()[1]) + except (IndexError, ValueError): + return 0 + + def _parse_content_length(header): """从响应头取 Content-Length:chunked 或无长度即拒绝。 @@ -102,7 +111,13 @@ def _unix_socket_api(socket_path, method, path, payload=None): body 能解析 JSON 时为 dict,否则为原文本。socket 不存在 或连接失败时 raise RuntimeError(调用方据此判 api 超时 /失败,不编造 200)。读满语义见 _read_header / - _parse_content_length / _read_body(P1-6,纯移动)。""" + _parse_content_length / _read_body(P1-6,纯移动)。 + 204/304 例外:按 HTTP 语义体长恒为 0,不要求 + Content-Length、也不读体——CHV 的 vm.create/vm.boot + 成功即回 204 且不带该头(2026-11-07 WSL 真机实测: + vmm.ping 200 带长度、vm.create 204 无长度),严格 + 校验会把合法成功当失败拒掉;其它状态仍严格(无长度 + /截断/chunked 均拒绝)。""" if not os.path.exists(socket_path): raise RuntimeError("api socket 不存在:%s" % socket_path) body = b"" @@ -121,8 +136,12 @@ def _unix_socket_api(socket_path, method, path, payload=None): sock.connect(socket_path) sock.sendall(raw) header, body = _read_header(sock) - content_length = _parse_content_length(header) - body = _read_body(sock, body, content_length) + if _parse_status(header) in (204, 304): + # 体长恒为 0:不读体(keep-alive 下读会等到超时)。 + body = b"" + else: + content_length = _parse_content_length(header) + body = _read_body(sock, body, content_length) finally: sock.close() return _parse_response(header, body) diff --git a/cloud/tests/test_chv_cover100.py b/cloud/tests/test_chv_cover100.py index db7595e..9026131 100644 --- a/cloud/tests/test_chv_cover100.py +++ b/cloud/tests/test_chv_cover100.py @@ -103,6 +103,50 @@ class ApiClientCover100Test(unittest.TestCase): self.assertIn(b'"name": "vm1"', fake.sent) self.assertTrue(fake.closed) + def test_204_without_content_length_is_success(self): + # 真机实测(2026-11-07,CHV 54.0.0):vm.create 成功回 + # 204 No Content,且不带 Content-Length、无体——严格 + # 校验会把合法成功当失败拒掉。204 按 HTTP 语义体长为 + # 0,不读体(keep-alive 下读会等到超时;假 socket 的 + # 剩余片为空,读体即报截断,本测试能抓住误读)。 + fake = _FakeApiSocket([ + b"HTTP/1.1 204 No Content\r\nServer: Cloud Hypervisor API" + b"\r\nConnection: keep-alive\r\n\r\n", + ]) + with tempfile.TemporaryDirectory() as tmp: + sock_path = os.path.join(tmp, "chv.sock") + with open(sock_path, "w") as fh: + fh.write("x") + with mock.patch.object(api_client.socket, "socket", + lambda *a, **k: fake): + status, body = api_client._unix_socket_api( + sock_path, "PUT", "/vm.create", + payload={"cpus": {"boot_vcpus": 2}}) + self.assertEqual(status, 204) + self.assertEqual(body, {}) + self.assertTrue(fake.closed) + + def test_200_without_content_length_still_rejected(self): + # 204 例外不得放宽其它状态:200 无长度仍拒绝。 + fake = _FakeApiSocket([ + b"HTTP/1.1 200 OK\r\nContent-Type: application/json" + b"\r\n\r\n{}", + ]) + with tempfile.TemporaryDirectory() as tmp: + sock_path = os.path.join(tmp, "chv.sock") + with open(sock_path, "w") as fh: + fh.write("x") + with mock.patch.object(api_client.socket, "socket", + lambda *a, **k: fake): + with self.assertRaises(RuntimeError): + api_client._unix_socket_api( + sock_path, "GET", "/vmm.ping") + + def test_parse_status(self): + self.assertEqual(api_client._parse_status("HTTP/1.1 204 foo"), 204) + self.assertEqual(api_client._parse_status("HTTP/1.1 OK"), 0) + self.assertEqual(api_client._parse_status(""), 0) + def test_parse_status_non_numeric_is_zero(self): status, _body = api_client._parse_response( "HTTP/1.1 OK\r\n\r\n", b"{}")