diff --git a/cloud/server.py b/cloud/server.py index 71014e9..85c0880 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -959,6 +959,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() + # 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再 + # 尝试回 500——响应一旦开始就不能再发第二个状态行。 + self._responded = True self.wfile.write(body) def _send_html(self, status, html_body): @@ -967,6 +970,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() + self._responded = True self.wfile.write(body) def _read_body(self): @@ -1006,18 +1010,43 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): # -- 入口 ---------------------------------------------------------- def do_GET(self): - self._dispatch("GET") + self._dispatch_safe("GET") def do_POST(self): - self._dispatch("POST") + self._dispatch_safe("POST") def do_DELETE(self): - self._dispatch("DELETE") + self._dispatch_safe("DELETE") def do_PATCH(self): # PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501), # 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。 - self._dispatch("PATCH") + self._dispatch_safe("PATCH") + + def _dispatch_safe(self, method): + """分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是 + 让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端 + “HTTP 502”的:异常逃出 handle_one_request 后连接无响应, + nginx 只能报 502,客户端拿不到任何可读错误)。 + + 500 体只写固定 internal_error,不把堆栈泄漏给客户端; + 堆栈走 _server_log.exception 进服务端日志。已开始响应 + (SSE 流、_send_json/_send_html 已发头)时不再二次发送, + 只记日志——响应一旦开始就不能再发第二个状态行。 + _responded 每请求重置(同一连接 keep-alive 复用 handler + 对象时不串请求)。 + """ + self._responded = False + try: + self._dispatch(method) + except Exception: + _server_log.exception("unhandled error in %s %s", + method, self.path) + if not self._responded: + try: + self._send_json(500, {"error": "internal_error"}) + except Exception: + pass def _dispatch(self, method): parsed = urlparse(self.path) @@ -2461,6 +2490,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self.end_headers() except (BrokenPipeError, ConnectionResetError): return + self._responded = True # SSE 无 Content-Length,done 后靠关连接界定流结束;注意 # send_header("Connection", "keep-alive") 会把 # close_connection 改回 False,所以必须在 end_headers 之后 diff --git a/cloud/tests/test_handler_500.py b/cloud/tests/test_handler_500.py new file mode 100644 index 0000000..5188a0b --- /dev/null +++ b/cloud/tests/test_handler_500.py @@ -0,0 +1,108 @@ +"""请求处理 500 兜底单测。 + +_dispatch 里未捕获异常必须回 500 JSON,而不是让请求线程直接 +断连(2026-10-27 死锁事件就是这么变成前端“HTTP 502”的)。 +同时验证:500 之后同一连接仍可用(_responded 每请求重置), +服务端记了异常日志。 +""" + +import http.client +import json +import os +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +try: + from cloud.auth import AuthStore + from cloud.server import CloudControlPlaneHandler, create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.server import CloudControlPlaneHandler, create_server + + +class Handler500TestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "owner/repo"}, self.owner_key) + self.assertEqual(status, 201, self.project) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None, conn=None): + own = conn is None + if own: + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + if key is not None: + hdrs["X-API-Key"] = key + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + raw = resp.read() + if own: + conn.close() + try: + parsed = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + parsed = None + return resp.status, parsed + + def test_exception_returns_500_json_not_dropped(self): + # _dispatch 整体抛错:客户端必须拿到 500 + 固定 error 体, + # 而不是连接断开(旧行为:无响应,nginx 转 502)。 + with mock.patch.object( + CloudControlPlaneHandler, "_dispatch", + side_effect=RuntimeError("boom")), \ + mock.patch("cloud.server._server_log") as log: + status, body = self._req( + "GET", "/projects", key=self.owner_key) + self.assertEqual(status, 500) + self.assertEqual(body, {"error": "internal_error"}) + log.exception.assert_called_once() + + def test_connection_usable_after_500(self): + # 同一连接上:先 500,再正常请求须 200——_responded + # 必须每请求重置,不能把连接钉死在已响应状态。 + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + try: + with mock.patch.object( + CloudControlPlaneHandler, "_dispatch", + side_effect=RuntimeError("boom")), \ + mock.patch("cloud.server._server_log"): + status, body = self._req( + "GET", "/projects", key=self.owner_key, conn=conn) + self.assertEqual(status, 500) + self.assertEqual(body, {"error": "internal_error"}) + status, body = self._req( + "GET", "/projects", key=self.owner_key, conn=conn) + self.assertEqual(status, 200) + self.assertIsInstance(body, dict) + finally: + conn.close()