Reclaim own port holders before agent start and install #456
File diff suppressed because one or more lines are too long
+102
-3
@@ -893,6 +893,81 @@ port_owner() {
|
||||
fi
|
||||
}
|
||||
|
||||
# port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。
|
||||
port_owner_pid() {
|
||||
local line pid
|
||||
line="$(port_owner)"
|
||||
[ -n "$line" ] || return 1
|
||||
pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)"
|
||||
[ -n "$pid" ] || return 1
|
||||
printf '%s' "$pid"
|
||||
}
|
||||
|
||||
# cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN;
|
||||
# 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。
|
||||
cmdline_matches_own() {
|
||||
local pid="$1" mark base cmd
|
||||
mark="${SVC_PIDCHECK:-}"
|
||||
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
|
||||
[ -z "$mark" ] && mark="${ENV_BIN:-}"
|
||||
[ -n "$mark" ] || return 1
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
|
||||
case "$cmd" in *"$mark"*) return 0 ;; esac
|
||||
base="$(basename "$mark" 2>/dev/null || true)"
|
||||
[ -n "$base" ] && [ "$base" != "$mark" ] || return 1
|
||||
case "$cmd" in
|
||||
*"$base"*)
|
||||
[ -n "${ENV_DIR:-}" ] || return 1
|
||||
case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。
|
||||
# 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。
|
||||
find_own_in_ancestry() {
|
||||
local cur="$1" i=0 next
|
||||
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do
|
||||
if cmdline_matches_own "$cur"; then
|
||||
printf '%s' "$cur"
|
||||
return 0
|
||||
fi
|
||||
next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)"
|
||||
[ -n "$next" ] && [ "$next" != "$cur" ] || return 1
|
||||
cur="$next"
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。
|
||||
# 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。
|
||||
# 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿,
|
||||
# check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。
|
||||
reclaim_own_port() {
|
||||
local port="${ENV_ADDR##*:}" listener target i
|
||||
[ -n "$port" ] || return 0
|
||||
port_listening "$port" || return 0
|
||||
listener="$(port_owner_pid 2>/dev/null || true)"
|
||||
[ -n "$listener" ] || return 1
|
||||
target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)"
|
||||
if [ -n "$target" ]; then
|
||||
c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收"
|
||||
proc_stop "$target"
|
||||
rm -f "$ENV_PID"
|
||||
hist_event stop ok "$target" "reclaim_own_port"
|
||||
for i in $(seq 1 50); do
|
||||
port_listening "$port" || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用"
|
||||
return 1
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
|
||||
@@ -1057,9 +1132,19 @@ svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-de
|
||||
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
|
||||
|
||||
check_port_free() {
|
||||
local owner; owner="$(port_owner)"
|
||||
# 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。
|
||||
reclaim_own_port || true
|
||||
local owner pid own
|
||||
owner="$(port_owner)"
|
||||
if [ -n "$owner" ]; then
|
||||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||||
pid="$(port_owner_pid 2>/dev/null || true)"
|
||||
own=""
|
||||
[ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)"
|
||||
if [ -n "$own" ]; then
|
||||
c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner"
|
||||
else
|
||||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||||
fi
|
||||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||||
return 1
|
||||
fi
|
||||
@@ -1403,6 +1488,11 @@ resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/
|
||||
|
||||
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
|
||||
local srcbin="$1" srcplug="$2"
|
||||
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
|
||||
if ! reclaim_own_port; then
|
||||
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_BIN_DIR"
|
||||
rm -rf "$ENV_DIR/plugins"
|
||||
cp -f "$srcbin" "$ENV_BIN" || return 1
|
||||
@@ -3329,11 +3419,15 @@ do_stop() {
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then
|
||||
c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例"
|
||||
# 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
if ! pid_alive "$pid"; then
|
||||
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
|
||||
rm -f "$ENV_PID"
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
|
||||
return 0
|
||||
fi
|
||||
@@ -3355,9 +3449,14 @@ do_stop() {
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
do_stop_quiet() {
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi
|
||||
if [ -z "$pid" ]; then
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
if ! pid_alive "$pid"; then
|
||||
rm -f "$ENV_PID"
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -437,6 +437,61 @@ class SvcAutoRecoverTests(unittest.TestCase):
|
||||
thief.terminate()
|
||||
thief.wait(timeout=10)
|
||||
|
||||
# ---- 4b. 本服务孤儿占口(无 pidfile)→ start 回收后成功;外部占口仍拒 ----
|
||||
|
||||
def _wait_port(self, port, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
s = socket.socket()
|
||||
s.settimeout(0.2)
|
||||
try:
|
||||
s.connect(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
s.close()
|
||||
return False
|
||||
|
||||
def test_start_reclaims_own_orphan_without_pidfile(self):
|
||||
"""模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。"""
|
||||
port = 18812
|
||||
self.seed_agent("dev", port)
|
||||
self.start_ok("dev")
|
||||
old = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(old))
|
||||
# 只删 pidfile、不杀进程——对齐 2026-10-12 genui 现场(list=未运行、端口仍监听)。
|
||||
(self.runtime("dev") / "svc.pid").unlink()
|
||||
self.assertIsNone(self.pid_of("dev"))
|
||||
self.assertTrue(self.pid_alive(old))
|
||||
self.assertTrue(self._wait_port(port))
|
||||
result = self.start_ok("dev")
|
||||
self.assertIn("残留", result.stdout)
|
||||
new = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(new), result.stdout)
|
||||
self.assertNotEqual(new, old)
|
||||
self.assertFalse(self.pid_alive(old))
|
||||
stop_events = self.history_events("dev", "stop")
|
||||
self.assertTrue(any("reclaim_own_port" in l for l in stop_events), stop_events)
|
||||
|
||||
def test_start_rejects_foreign_port_occupant(self):
|
||||
"""外部 python http.server 占口:start 必须失败并提示非本服务。"""
|
||||
port = 18813
|
||||
self.seed_agent("dev", port)
|
||||
thief = subprocess.Popen(
|
||||
["python3", "-m", "http.server", str(port), "--bind", "127.0.0.1"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.addCleanup(thief.terminate)
|
||||
self.assertTrue(self._wait_port(port), "thief 未能监听")
|
||||
try:
|
||||
result = self.svc("dev", "start", check=False)
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
self.assertIn("非本服务", result.stdout)
|
||||
self.assertFalse(self.pid_alive(self.pid_of("dev")))
|
||||
finally:
|
||||
thief.terminate()
|
||||
thief.wait(timeout=10)
|
||||
|
||||
# ---- 5. watchdog:单元文件生成(--print 走 main 分发全链路)----
|
||||
|
||||
def test_watchdog_print_plan(self):
|
||||
|
||||
Reference in New Issue
Block a user