From 6953c3243475f3bdbbb11e9292f0d3431c6db887 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 09:33:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E7=AB=AF=E5=8F=A3=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E6=8A=A5=E9=94=99=E5=8F=8B=E5=A5=BD=E5=8C=96=E2=80=94?= =?UTF-8?q?=E2=80=94Python=20=E5=B1=82=20EADDRINUSE=20=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E5=8C=96=EF=BC=8Cdev=20=E8=84=9A=E6=9C=AC=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=89=8D=E9=A2=84=E6=A3=80=E5=B9=B6=E7=BB=99=E5=87=BA=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E8=BF=9B=E7=A8=8B=E4=B8=8E=E5=A4=84=E7=90=86=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- browser_gateway/server/http.py | 24 ++++++++++- browser_gateway/test_gateway.py | 30 ++++++++++++++ scripts/dev-gateway.mjs | 73 ++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/browser_gateway/server/http.py b/browser_gateway/server/http.py index ac513c8..ba6e575 100644 --- a/browser_gateway/server/http.py +++ b/browser_gateway/server/http.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno import hmac import json import logging @@ -1026,6 +1027,27 @@ def load_stable_node_id(state_dir: str, configured: str) -> str: return value +def format_listen_address(address: tuple[str, int]) -> str: + host, port = address + shown = host or "0.0.0.0" + if ":" in shown: + shown = f"[{shown}]" + return f"{shown}:{port}" + + +def create_server(config: dict, gateway: Gateway) -> GatewayHTTPServer: + try: + return GatewayHTTPServer(config["listen"], gateway) + except OSError as exc: + if exc.errno != errno.EADDRINUSE: + raise + raise SystemExit( + "browser gateway cannot listen on " + f"{format_listen_address(config['listen'])}: address already in use; " + "stop the process holding the port or change LISTEN_ADDR" + ) from exc + + def run() -> None: config = load_config() if os.geteuid() == 0: @@ -1045,7 +1067,7 @@ def run() -> None: external_display=config["external_display"], ) gateway = Gateway(runtimes, config["token"], node_id) - server = GatewayHTTPServer(config["listen"], gateway) + server = create_server(config, gateway) LOG.info( json.dumps( { diff --git a/browser_gateway/test_gateway.py b/browser_gateway/test_gateway.py index 10fa03d..75dc8e2 100644 --- a/browser_gateway/test_gateway.py +++ b/browser_gateway/test_gateway.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import errno import io import json import socket @@ -53,6 +54,8 @@ from .proxy import ( gateway_module = import_module(f"{__package__}.server.http") douyin_module = import_module(f"{__package__}.platform.douyin") proxy_module = import_module(f"{__package__}.proxy") +create_server = gateway_module.create_server +format_listen_address = gateway_module.format_listen_address Gateway = gateway_module.Gateway RequestError = gateway_module.RequestError decode_generation = gateway_module.decode_generation @@ -208,6 +211,33 @@ class GatewayValidationTests(unittest.TestCase): ) + def test_create_server_reports_conflicting_listen_address(self) -> None: + gateway = Gateway.__new__(Gateway) + with patch.object( + gateway_module, + "GatewayHTTPServer", + side_effect=OSError(errno.EADDRINUSE, "Address already in use"), + ): + with self.assertRaises(SystemExit) as ctx: + create_server({"listen": ("", 8081)}, gateway) + message = str(ctx.exception) + self.assertIn("0.0.0.0:8081", message) + self.assertIn("address already in use", message) + self.assertIn("LISTEN_ADDR", message) + with patch.object( + gateway_module, + "GatewayHTTPServer", + side_effect=OSError(errno.EACCES, "Permission denied"), + ): + with self.assertRaises(OSError) as denied: + create_server({"listen": ("127.0.0.1", 8081)}, gateway) + self.assertEqual(denied.exception.errno, errno.EACCES) + + def test_format_listen_address(self) -> None: + self.assertEqual(format_listen_address(("", 8081)), "0.0.0.0:8081") + self.assertEqual(format_listen_address(("127.0.0.1", 28187)), "127.0.0.1:28187") + self.assertEqual(format_listen_address(("::1", 8081)), "[::1]:8081") + def test_http_routes_and_body_validation(self) -> None: handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler) gateway = Mock() diff --git a/scripts/dev-gateway.mjs b/scripts/dev-gateway.mjs index f434804..bdd3f71 100644 --- a/scripts/dev-gateway.mjs +++ b/scripts/dev-gateway.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node // gateway: 直接运行开发用 native browser gateway;不依赖 systemd。 -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,6 +27,76 @@ const env = { ...process.env, ...rootEnv, ...gatewayEnv }; if (!env.LISTEN_ADDR) env.LISTEN_ADDR = "127.0.0.1:28187"; if (!env.GATEWAY_TOKEN) env.GATEWAY_TOKEN = "dev-creatorhub-gateway-token"; +// 与 Python 侧 split_listen_address 对齐。 +function splitListenAddr(value) { + let rest = value; + let host = ""; + if (rest.startsWith(":")) { + rest = rest.slice(1); + } else if (rest.startsWith("[")) { + const closing = rest.indexOf("]:"); + if (closing <= 1) throw new Error(`LISTEN_ADDR 必须是 host:port:${value}`); + host = rest.slice(1, closing); + rest = rest.slice(closing + 2); + } else { + const sep = rest.lastIndexOf(":"); + if (sep < 0) throw new Error(`LISTEN_ADDR 必须是 host:port:${value}`); + host = rest.slice(0, sep); + rest = rest.slice(sep + 1); + } + const port = Number(rest); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`LISTEN_ADDR 端口必须是 1..65535:${value}`); + } + return { host, port }; +} + +function probePort(host, port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", (err) => resolve(err)); + server.listen({ host: host || undefined, port, exclusive: true }, () => { + server.close(() => resolve(null)); + }); + }); +} + +// 通过 ss -ltnp 找出占用端口的进程;失败或拿不到进程信息时返回 []。 +function findOccupants(port) { + try { + const out = execFileSync("ss", ["-ltnp"], { encoding: "utf8" }); + const occupants = []; + for (const line of out.split("\n")) { + if (!line.includes(`:${port} `)) continue; + for (const match of line.matchAll(/"([^"]+)",pid=(\d+)/g)) { + occupants.push(`${match[1]} (pid ${match[2]})`); + } + } + return [...new Set(occupants)]; + } catch { + return []; + } +} + +async function assertListenAddrFree(value) { + const { host, port } = splitListenAddr(value); + const shown = host || "0.0.0.0"; + const err = await probePort(host, port); + if (!err) return; + if (err.code !== "EADDRINUSE") return; // 其余错误交给 Python 侧原样报出。 + const occupants = findOccupants(port); + console.error(`[gateway] 监听地址 ${shown}:${port} 已被占用,gateway 无法启动。`); + if (occupants.length > 0) { + console.error(`[gateway] 占用进程:${occupants.join("、")}(常见原因:上一个 gateway 实例还在运行)。`); + console.error(`[gateway] 处理:先停掉占用进程,或用 LISTEN_ADDR 换一个端口(优先级:~/.config/creatorhub/browser-gateway.env > .env)。`); + } else { + console.error(`[gateway] 无法识别占用进程(可能需要 root 才能查看),可用命令:ss -ltnp | grep :${port}`); + } + process.exit(1); +} + +await assertListenAddrFree(env.LISTEN_ADDR); + const gateway = spawn("python3", ["-m", "browser_gateway.server.http"], { cwd: root, env,