fix(dev): precheck dev ports and avoid pid file locks
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 14:01:22 +08:00
parent 35d63f5b2e
commit d6219f1a0c
3 changed files with 176 additions and 24 deletions

View File

@@ -19,7 +19,7 @@ usage() {
1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;端口被占用时自动接受 SpacetimeDB 建议的最近可用端口。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;需要新启动时会先检测端口并选择最近可用端口。
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
EOF
}
@@ -191,6 +191,144 @@ port_from_listen_addr() {
echo "${listen_addr##*:}"
}
read_digits_from_file() {
local file_path="$1"
node -e '
const fs = require("fs");
const filePath = process.argv[1];
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const digits = fs.readFileSync(filePath, "utf8").replace(/\D/gu, "").slice(0, 20);
if (digits) {
process.stdout.write(digits);
process.exit(0);
}
process.exit(1);
} catch {
sleep(100);
}
}
process.exit(1);
' "${file_path}" 2>/dev/null
}
read_trimmed_first_line_from_file() {
local file_path="$1"
node -e '
const fs = require("fs");
const filePath = process.argv[1];
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const firstLine = fs.readFileSync(filePath, "utf8").split(/\r?\n/u)[0]?.trim() ?? "";
if (firstLine) {
process.stdout.write(firstLine);
process.exit(0);
}
process.exit(1);
} catch {
sleep(100);
}
}
process.exit(1);
' "${file_path}" 2>/dev/null
}
validate_tcp_port() {
local port="$1"
local label="$2"
if ! [[ "${port}" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then
echo "[dev:rust] ${label} 端口无效: ${port}" >&2
exit 1
fi
}
is_tcp_port_available() {
local host="$1"
local port="$2"
node -e '
const net = require("net");
const host = process.argv[1];
const port = Number(process.argv[2]);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
process.exit(2);
}
const server = net.createServer();
server.once("error", () => process.exit(1));
server.once("listening", () => server.close(() => process.exit(0)));
server.listen({ host, port, exclusive: true });
' "${host}" "${port}" >/dev/null 2>&1
}
describe_tcp_port_owner() {
local port="$1"
if command -v powershell.exe >/dev/null 2>&1; then
PORT_FOR_POWERSHELL="${port}" powershell.exe -NoProfile -Command '
$port = [int]$env:PORT_FOR_POWERSHELL
Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 5 |
ForEach-Object {
$process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
$name = if ($process) { $process.ProcessName } else { "unknown" }
"pid=$($_.OwningProcess) name=$name local=$($_.LocalAddress):$($_.LocalPort)"
}
' 2>/dev/null || true
return
fi
if command -v ss >/dev/null 2>&1; then
ss -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true
fi
}
find_nearest_available_port() {
local host="$1"
local requested_port="$2"
local label="$3"
local max_probe_count="${4:-100}"
validate_tcp_port "${requested_port}" "${label}"
local port="${requested_port}"
local probe_count=0
while ((port <= 65535 && probe_count < max_probe_count)); do
if is_tcp_port_available "${host}" "${port}"; then
if [[ "${port}" != "${requested_port}" ]]; then
echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,改用最近可用端口 ${host}:${port}" >&2
fi
echo "${port}"
return
fi
if ((probe_count == 0)); then
echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,开始查找最近可用端口。" >&2
describe_tcp_port_owner "${requested_port}" >&2 || true
fi
port=$((port + 1))
probe_count=$((probe_count + 1))
done
echo "[dev:rust] 无法为 ${label}${host}:${requested_port} 起向后 ${max_probe_count} 个端口内找到可用端口。" >&2
exit 1
}
spacetime_url_record_path() {
local data_dir="$1"
echo "${data_dir}/dev-rust-spacetime-url"
@@ -215,8 +353,7 @@ read_spacetime_pid() {
fi
local pid
pid="$(tr -cd '0-9' <"${pid_file}" | head -c 20)"
if [[ -z "${pid}" ]]; then
if ! pid="$(read_digits_from_file "${pid_file}")"; then
return 1
fi
@@ -230,16 +367,13 @@ try_reuse_existing_spacetime() {
local existing_pid
local recorded_url=""
if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then
return 1
if [[ -f "${url_file}" ]]; then
if ! recorded_url="$(read_trimmed_first_line_from_file "${url_file}")"; then
recorded_url=""
fi
fi
if ! kill -0 "${existing_pid}" 2>/dev/null; then
echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。"
return 1
fi
if [[ ! -f "${url_file}" ]]; then
if [[ -z "${recorded_url}" ]]; then
local start_log
start_log="$(spacetime_start_log_path "${data_dir}")"
if [[ -f "${start_log}" ]]; then
@@ -261,11 +395,9 @@ try_reuse_existing_spacetime() {
fi
fi
if [[ -z "${recorded_url}" ]]; then
echo "[dev:rust] 发现运行中的 SpacetimeDB pid=${existing_pid},但未找到 URL 记录: ${url_file}"
echo "[dev:rust] 未找到可复用的 SpacetimeDB URL 记录: ${url_file}"
return 1
fi
else
recorded_url="$(head -n 1 "${url_file}" | tr -d '\r' | xargs)"
fi
if [[ -z "${recorded_url}" ]]; then
@@ -277,10 +409,24 @@ try_reuse_existing_spacetime() {
SPACETIME_SERVER="${recorded_url}"
SPACETIME_PORT="$(port_from_listen_addr "${recorded_url}")"
SPACETIME_REUSED_EXISTING=1
echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})"
if existing_pid="$(read_spacetime_pid "${data_dir}")"; then
echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})"
else
echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid 文件暂不可读)"
fi
return 0
fi
if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then
echo "[dev:rust] URL 不在线且 spacetime.pid 暂不可读: ${recorded_url},将检查 data-dir 占用。"
return 1
fi
if ! kill -0 "${existing_pid}" 2>/dev/null; then
echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。"
return 1
fi
echo "[dev:rust] pid=${existing_pid} 存在,但 URL 不在线: ${recorded_url},将重新启动 SpacetimeDB。"
return 1
}
@@ -760,15 +906,17 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
exit 1
fi
SPACETIME_PORT="$(find_nearest_available_port "${SPACETIME_HOST}" "${SPACETIME_PORT}" "SpacetimeDB")"
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
SPACETIME_START_LOG="$(spacetime_start_log_path "${SPACETIME_DATA_DIR}")"
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
: >"${SPACETIME_START_LOG}"
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
# 当目标端口被占用时SpacetimeDB 会询问是否使用最近的可用端口;
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
printf '\n' | spacetime \
# 启动前已经由脚本选定端口,避免 api-server 和 SpacetimeDB 对数据库地址认知不一致。
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
@@ -815,8 +963,11 @@ fi
echo "[dev:rust] 启动 api-server"
load_api_server_env_files
API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server")"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}"
(
cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \