diff --git a/Makefile b/Makefile index 8cb0a2e4..b01bdcef 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PLUGINS := plugins COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO) -.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version +.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version version: @echo "agent-system $(VERSION)" @@ -67,6 +67,8 @@ help: @echo "make svc-commits 列出可部署提交:make svc-commits SRC=dev|release [N=20]" @echo "make svc-history 事件历史:make svc-history [ENV] [N=20](完整文档:docs/svc.md)" @echo "make svc-update 自动更新过时服务:make svc-update [NAMES='dev pg1'] [ARGS='--check --all']" + @echo "make svc-recover 自恢复:拉回 autostart=1 但已死的服务:make svc-recover [ARGS='dev --quiet']" + @echo "make svc-watchdog 自恢复看护:make svc-watchdog ARGS='install|remove|status'(systemd 用户 timer)" @echo "make dev 开发模式说明(前后端分离热更新)" @echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)" @echo "make status submodule 状态" @@ -175,6 +177,12 @@ svc-commits: svc-history: scripts/svc.sh history $(ENV) $(N) +svc-recover: + scripts/svc.sh recover $(ARGS) + +svc-watchdog: + scripts/svc.sh watchdog $(ARGS) + svc-update: scripts/svc.sh update $(NAMES) $(ARGS) diff --git a/docs/svc.md b/docs/svc.md index 9fd50aa7..ec8deea2 100644 --- a/docs/svc.md +++ b/docs/svc.md @@ -426,14 +426,71 @@ scripts/svc.sh update [name...] [--check] [--all] [--no-fetch] 任一服务失败不中断其余服务;最终汇总 `成功/失败/跳过`。退出码:0 全部成功或最新; 1 有失败;2 用法错误。 -### 6.5 help +### 6.5 recover(自恢复:拉回死亡服务) + +```bash +scripts/svc.sh recover # 扫描全部已登记服务 +scripts/svc.sh recover dev pg1 # 点名恢复 +scripts/svc.sh recover --quiet # 静默(watchdog timer 用) +``` + +svc 服务是裸后台进程(nohup):进程崩溃、被 OOM killer 杀掉或机器重启后, +**没有人把它拉回来**——recover 补上这一环。 + +判定依据是 svc.env 的 **autostart 期望态键**: + +| 动作 | autostart | +|---|---| +| start / restart / deploy / update 健康检查通过 | 置 `1`(期望在运行) | +| 显式 `stop`(含 deploy/update 的前置停机) | 移除(保持停止是明确意图) | +| 进程自行死亡(崩溃 / OOM 被杀 / 重启后) | 保留 → recover 据此拉回 | +| `rm` 注销 | 键随 svc.env 消失,不再触碰 | + +行为细节: + +- 扫描顺序 postgres 在前、agent/static 在后(绑定实例先起,agent 重建 DSN 才有效); +- 只对 `autostart=1` 且 pidfile+cmdline 校验已死的服务执行**裸 start**(复用持久化 + `exec=` 与数据库绑定,不改任何配置、不重建产物); +- 逐服务持 `runtime//.start.lock`(非阻塞):deploy(含构建窗口)/手动 start + 持锁期间跳过本轮,不抢跑; +- 每次尝试写 `recover ok|fail` 事件(`history` 可追溯);进程反复即退时自然限频 + 约 1 次/分钟(健康探测 20s + timer 周期); +- deploy/update **失败**(构建失败、预检拒绝)→ 服务保持停止等人工介入,watchdog + 不盲目复活(前置停机已清 autostart); +- 端口被外部进程占用时 start 报错放弃(不抢不杀),下轮重试。 + +### 6.6 watchdog(自恢复看护 timer) + +```bash +scripts/svc.sh watchdog install # 安装并启用 systemd 用户 timer(每分钟 recover) +scripts/svc.sh watchdog install --print # 只打印单元文件与计划 +scripts/svc.sh watchdog remove # 停用并删除 timer +scripts/svc.sh watchdog status # timer 状态 + autostart=1 的服务清单 +``` + +- 单元写入 `~/.config/systemd/user/agent-system-svc-recover.{service,timer}` + (`OnBootSec=1min` + `OnUnitActiveSec=60s`),`ExecStart=<本仓>/scripts/svc.sh recover --quiet`; +- 开机自启依赖 linger(`sudo loginctl enable-linger `);未开时安装会提示, + 登出不受影响(KillUserProcesses 默认关,进程本就不随登出死); +- 换检出目录后重装一次即可(status 会提示 timer 指向的 ROOT 与当前不一致); +- 无 systemd 用户会话的环境(如某些容器)install 会拒绝并给出 cron 降级命令: + `* * * * * /scripts/svc.sh recover --quiet >/dev/null 2>&1`。 + +一键自恢复配方: + +```bash +scripts/svc.sh dev deploy # 启动成功 → autostart=1 +scripts/svc.sh watchdog install # 之后 kill -9 / OOM / 重启,约 1 分钟内自动拉回 +``` + +### 6.7 help ```bash scripts/svc.sh # 一屏速览(短帮助) scripts/svc.sh -h # 同上 scripts/svc.sh --help # 完整帮助(全部命令/参数/约束) scripts/svc.sh help # 分主题清单 -scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config examples safety +scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config recovery examples safety ``` ## 7. 命令参考:数据库 diff --git a/scripts/svc.sh b/scripts/svc.sh index 01bbb6cf..84db2bb5 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -23,6 +23,9 @@ # scripts/svc.sh commits [dev|release] [N] 可部署提交列表(默认 dev,N=20) # scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条) # scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关) +# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起 +# (崩溃/OOM 被杀/重启后由 watchdog 定时调用) +# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover) # scripts/svc.sh help [主题] 分主题详细帮助 # # ════════════════════ 部署来源(agent/static 的 deploy)════════════════════ @@ -69,6 +72,7 @@ # 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC__ADDR 可覆盖 # - runtime//:bin|htdocs|data|pgdata|plugins|backups + svc.pid + # svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流) +# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态) # - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库); # 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物 # @@ -91,6 +95,8 @@ # scripts/svc.sh db restore pg2 --file --db app --force # scripts/svc.sh list --tag core && scripts/svc.sh history # scripts/svc.sh info && scripts/svc.sh info pg1 --reveal # 全景报告 / 单服务深度(含掩码值) +# scripts/svc.sh watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回 +# kill -9 && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复 # scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge # make svc ENV=dev ARGS="--env .env.dev --port 9000" # @@ -121,8 +127,11 @@ SVC_ENVFILE="" SRC_DIR="${SVC_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/src}" # 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。 -c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; } -c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; } +c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; } +c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; } +# c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败 +# 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。 +c_event() { printf '\033[1;36m[svc]\033[0m %s\n' "$*" >&2; } c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; } c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; } @@ -150,6 +159,8 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po scripts/svc.sh commits [dev|release] [N] 可部署提交 scripts/svc.sh history [name] [N] 事件历史 scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态) + scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务 + scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover) 帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle db dbops config examples safety) 文档:docs/svc.md @@ -157,7 +168,7 @@ EOF } # HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。 -HELP_TOPICS="model deploy lifecycle db dbops config examples safety" +HELP_TOPICS="model deploy lifecycle db dbops config recovery examples safety" cmd_help() { local topic="${1:-}" @@ -196,6 +207,7 @@ update deploy stop start restart logs help info version。 svc.log | pg.log 日志(postgres 用 pg.log) svc.env 登记 + 持久化配置(dotenv,600) history.jsonl 事件流(append-only,一行一个 JSON) + .start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态) svc.env 字段: kind agent|static|postgres(旧登记无此键视为 agent) @@ -210,6 +222,8 @@ svc.env 字段: pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory) exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。 只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键 + autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留—— + recover/watchdog 据此拉回,见 help recovery) db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键 dbref agent 托管数据库绑定 /<库名>(启动时由实例登记重建 DSN) dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用) @@ -220,6 +234,7 @@ history.jsonl 事件(scripts/svc.sh history 查看): create db create / 物理克隆登记 deploy begin|ok|fail(含来源) start ok|fail|warn(fail 含原因:进程即退/健康检查超时) + recover ok|fail(watchdog/手动 recover 拉回死亡服务) stop ok|fail set 登记变更(addr 旧 → 新) backup ok|fail(文件与大小) @@ -308,6 +323,10 @@ EOF scripts/svc.sh rm 停止并移除登记(目录/数据/历史保留) scripts/svc.sh rm --purge 连 runtime// 整个目录一起删除 +自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery): + scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起 + scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover + 自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关): scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较) scripts/svc.sh update 更新所有"过时且运行中"的服务 @@ -435,6 +454,39 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只 scripts/svc.sh config [file|--clear] 更换/移除/查看(见 help lifecycle); 切换前 schema 校验(agent-system config explain -config ),运行中自动重启 即时生效;登记于 svc.env conffile=,info/status 可见。 +EOF + return 0 ;; + recovery) + cat >&2 <<'EOF' +【recovery】自恢复:autostart 期望态 + recover + watchdog + +期望运行态(autostart 键,svc.env): + - start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1 + - 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图) + - 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复 + - rm 注销后无 svc.env,recover 不再触碰 + + scripts/svc.sh recover [name] [--quiet] + 扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起), + 对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start + (复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。 + 逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。 + 进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。 + + scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover): + ~/.config/systemd/user/agent-system-svc-recover.{service,timer} + (OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖 + loginctl enable-linger,未开时安装会提示) + scripts/svc.sh watchdog remove 停用并移除 timer + scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单 + 无 systemd 用户会话的环境可退化为 cron: + * * * * * /scripts/svc.sh recover --quiet >/dev/null 2>&1 + +语义边界: + - recover 只用既有登记拉起,不重建产物、不改变任何配置; + - deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入, + watchdog 不盲目复活(前置停机已清 autostart); + - 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。 EOF return 0 ;; examples) @@ -539,7 +591,7 @@ CLI_SEED_OWNER="" CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0 CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0 CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT="" -CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0 +CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=0 CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH="" CLI_TAGS=() @@ -600,7 +652,7 @@ addr_compose() { } # ---- 名称与端口 ---- -RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version " +RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog " # valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。 valid_env() { @@ -783,6 +835,36 @@ port_owner() { fi } +# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)---- +# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s), +# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。 +# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。 +SVC_LOCK_HELD=0 +svc_lock() { + [ "$SVC_LOCK_HELD" = 1 ] && return 0 + command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; } + mkdir -p "$ENV_DIR" 2>/dev/null || return 1 + exec 9>>"$ENV_DIR/.start.lock" || return 1 + if [ "$1" = "-n" ]; then + flock -n 9 || { exec 9>&-; return 1; } + else + flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)" + fi + SVC_LOCK_HELD=1 + return 0 +} +svc_unlock() { + [ "$SVC_LOCK_HELD" = 1 ] || return 0 + exec 9>&- || true + SVC_LOCK_HELD=0 + return 0 +} + +# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 1, +# 显式 stop 移除;进程自行死亡不动——recover 据此判断是否拉回)。 +svc_mark_autostart() { svc_set_key autostart 1 >/dev/null 2>&1 || true; } +svc_clear_autostart() { svc_del_key autostart 2>/dev/null || true; } + check_port_free() { local owner; owner="$(port_owner)" if [ -n "$owner" ]; then @@ -1641,10 +1723,12 @@ agent_do_start() { fi guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN") fi + # 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续 + # 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。 if [ "${#guard_env[@]}" -gt 0 ];then - nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 & + nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- & else - nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 & + nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- & fi echo $! > "$ENV_PID" # 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。 @@ -1659,6 +1743,7 @@ agent_do_start() { # 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置 #(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。 record_state + svc_mark_autostart c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)" hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}" seed_settings "$phost" "${probe##*:}" @@ -1673,6 +1758,7 @@ agent_do_start() { done # 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。 record_state + svc_mark_autostart c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log" hist_event start fail "$(read_pid)" "健康检查超时" return 1 @@ -1781,13 +1867,14 @@ static_do_start() { SVC_PIDCHECK="--directory $htdocs" SVC_VERSION="" c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs" - nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 & + nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 9>&- & echo $! > "$ENV_PID" local probe_host="$host" [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1" for _ in $(seq 1 50); do if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规) + svc_mark_autostart c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)" hist_event start ok "$(read_pid)" "http://$probe_host:$port/" return 0 @@ -1800,6 +1887,7 @@ static_do_start() { sleep 0.2 done record_state "htdocs=$htdocs" + svc_mark_autostart c_err "[$ENV_NAME] 10s 健康检查未通过" hist_event start fail "$(read_pid)" "健康检查超时" return 1 @@ -1918,7 +2006,7 @@ pg_do_start() { [ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'" c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)" if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \ - -o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then + -o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1 9>&-; then c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log" hist_event start fail "" "pg_ctl start 失败" return 1 @@ -1941,6 +2029,7 @@ pg_do_start() { fi record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \ "authmode=$(svc_get authmode)" "pgdata=$pgdata" + svc_mark_autostart if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))" hist_event start ok "$(read_pid)" "pg_isready ok" @@ -1959,11 +2048,13 @@ pg_do_stop() { if [ -z "$pid" ] || ! pid_alive "$pid"; then c_info "[$ENV_NAME] postgres 未在运行" rm -f "$ENV_PID" + svc_clear_autostart return 0 fi c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)" if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then rm -f "$ENV_PID" + svc_clear_autostart c_ok "[$ENV_NAME] postgres 已停止" hist_event stop ok "$pid" "pg_ctl fast" return 0 @@ -1971,7 +2062,7 @@ pg_do_stop() { c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)" kill -TERM "$pid" 2>/dev/null || true for _ in $(seq 1 100); do - pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; } + pid_alive "$pid" || { rm -f "$ENV_PID"; svc_clear_autostart; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; } sleep 0.1 done c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)" @@ -2697,6 +2788,7 @@ do_stop() { if ! pid_alive "$pid"; then c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" rm -f "$ENV_PID" + svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止 return 0 fi case "$SVC_KIND" in @@ -2705,6 +2797,7 @@ do_stop() { c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" proc_stop "$pid" rm -f "$ENV_PID" + svc_clear_autostart c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; @@ -2712,12 +2805,14 @@ do_stop() { return 0 } -# do_stop_quiet:deploy 前置——未运行时静默返回。 +# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态: +# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。 do_stop_quiet() { local pid; pid="$(read_pid)" - if [ -z "$pid" ]; then return 0; fi + if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi if ! pid_alive "$pid"; then rm -f "$ENV_PID" + svc_clear_autostart return 0 fi case "$SVC_KIND" in @@ -2726,6 +2821,7 @@ do_stop_quiet() { c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" proc_stop "$pid" rm -f "$ENV_PID" + svc_clear_autostart c_ok "[$ENV_NAME] 已停止" hist_event stop ok "$pid" "SIGTERM" ;; @@ -3206,6 +3302,7 @@ cmd_update() { case "$1" in --check) CLI_CHECK=1; shift ;; --all) CLI_ALL=1; shift ;; + --quiet) CLI_QUIET=1; shift ;; --no-fetch) CLI_NOFETCH=1; shift ;; --) shift; break ;; -h|--help) usage; exit 0 ;; @@ -3350,6 +3447,7 @@ cmd_update() { c_info "[$name] 更新:$from → $tov($deploy_from)" hist_event update begin "$(read_pid)" "from=$from to=$tov" CLI_FROM="$deploy_from" + svc_lock wait if agent_do_deploy; then hist_event update ok "$(read_pid)" "from=$from to=$tov" c_ok "[$name] 已更新:$from → $tov" @@ -3359,12 +3457,200 @@ cmd_update() { c_err "[$name] 更新失败($from → $tov),其余服务继续" failed=$((failed + 1)) fi + svc_unlock CLI_FROM="" done c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用" [ "$failed" -eq 0 ] && return 0 || return 1 } +# ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)---- + +# recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。 +recover_targets() { + local n + while IFS= read -r n; do + [ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n" + done < <(registered_names) + while IFS= read -r n; do + [ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n" + done < <(registered_names) +} + +# cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化 +# exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。 +# 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。 +cmd_recover() { + local names=() a + while [ $# -gt 0 ]; do + case "$1" in + --quiet) CLI_QUIET=1; shift ;; + --) shift; break ;; + -h|--help) cat >&2 <<'EOF' +用法:scripts/svc.sh recover [name...] [--quiet] + 自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1 + 且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。 + 通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。 +EOF + return 0 ;; + -*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;; + *) names+=("$1"); shift ;; + esac + done + local targets=() n rc=0 recovered=0 failed=0 skipped=0 + if [ "${#names[@]}" -gt 0 ]; then + for a in ${names[@]+"${names[@]}"}; do + valid_env "$a" || { c_err "非法服务名:$a"; return 2; } + [ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; } + done + # 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。 + local pgs=() others=() + for a in ${names[@]+"${names[@]}"}; do + [ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a") + done + targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"}) + else + while IFS= read -r n; do targets+=("$n"); done < <(recover_targets) + fi + [ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; } + for n in ${targets[@]+"${targets[@]}"}; do + [ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; } + local st; st="$(svc_pid_state "$n")" + if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi + env_config "$n" + if ! svc_lock -n; then + c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过" + skipped=$((skipped + 1)); continue + fi + c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)" + if do_start; then + hist_event recover ok "$(read_pid)" "autostart revival" + recovered=$((recovered + 1)) + else + hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)" + failed=$((failed + 1)); rc=1 + fi + svc_unlock + done + if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then + c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped" + fi + return "$rc" +} + +# ---- watchdog:systemd 用户级 timer,每分钟执行 recover ---- +WD_UNIT_BASE="agent-system-svc-recover" +WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +# wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。 +wd_service_text() { + printf '%s +' \ + "# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \ + "[Unit]" \ + "Description=agent-system svc auto-recovery (restart crashed/killed services)" \ + "" \ + "[Service]" \ + "Type=oneshot" \ + "ExecStart=$1/scripts/svc.sh recover --quiet" \ + "Nice=5" +} +wd_timer_text() { + printf '%s\n' \ + "[Unit]" \ + "Description=Run agent-system svc recovery every minute" \ + "" \ + "[Timer]" \ + "OnBootSec=1min" \ + "OnUnitActiveSec=60s" \ + "AccuracySec=10s" \ + "Unit=${WD_UNIT_BASE}.service" \ + "" \ + "[Install]" \ + "WantedBy=timers.target" +} + +# wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。 +wd_systemd_ok() { + command -v systemctl >/dev/null 2>&1 || return 1 + systemctl --user show-environment >/dev/null 2>&1 +} + +# cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。 +cmd_watchdog() { + local sub="${1:-status}"; [ $# -gt 0 ] && shift || true + case "$sub" in + install) + local print=0 + case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac + local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer" + if [ "$print" = 1 ]; then + c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd" + printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")" + printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)" + wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)" + return 0 + fi + wd_systemd_ok || { + c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer" + c_err " 降级方案(cron,每分钟):" + c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -" + return 1 + } + mkdir -p "$WD_UNIT_DIR" + { wd_service_text "$ROOT"; printf '\n'; } > "$svc" + { wd_timer_text; printf '\n'; } > "$tmr" + systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; } + systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || { + c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; } + c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)" + [ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \ + || c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响" + c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service" + return 0 + ;; + remove|uninstall) + wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; } + if wd_systemd_ok; then + systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true + systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true + fi + rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer" + if wd_systemd_ok; then systemctl --user daemon-reload || true; fi + c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)" + return 0 + ;; + status) + local n auto_list="" + if wd_systemd_ok; then + systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true + local exec_line + exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)" + if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then + c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)" + fi + else + c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)" + fi + while IFS= read -r n; do + [ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n" + done < <(registered_names) + c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}" + return 0 + ;; + -h|--help|help) + cat >&2 <<'EOF' +用法:scripts/svc.sh watchdog install|remove|status [install --print] + install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet) + install --print 只打印将写入的单元文件与计划(不落盘) + remove 停用并删除 timer(服务登记与运行状态不受影响) + status timer 状态 + autostart=1 的服务清单 +EOF + return 0 ;; + *) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;; + esac +} + # ---- main ---- main() { @@ -3407,6 +3693,8 @@ main() { commits) shift; cmd_commits "$@"; exit 0 ;; history) shift; cmd_history "$@"; exit 0 ;; update) shift; cmd_update "$@" || exit $?; exit 0 ;; + recover) shift; cmd_recover "$@" || exit $?; exit 0 ;; + watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;; esac ENV_NAME="$1"; shift # 动作词可省略(省略 = deploy);flags 可直接跟随服务名。 @@ -3432,6 +3720,11 @@ main() { exit 2 fi env_config "$ENV_NAME" + # 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含 + # 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。 + case "$action" in + deploy|stop|start|restart) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;; + esac case "$action" in deploy) case "$SVC_KIND" in diff --git a/tests/test_svc_recover.py b/tests/test_svc_recover.py new file mode 100644 index 00000000..88fda96f --- /dev/null +++ b/tests/test_svc_recover.py @@ -0,0 +1,361 @@ +"""svc.sh 自恢复回归:autostart 期望态 + recover + watchdog(2026-10-11)。 + +验证崩溃(SIGKILL,模拟 OOM 被杀)后 recover 按持久化登记拉回服务、显式 stop +不被复活、并发生命周期锁防抢跑、watchdog 单元生成正确。全部使用临时目录 + +桩二进制(bash + python http.server 模拟 agent-system;HTTP 子进程随父进程 +退出——真实产物是单进程二进制,被杀即端口释放,桩须对齐该语义)。 +""" +import fcntl +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] +SVC_SCRIPT = SOURCE / "scripts" / "svc.sh" + +# 桩 agent-system:version / config explain(环境层 DSN 感知)/ serve(记录参数后 +# 用 http.server 应答健康探测)。TERM 转发给子进程保证 stop 干净;bash 自身不 +# exec——pidcheck(二进制路径)须留在 cmdline。HTTP 子进程轮询 ppid:父进程被 +# SIGKILL 后 0.5s 内自行退出(对齐单进程产物“进程死=端口释放”的语义)。 +STUB = r"""#!/usr/bin/env bash +set -u +trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}" +mkdir -p "$trace_dir" +case "${1:-}" in + version) + echo "stub-agent-system 1.0" + exit 0 ;; + config) + if [ "${2:-}" = "explain" ]; then + echo "config file: (none)" + echo "field value source" + if [ -n "${AGENT_SYSTEM_DSN:-}" ]; then + printf 'dsn postgresql://%s env\n' "$AGENT_SYSTEM_DSN" + else + echo "dsn - default" + fi + fi + exit 0 ;; + serve) + shift + case "${1:-}" in + -h|--help) + echo "Usage: agent-system serve -addr H:P -data DIR -plugins DIR -auth MODE -config FILE" + exit 0 ;; + esac + printf 'args: %s\n' "$*" > "$trace_dir/serve.txt" + addr="" + while [ $# -gt 0 ]; do + case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac + done + port="${addr##*:}"; host="${addr%:*}" + trap 'kill "$child" 2>/dev/null; exit 0' TERM INT + python3 - "$port" "$host" <<'PYSRV' >/dev/null 2>&1 & +import os, sys, threading, time +from http.server import BaseHTTPRequestHandler, HTTPServer +port, host = int(sys.argv[1]), sys.argv[2] +class H(BaseHTTPRequestHandler): + def _ok(self, body=b'{"ok":true}'): + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(body) + def do_GET(self): + self._ok() + def do_HEAD(self): + self._ok(b'') + def do_PUT(self): + n = int(self.headers.get('Content-Length') or 0) + self.rfile.read(n) + self._ok() + def log_message(self, *a): + pass +ppid = os.getppid() +def watch(): + while True: + try: + if os.getppid() != ppid: + os._exit(0) + except Exception: + os._exit(0) + time.sleep(0.3) +threading.Thread(target=watch, daemon=True).start() +try: + HTTPServer((host, port), H).serve_forever() +except Exception: + os._exit(1) +PYSRV + child=$! + wait "$child" + ;; + *) + exit 0 ;; +esac +""" + + +class SvcAutoRecoverTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.mkdtemp(prefix="svc-autorecover-") + self.addCleanup(shutil.rmtree, tmp, True) + self.root = Path(tmp) + (self.root / "scripts").mkdir(parents=True) + shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh") + self.trace = self.root / "trace" + self.stub = self.root / "stub-agent-system" + self.stub.write_text(STUB) + self.stub.chmod(0o755) + (self.root / "plugins").mkdir() + self.env = os.environ.copy() + self.env.update({ + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_TERMINAL_PROMPT": "0", + "GIT_AUTHOR_NAME": "svc recover", "GIT_COMMITTER_NAME": "svc recover", + "GIT_AUTHOR_EMAIL": "svc-test@example.invalid", + "GIT_COMMITTER_EMAIL": "svc-test@example.invalid", + "SVC_STUB_TRACE": str(self.trace), + "SVC_SRC_DIR": str(self.root / "srccache"), + # 隔离 watchdog 单元目录(--print 不落盘,但断言要在受控目录下做)。 + "XDG_CONFIG_HOME": str(self.root / "xdg"), + }) + for key in list(self.env): + if key.startswith("SVC_") and key not in ("SVC_STUB_TRACE", "SVC_SRC_DIR"): + self.env.pop(key) + for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"): + self.env.pop(key, None) + + def tearDown(self): + # 兜底清理:停掉本测试遗留的服务进程,避免端口跨用例泄漏。 + runtime = self.root / "runtime" + if runtime.is_dir(): + for d in sorted(runtime.iterdir()): + if (d / "svc.pid").exists(): + try: + subprocess.run( + ["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"], + cwd=self.root, env=self.env, timeout=30, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except subprocess.TimeoutExpired: + pass # 兜底强杀(见下) + # 直接 SIGKILL 兜底:确保桩进程(及其锁 fd/端口)不跨用例泄漏。 + try: + pidf = d / "svc.pid" + if pidf.exists(): + pid = int(pidf.read_text().strip() or 0) + if pid: + os.kill(pid, 9) + except (ValueError, ProcessLookupError, PermissionError, FileNotFoundError): + pass + + # ---- 夹具 ---- + + def svc(self, *args, check=True): + proc = subprocess.run( + ["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)], + cwd=self.root, env=self.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120) + if check: + self.assertEqual(proc.returncode, 0, proc.stdout) + return proc + + def runtime(self, name): + return self.root / "runtime" / name + + def kv(self, name, key): + envfile = self.runtime(name) / "svc.env" + if not envfile.exists(): + return None + for line in envfile.read_text().splitlines(): + if line.startswith(key + "="): + return line[len(key) + 1:] + return None + + def seed_agent(self, name, port): + d = self.runtime(name) + (d / "bin").mkdir(parents=True, exist_ok=True) + (d / "data").mkdir(exist_ok=True) + (d / "plugins").mkdir(exist_ok=True) + shutil.copy2(self.stub, d / "bin" / "agent-system") + (d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [ + ("kind", "agent"), ("env", name), ("pid", ""), + ("addr", "127.0.0.1:%d" % port), ("tags", ""), + ("source", "local@deadbee"), ("version", "stub"), ("started", ""), + ("pidcheck", str(d / "bin" / "agent-system")), + ("exec", "--addr 127.0.0.1:%d --data %s --plugins %s" + % (port, d / "data", d / "plugins")), + ])) + + def start_ok(self, name): + result = self.svc(name, "start") + self.assertIn("已启动", result.stdout) + return result + + def pid_of(self, name): + pidf = self.runtime(name) / "svc.pid" + if not pidf.exists(): + return None + text = pidf.read_text().strip() + return int(text) if text else None + + def pid_alive(self, pid): + if not pid: + return False + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + + def kill9(self, name): + """SIGKILL 服务主进程(模拟 OOM killer),保留 pidfile 与登记。""" + pid = self.pid_of(name) + self.assertTrue(self.pid_alive(pid), "服务应先在运行") + os.kill(pid, 9) + for _ in range(100): # 等 bash 主进程消失(HTTP 子进程由 ppid 看护自杀) + if not self.pid_alive(pid): + break + import time + time.sleep(0.05) + self.assertFalse(self.pid_alive(pid)) + import time + time.sleep(0.6) + return pid + + def history_events(self, name, event): + f = self.runtime(name) / "history.jsonl" + if not f.exists(): + return [] + out = [] + for line in f.read_text().splitlines(): + if ('"event":"%s"' % event) in line: + out.append(line) + return out + + # ---- 1. 期望态:start 成功置位 / 显式 stop 清除(含进程已死的 stop)---- + + def test_start_records_autostart_and_stop_clears(self): + self.seed_agent("dev", 18801) + self.start_ok("dev") + self.assertEqual(self.kv("dev", "autostart"), "1") + self.svc("dev", "stop") + self.assertIsNone(self.kv("dev", "autostart")) + + def test_stop_on_dead_process_clears_autostart(self): + self.seed_agent("dev", 18802) + self.start_ok("dev") + self.kill9("dev") + # 进程已死但 autostart 保留(等待 recover);显式 stop 表示"保持停止"。 + self.assertEqual(self.kv("dev", "autostart"), "1") + self.svc("dev", "stop") + self.assertIsNone(self.kv("dev", "autostart")) + + # ---- 2. recover:崩溃拉回 / 运行中不动 / 显式停止不复活 / 未启动不碰 ---- + + def test_recover_restarts_sigkilled_service(self): + self.seed_agent("dev", 18803) + self.start_ok("dev") + old_pid = self.kill9("dev") + result = self.svc("recover", "--quiet") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("recover 汇总:拉回 1", result.stdout) # c_info 在 --quiet 下仍输出 + new_pid = self.pid_of("dev") + self.assertTrue(self.pid_alive(new_pid)) + self.assertNotEqual(new_pid, old_pid) + self.assertEqual(self.kv("dev", "autostart"), "1") + ok_events = self.history_events("dev", "recover") + self.assertTrue(any('"result":"ok"' in l for l in ok_events), ok_events) + # 恢复用的仍是持久化 exec=(svc.env 登记未漂移)。 + self.assertIn("--addr 127.0.0.1:18803", self.kv("dev", "exec")) + + def test_recover_noop_while_running(self): + self.seed_agent("dev", 18804) + self.start_ok("dev") + pid = self.pid_of("dev") + result = self.svc("recover") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.pid_of("dev"), pid) # 未重启 + self.assertEqual(self.history_events("dev", "recover"), []) + + def test_recover_respects_explicit_stop(self): + self.seed_agent("dev", 18805) + self.start_ok("dev") + self.svc("dev", "stop") + result = self.svc("recover") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertFalse(self.pid_alive(self.pid_of("dev"))) + self.assertEqual(self.history_events("dev", "recover"), []) + + def test_recover_ignores_never_started_service(self): + self.seed_agent("dev", 18806) # 从未 start:无 autostart 键 + result = self.svc("recover") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertFalse(self.pid_alive(self.pid_of("dev"))) + self.assertEqual(self.history_events("dev", "recover"), []) + + # ---- 3. 并发防护:生命周期锁被持有时 recover 跳过 ---- + + def test_recover_skips_when_lock_held(self): + self.seed_agent("dev", 18807) + self.start_ok("dev") + self.kill9("dev") + lock = self.runtime("dev") / ".start.lock" + with open(lock, "w") as fh: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = self.svc("recover") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("锁被占用", result.stdout) + self.assertFalse(self.pid_alive(self.pid_of("dev"))) + self.assertEqual(self.history_events("dev", "recover"), []) + fcntl.flock(fh, fcntl.LOCK_UN) + # 锁释放后同一命令即可拉回。 + result = self.svc("recover") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertTrue(self.pid_alive(self.pid_of("dev"))) + + # ---- 4. 恢复失败路径:端口被外部进程占用 → fail 事件 + 非零退出 ---- + + def test_recover_fails_when_port_stolen(self): + self.seed_agent("dev", 18808) + self.start_ok("dev") + self.kill9("dev") + thief = subprocess.Popen( + ["python3", "-m", "http.server", "18808", "--bind", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.addCleanup(thief.terminate) + try: + result = self.svc("recover", check=False) + self.assertEqual(result.returncode, 1, result.stdout) + fail_events = self.history_events("dev", "recover") + self.assertTrue(any('"result":"fail"' in l for l in fail_events), fail_events) + 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): + result = self.svc("watchdog", "install", "--print") + self.assertIn( + "ExecStart=%s/scripts/svc.sh recover --quiet" % self.root, result.stdout) + self.assertIn("OnUnitActiveSec=60s", result.stdout) + self.assertIn("OnBootSec=1min", result.stdout) + self.assertIn("WantedBy=timers.target", result.stdout) + # 只打印,不落盘(受控 XDG_CONFIG_HOME 下无单元文件)。 + unit_dir = self.root / "xdg" / "systemd" / "user" + self.assertFalse((unit_dir / "agent-system-svc-recover.timer").exists()) + self.assertFalse((unit_dir / "agent-system-svc-recover.service").exists()) + + def test_recover_reserved_name_rejected(self): + self.seed_agent("recover", 18809) # 保留字不可作服务名 + result = self.svc("recover", "recover", check=False) + self.assertEqual(result.returncode, 2, result.stdout) + + +if __name__ == "__main__": + unittest.main()