fix(gateway): 端口冲突报错友好化——Python 层 EADDRINUSE 一行化,dev 脚本启动前预检并给出占用进程与处理建议
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
+72
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user