Files
qlib/scripts/dev.sh
T
Simon d1287799f7 fix(dev): 端口探测改为 lsof 优先,修复 dev.sh 误判"未运行"
macOS 上 /usr/local/bin/ss 由 iproute2mac 提供(netstat 包装),输出以 TAB
分隔且 -p 不产出 pid=,导致 listener_pids 恒为空:

- port_in_use 永远为假 → start 重复拉起 uvicorn 并报
  [Errno 48] Address already in use,最终 die "后端启动超时"
- status 误报"未运行",stop 定位不到监听进程

改为 lsof 优先、ss 兜底(保留 Linux 真 iproute2 的 pid= 提取),并用
([^0-9]|$) 界定端口边界以兼容空格/TAB 两种分隔。显式 if 替代
[[ ]] && ——bash 3.2 在 set -e 下会因中途失败的 && 列表直接退出。
2026-09-30 19:37:32 +08:00

216 lines
7.2 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================
# qlib-platform 前后端一键管理脚本
#
# 用法:
# scripts/dev.sh start 启动后端(8000) + 前端(3000)
# scripts/dev.sh stop 停止两者
# scripts/dev.sh restart 重启两者
# scripts/dev.sh status 查看状态
# scripts/dev.sh logs [api|web] 实时查看日志
#
# 环境变量(可选):
# API_PORT 后端端口(默认 8000)
# WEB_PORT 前端端口(默认 3000)
# BACKEND_API_URL 前端同源代理指向的后端地址(默认 http://127.0.0.1:8000,
# 局域网访问前端时改为 http://<本机IP>:8000)
#
# 进程管理说明:pidfile 记录启动进程;停止时同时按「监听端口」定位真实服务进程
# (pnpm/uv 包装进程可能提前退出),并对整进程组停止,避免子进程残留。
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="$ROOT/.run"
LOG_DIR="$ROOT/logs"
mkdir -p "$RUN_DIR" "$LOG_DIR"
API_PORT="${API_PORT:-8000}"
WEB_PORT="${WEB_PORT:-3000}"
BACKEND_API_URL="${BACKEND_API_URL:-http://127.0.0.1:8000}"
API_PID_FILE="$RUN_DIR/backend.pid"
WEB_PID_FILE="$RUN_DIR/frontend.pid"
API_LOG="$LOG_DIR/backend.log"
WEB_LOG="$LOG_DIR/frontend.log"
log() { echo "[dev.sh] $*"; }
die() { echo "[dev.sh] 错误: $*" >&2; exit 1; }
# ---------- 进程探测 ----------
# 监听指定端口的 pid(无则空)
# 取值顺序:lsof → ss。
# 不能把 ss 放在首位:macOS 上 /usr/local/bin/ss 常由 iproute2mac 提供(netstat 包装),
# 其输出以 TAB 分隔且 -p 不产出 pid=,会让匹配恒为空 → port_in_use 永远为假 →
# start 重复拉起进程并报 [Errno 48] Address already in use,status 也误报"未运行"。
# 真实 iproute2(Linux)的 ss 仍作为兜底,此时可从 users:(("x",pid=NNN)) 取到 pid。
listener_pids() { # $1 port
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="$(lsof -t -iTCP:"$1" -sTCP:LISTEN 2>/dev/null || true)"
fi
if [[ -z "$pids" ]] && command -v ss >/dev/null 2>&1; then
# ([^0-9]|$) 界定端口边界:兼容 ss 的空格与 TAB 两种分隔,且避免匹配到更长的端口号
pids="$(ss -tlnp 2>/dev/null | awk -v p=":$1" '
$0 ~ (p "([^0-9]|$)") && /LISTEN/ {
if (match($0, /pid=[0-9]+/)) print substr($0, RSTART+4, RLENGTH-4)
}' || true)"
fi
if [[ -n "$pids" ]]; then
printf '%s\n' "$pids"
fi
return 0
}
port_in_use() { [[ -n "$(listener_pids "$1")" ]]; }
pidfile_alive() { [[ -f "$1" ]] && kill -0 "$(cat "$1")" 2>/dev/null; }
# 后台启动命令:Linux 用 setsid 脱离会话;macOS 无 setsid(BSD)则退回 nohup。
# 两种方式都写 pidfile + 按端口定位,stop 逻辑不依赖进程组。
spawn_bg() { # $1 日志文件;其余为命令
local logfile="$1"; shift
if command -v setsid >/dev/null 2>&1; then
setsid nohup "$@" >>"$logfile" 2>&1 &
else
nohup "$@" >>"$logfile" 2>&1 &
fi
}
# 杀掉指定 pid 及其整进程组(uvicorn --reload / next dev 的子进程一起清理)
# 注意:macOS 无 setsid 时子进程与当前脚本同进程组,此时禁止按 pgid 杀,
# 否则会连带杀掉调用方自身;改为逐 pid + 子进程清理。
kill_tree() { # $1 pid
local pid pgid self_pgid
pid="$1"
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
self_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ' || true)"
if [[ -n "${pgid:-}" && "$pgid" != "${self_pgid:-}" ]]; then
kill -- "-$pgid" 2>/dev/null || true
fi
# 先清理子进程再杀父进程:父进程先退出会让其子进程被 reparent,pkill -P 不再匹配
pkill -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
}
# ---------- 启动 ----------
start_api() {
if port_in_use "$API_PORT"; then
log "后端已在 :$API_PORT 运行(pid $(listener_pids "$API_PORT" | head -1))"
listener_pids "$API_PORT" | head -1 > "$API_PID_FILE"
return 0
fi
log "启动后端 :${API_PORT}(日志 ${API_LOG})"
( cd "$ROOT/backend"
spawn_bg "$API_LOG" uv run uvicorn app.main:app --host 0.0.0.0 --port "$API_PORT" --reload
) || true
for _ in $(seq 1 40); do
if port_in_use "$API_PORT"; then
listener_pids "$API_PORT" | head -1 > "$API_PID_FILE"
return 0
fi
sleep 0.5
done
die "后端启动超时,请查看 $API_LOG"
}
start_web() {
if port_in_use "$WEB_PORT"; then
log "前端已在 :$WEB_PORT 运行(pid $(listener_pids "$WEB_PORT" | head -1))"
listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE"
return 0
fi
log "启动前端 :${WEB_PORT}(日志 ${WEB_LOG})"
( cd "$ROOT/frontend/web"
export BACKEND_API_URL
# pnpm exec next 直接运行,避免 package.json dev 脚本自带 -p 3000 与端口参数重复
# -H 0.0.0.0:显式监听所有网卡(局域网可用 http://<本机IP>:3000 访问)
spawn_bg "$WEB_LOG" pnpm exec next dev -H 0.0.0.0 -p "$WEB_PORT"
) || true
for _ in $(seq 1 80); do
if port_in_use "$WEB_PORT"; then
listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE"
return 0
fi
sleep 0.5
done
die "前端启动超时,请查看 $WEB_LOG"
}
# ---------- 停止 ----------
stop_service() { # $1 pidfile $2 port $3 名称
local killed=0
if pidfile_alive "$1"; then
local pid
pid="$(cat "$1")"
log "停止 $3 (pid $pid)"
kill_tree "$pid"
killed=1
fi
local lp
for lp in $(listener_pids "$2"); do
log "停止 $3 监听进程 (pid $lp)"
kill_tree "$lp"
killed=1
done
if [[ "$killed" == 0 ]]; then
log "$3 未在运行"
fi
for _ in $(seq 1 20); do
port_in_use "$2" || break
sleep 0.3
done
if pidfile_alive "$1"; then kill -9 "$(cat "$1")" 2>/dev/null || true; fi
for lp in $(listener_pids "$2"); do kill -9 "$lp" 2>/dev/null || true; done
rm -f "$1"
log "$3 已停止"
}
status() {
local api_pid web_pid
api_pid="$(listener_pids "$API_PORT" | head -1 | tr -d '\n ' || true)"
web_pid="$(listener_pids "$WEB_PORT" | head -1 | tr -d '\n ' || true)"
# 注意:不用 "${pid:+运行中 pid $pid}${pid:-未运行}" —— bash 3.2(macOS 自带)
# 对 :+ 词内再引用同一变量会重复展开,导致 PID 显示为拼接值。
if [[ -n "$api_pid" ]]; then log "后端: 运行中 pid $api_pid"; else log "后端: 未运行"; fi
if [[ -n "$web_pid" ]]; then log "前端: 运行中 pid $web_pid"; else log "前端: 未运行"; fi
log "访问: 前端 http://127.0.0.1:$WEB_PORT | API http://127.0.0.1:$API_PORT/api/health"
}
# ---------- actions ----------
case "${1:-start}" in
start)
start_api
start_web
status
;;
stop)
stop_service "$API_PID_FILE" "$API_PORT" "后端"
stop_service "$WEB_PID_FILE" "$WEB_PORT" "前端"
;;
restart)
"$0" stop
sleep 1
"$0" start
;;
status)
status
;;
logs)
case "${2:-}" in
api) tail -f "$API_LOG" ;;
web) tail -f "$WEB_LOG" ;;
*) tail -f "$API_LOG" "$WEB_LOG" ;;
esac
;;
*)
echo "用法: $0 {start|stop|restart|status|logs [api|web]}" >&2
exit 1
;;
esac