Commit 7ea91baee6011e538fc031e5d88fb4e0f39e5d6a
1 parent
ead036cf
ops(wechat): 修复微信回复 watcher 误重启 + 隧道自愈并入看门狗
task.hexdo.com 探测发现指向错了 Mac 的 agent-bridge,域名重新分配后 (详见 docs/2026-08-07-task-hexdo-tunnel-and-wechat-watcher.md),把 task.hexdo.com 隧道健康检查/自愈加进看门狗第 7 节;顺带修复微信自动 回复 watcher 的判活逻辑(`ps -A|grep` 在设备负载下不可靠导致健康进 程被误判重启),改用 pidfile + /proc/cmdline 判活,并修掉 log() 把 每行日志写两遍的小 bug。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
198 additions
and
22 deletions
docs/2026-08-07-task-hexdo-tunnel-and-wechat-watcher.md
0 → 100644
| 1 | +# task.hexdo.com 隧道重新分配 + 微信回复 watcher 误重启修复 | ||
| 2 | + | ||
| 3 | +> 时间:2026-08-07 | ||
| 4 | +> 触发:用户反馈「pixel 的反向代理不能用了 task.hexdo.com」 | ||
| 5 | + | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +## 一、task.hexdo.com 反代排查与重新分配 | ||
| 9 | + | ||
| 10 | +### 起因排查 | ||
| 11 | + | ||
| 12 | +`task.hexdo.com` 最初被以为是 Pixel 手机 `wechat-viewer` 的反代域名,但实际排查发现:这个域名自 **2026-06-15 创建起就一直 CNAME 到 Mac 上 `agent-bridge` 仓库的 `claude-yi` cloudflared 隧道**(源站是 pm2 管理的 bridge 进程,health port 10003),跟手机完全无关——用 Cloudflare API 直接查 DNS 记录的 `created_on` 字段坐实了这一点,不是「本来指手机、后来被改掉」,而是从来没指过手机。 | ||
| 13 | + | ||
| 14 | +Mac 侧当时的故障是两个独立组件都被手动停掉、没人重新拉起: | ||
| 15 | +- pm2 里 `claude-yi` 这个 entry 整个不存在(不是 stopped,是没了) | ||
| 16 | +- launchd job `com.hexdo.cloudflared-claude-yi` 完全没有 bootstrap(`launchctl list` 查不到) | ||
| 17 | + | ||
| 18 | +两边日志都在 2026-07-30 17:18 前后干净 `SIGTERM` 退出,时间戳吻合,判断是一次性手动停止后忘记恢复。修复: | ||
| 19 | +```bash | ||
| 20 | +cd ~/Documents/LemonAI/agent-bridge/agents/claude-yi && ./start.sh | ||
| 21 | +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.hexdo.cloudflared-claude-yi.plist | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +### 架构决策:域名重新分配 | ||
| 25 | + | ||
| 26 | +确认现状后,用户明确决定:`task.hexdo.com` 改专属 Pixel 手机的 `wechat-viewer`,Mac 的 `agent-bridge` 换一个新域名 `bridge.hexdo.com`。用 Cloudflare API(凭证从 Mac `~/.cloudflared/cert.pem` 里解出 `zoneID`/`accountID`/`apiToken`,因为 `cloudflared tunnel route dns --overwrite-dns` 这个版本实测不生效)直接 PATCH DNS 记录完成切换: | ||
| 27 | + | ||
| 28 | +1. Mac 上 `cloudflared tunnel create pixel-wechat-viewer` 生成手机要用的新隧道凭证(`~/.cloudflared/704efd12-a194-45dc-8333-4d6952eeb66f.json`) | ||
| 29 | +2. `cloudflared tunnel route dns claude-yi bridge.hexdo.com` 给 Mac 隧道挂新域名 | ||
| 30 | +3. Cloudflare API PATCH 把 `task.hexdo.com` 的 CNAME 记录改指向新隧道 `704efd12-....cfargotunnel.com` | ||
| 31 | +4. Mac `~/.cloudflared/config.yml` 的 ingress hostname 从 `task.hexdo.com` 改成 `bridge.hexdo.com`,`launchctl kickstart -k` 重连 | ||
| 32 | +5. 新隧道凭证 + `config.yml`(ingress `task.hexdo.com → http://127.0.0.1:5001`)通过 `adb push /sdcard/` 中转(不能直接 push 进 Termux 私有目录,会 Permission denied)+ `su -c cp/chown` 落到手机 `~/.cloudflared/` | ||
| 33 | + | ||
| 34 | +### 踩的坑:进程属主必须是 termux 应用自己的 uid,不能是 root | ||
| 35 | + | ||
| 36 | +手动验证时图省事用 `su -c 'setsid nohup cloudflared ...'` 起了一次隧道——这样起的进程属主是 **root**。但看门狗脚本、cron 调度器实际都以 Termux 应用自己的 uid(当时是 `u0_a329`,数字 uid `10329`)跑,**Android 有进程可见性隔离,非 root 的 `pgrep`/`ps` 看不到 root 拥有的进程**,会导致看门狗永远以为隧道没起、每次都想再起一个,还会因为想写日志到 root 占用的旧文件而权限不够。 | ||
| 37 | + | ||
| 38 | +正确做法:`su -G 3003 <termux_uid> -c '...'` 以 termux 自己的身份起(`-G 3003` 是保住 `inet` 组权限,否则 DNS 会断,这条是这台设备的老坑,见 `~/.claude/projects/.../memory/magisk-su-android-inet-group.md`)。cloudflared 二进制 + `~/.cloudflared/` 下的凭证/配置全是 termux 应用自己拥有,**这个隧道进程完全不需要 root**。 | ||
| 39 | + | ||
| 40 | +### 隧道自愈并入已有看门狗(没有另建新 skill) | ||
| 41 | + | ||
| 42 | +Pixel 上本来就有一个每 10 分钟跑一次的 cron job「WeChat Viewer 看门狗」(`~/.hermes/scripts/wechat-viewer-watchdog.sh`,`no_agent: true`,`deliver: origin`)。给它新增第 7 节: | ||
| 43 | +1. `pgrep` 查 `pixel-wechat-viewer` 隧道进程在不在,不在就起 | ||
| 44 | +2. 进程在不代表隧道通(连接可能卡死/边缘握手失败),实测 `curl https://task.hexdo.com/api/doctor` 才算数 | ||
| 45 | +3. 不通就重启一次隧道进程,重启完还不通就 `echo` 一行诊断信息,不再无限重试 | ||
| 46 | + | ||
| 47 | +**为什么 echo 一行就等于「失联通知」**:Hermes cron 的 `no_agent=true` job 由调度器直接跑 `script`,**stdout 原样投递回创建这个 job 时所在的会话**(`deliver: origin`,源码 `hermes-agent/tools/cronjob_tools.py` 里 `no_agent`/`deliver` 参数的注释写明 "the script IS the job and its stdout is delivered verbatim");stdout 为空则静默不投递(这条 job 已经稳定跑了近 6000 次没有噪音骚扰过,验证过这个假设成立)。所以不需要另外写 `am broadcast` 发微信通知——脚本里但凡有异常就 `echo`,正常就不 `echo`,天然就是失联通知。 | ||
| 48 | + | ||
| 49 | +### 最终状态 | ||
| 50 | + | ||
| 51 | +- `task.hexdo.com` → Pixel 手机 `pixel-wechat-viewer` 隧道 → `127.0.0.1:5001`(wechat-viewer),验证 `curl https://task.hexdo.com/api/doctor` → 200 | ||
| 52 | +- `bridge.hexdo.com` → Mac `claude-yi` 隧道 → `127.0.0.1:10003`(agent-bridge),验证 `curl https://bridge.hexdo.com/health` → 200 | ||
| 53 | +- 手机侧持久化:`~/.termux/boot/start-wechat-viewer`(Termux:Boot 脚本)末尾加了隧道启动命令,重启设备后自动拉起;顺手把该脚本里写死多年的 `fuser -k 5000/tcp` 改成了实际监听端口 `5001`(app.py 早就改用 5001,旧的 kill 命令一直在误杀空气) | ||
| 54 | + | ||
| 55 | +--- | ||
| 56 | + | ||
| 57 | +## 二、微信自动回复 watcher 误重启修复 | ||
| 58 | + | ||
| 59 | +### 症状 | ||
| 60 | + | ||
| 61 | +同一个看门狗脚本第 5 节(微信自动回复 watcher 单例保护)里,`watchdog.log` 天天刷屏「微信 Watcher 存活数=0(期望 1),重置中... 重置后存活数异常: 0」,几乎每个 cron 周期(10 分钟)都触发一次。 | ||
| 62 | + | ||
| 63 | +### 根因:探测本身不可靠,不是进程真的挂了 | ||
| 64 | + | ||
| 65 | +第 5 节原本靠 `su -c 'ps -A' 2>/dev/null | grep -c '[w]echat_reply_watcher'` 判活。现场用同一个 adb 会话连续两条命令直接实锤:`ps -A | grep -c` 报 0,紧接着(不到 1 秒)`ps -A -o PID,ARGS | grep` 却找到了同一个 PID——**探测本身在系统负载下会偶发返回空/截断,哪怕进程明明活着**。这跟 `wechat-reply-watcher-process-leak.md` 记录的 2026-07-25 877 进程雪球事故是同一个底层不可靠因子(那次是"探测漏检 → 没先 pkill 就叠加新实例 → 雪球";这次探测漏检的后果没那么严重,因为 7-25 之后已经加了"重置时先 pkill 清场"的幂等防线,不会再雪球,但会白白重启健康进程,在 1-2 秒空窗期有漏消息风险,且日志噪音很大)。 | ||
| 66 | + | ||
| 67 | +### 修复 | ||
| 68 | + | ||
| 69 | +`wechat_reply_watcher.py`:daemon 启动时把自己 PID 写进 `~/.hermes/wechat_reply/watcher.pid`(`_write_pidfile()`,正常/异常退出时 best-effort 清理,虽然 `pkill` 走 SIGTERM 默认终止不会触发 Python 的 `finally`,所以真正兜底还是靠下面的 `/proc` 存在性检查)。 | ||
| 70 | + | ||
| 71 | +`wechat-viewer-watchdog.sh` 第 5 节判活逻辑换成: | ||
| 72 | +```bash | ||
| 73 | +if [ -f "$WATCHER_PID_FILE" ]; then | ||
| 74 | + WPID=$(cat "$WATCHER_PID_FILE" 2>/dev/null) | ||
| 75 | + if [ -n "$WPID" ] && su -c "test -r /proc/$WPID/cmdline && grep -q wechat_reply_watcher /proc/$WPID/cmdline" 2>/dev/null; then | ||
| 76 | + WATCHER_ALIVE=1 | ||
| 77 | + fi | ||
| 78 | +fi | ||
| 79 | +``` | ||
| 80 | +单次读 `/proc/<pid>/cmdline` 比解析整个 `ps -A` 输出更轻量也更可靠,同时用 cmdline 内容校验防止 PID 复用误判成"还活着"。**"判活为假才重置"这个新逻辑没有削弱 7-25 加的防线**——一旦触发重置,仍然是先 `pkill -f wechat_reply_watcher.py` 清场再起新实例,不单纯信 pidfile 干净。 | ||
| 81 | + | ||
| 82 | +### 顺手修的独立小 bug:日志重复写 | ||
| 83 | + | ||
| 84 | +`log()` 函数同时 `print()`(会被 daemon 启动命令 `>> watcher.log 2>&1` 的 stdout 重定向捕获一次)又显式 `open(LOG_FILE,'a').write()` 同一个文件——每行日志被写两遍。历史 `watcher.log` 里所有行成对重复就是这个原因,纯噪音不影响功能,删掉了显式文件写那段,只留 `print()`。 | ||
| 85 | + | ||
| 86 | +### 验证 | ||
| 87 | + | ||
| 88 | +手动清场(`pkill`)后跑一次看门狗,触发重置成功、pid 记入 pidfile;**立即**再跑第二次看门狗——即使当次 `ps -A | grep -c` 依然复现性地报 0(证明探测本身的不可靠是持续存在的环境事实,不是偶发),pidfile+cmdline 检测正确判定「还活着」,没有触发不必要的重置。`watcher.log` 新写入的行确认不再重复。 | ||
| 89 | + | ||
| 90 | +### 遗留、未处理的问题 | ||
| 91 | + | ||
| 92 | +诊断过程中在手动跑整个看门狗脚本时看到「Gateway 不在线,重启中... `/tmp/hermes-gateway.log: Permission denied`」——这是我手动用 `su -G 3003 <uid> -c` 从 adb 触发时的执行环境差异(缺少 Termux 自身的 `LD_PRELOAD`/路径重写机制),不代表真实 cron 执行环境下也会失败,没有进一步处理,如果之后在真实 `watchdog.log` 里持续看到这条才需要认真排查。 | ||
| 93 | + | ||
| 94 | +--- | ||
| 95 | + | ||
| 96 | +## 涉及文件 | ||
| 97 | + | ||
| 98 | +- `~/.cloudflared/config.yml`(Mac,ingress 改 `bridge.hexdo.com`) | ||
| 99 | +- `~/.cloudflared/pixel-config.yml` → 手机 `~/.cloudflared/config.yml`(新建,ingress `task.hexdo.com`) | ||
| 100 | +- `~/.termux/boot/start-wechat-viewer`(手机,Termux:Boot 脚本,加隧道启动 + 修端口号) | ||
| 101 | +- `~/.hermes/scripts/wechat-viewer-watchdog.sh` = 本仓库 `wechat_plain/scripts/auto-reply/wechat-viewer-watchdog.sh`(新增第 7 节隧道自愈 + 第 5 节判活改用 pidfile) | ||
| 102 | +- `~/.hermes/wechat_reply/wechat_reply_watcher.py` = 本仓库 `wechat_plain/scripts/auto-reply/wechat_reply_watcher.py`(新增 pidfile 写入 + 修日志重复写) | ||
| 103 | + | ||
| 104 | +设备上对应文件均留有 `.bak-20260807*` 备份。 | ||
| 105 | + | ||
| 106 | +## 相关记忆 | ||
| 107 | + | ||
| 108 | +- `task-hexdo-reverse-proxy-outage.md`(pixelphone 项目 memory) | ||
| 109 | +- `task-hexdo-tunnel-clash-direct.md`(agent-bridge 项目 memory,Mac 侧 Clash TUN 劫持等历史故障模式,域名已改成 `bridge.hexdo.com`) | ||
| 110 | +- `wechat-reply-watcher-process-leak.md`(pixelphone 项目 memory,2026-07-25 877 进程雪球事故 + 本次后续修复) |
wechat_plain/scripts/auto-reply/wechat-viewer-watchdog.sh
| @@ -55,24 +55,37 @@ python3 "$APP_DIR/reap_orphan_reports.py" >> "$LOG" 2>&1 | @@ -55,24 +55,37 @@ python3 "$APP_DIR/reap_orphan_reports.py" >> "$LOG" 2>&1 | ||
| 55 | 55 | ||
| 56 | # ── 5. 微信自动回复 Watcher ──────────────────────── | 56 | # ── 5. 微信自动回复 Watcher ──────────────────────── |
| 57 | # 监听微信 DB 变化,写入 incoming.jsonl 队列 | 57 | # 监听微信 DB 变化,写入 incoming.jsonl 队列 |
| 58 | -# 单例保护:每次都先 pkill 清场再起一个,不能只信一次 ps|grep 的探测结果—— | ||
| 59 | -# 探测在系统过载时会漏检/超时返回空,若不清场会在卡死的旧实例上再叠一个, | ||
| 60 | -# 雪球式堆积出几百个进程拖垮整机(2026-07-25 事故:堆到 877 个吃满内存+swap)。 | 58 | +# 单例保护:判活用 pidfile+kill -0(不是 `ps -A | grep`)——2026-08-07 实测 |
| 59 | +# `su -c 'ps -A'` 经 adb/su 转一手会偶发返回空/截断,哪怕进程明明还活着, | ||
| 60 | +# 导致看门狗把健康进程当挂了、每个 cron 周期都重置一次("重置后存活数异常" | ||
| 61 | +# 的假象),既白白重启浪费资源,也在重启的 1-2 秒空窗里可能漏消息。 | ||
| 62 | +# 重置动作本身仍先 pkill 清场再起(不只信 pidfile 干净),避免卡死的旧实例 | ||
| 63 | +# 没被 pidfile 反映出来、跟新实例叠加——2026-07-25 事故就是堆到 877 个进程 | ||
| 64 | +# 吃满内存+swap,这条防线不能丢。 | ||
| 61 | 65 | ||
| 62 | REPLY_DIR=$HOME_DIR/.hermes/wechat_reply | 66 | REPLY_DIR=$HOME_DIR/.hermes/wechat_reply |
| 63 | -WATCHER_COUNT=$(su -c 'ps -A' 2>/dev/null | grep -c '[w]echat_reply_watcher') | 67 | +WATCHER_PID_FILE=$REPLY_DIR/watcher.pid |
| 68 | +WATCHER_ALIVE=0 | ||
| 64 | 69 | ||
| 65 | -if [ "$WATCHER_COUNT" -ne 1 ]; then | ||
| 66 | - echo "[$(date)] 微信 Watcher 存活数=$WATCHER_COUNT(期望 1),重置中..." | tee -a "$LOG" | 70 | +if [ -f "$WATCHER_PID_FILE" ]; then |
| 71 | + WPID=$(cat "$WATCHER_PID_FILE" 2>/dev/null) | ||
| 72 | + if [ -n "$WPID" ] && su -c "test -r /proc/$WPID/cmdline && grep -q wechat_reply_watcher /proc/$WPID/cmdline" 2>/dev/null; then | ||
| 73 | + WATCHER_ALIVE=1 | ||
| 74 | + fi | ||
| 75 | +fi | ||
| 76 | + | ||
| 77 | +if [ "$WATCHER_ALIVE" -ne 1 ]; then | ||
| 78 | + echo "[$(date)] 微信 Watcher 未存活(pidfile 检测),重置中..." | tee -a "$LOG" | ||
| 67 | su -c "pkill -f wechat_reply_watcher.py" 2>/dev/null | 79 | su -c "pkill -f wechat_reply_watcher.py" 2>/dev/null |
| 68 | sleep 1 | 80 | sleep 1 |
| 81 | + rm -f "$WATCHER_PID_FILE" | ||
| 69 | su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_watcher.py >> $REPLY_DIR/watcher.log 2>&1 &" | 82 | su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_watcher.py >> $REPLY_DIR/watcher.log 2>&1 &" |
| 70 | sleep 2 | 83 | sleep 2 |
| 71 | - WATCHER_COUNT2=$(su -c 'ps -A' 2>/dev/null | grep -c '[w]echat_reply_watcher') | ||
| 72 | - if [ "$WATCHER_COUNT2" -eq 1 ]; then | ||
| 73 | - echo "[$(date)] 微信 Watcher 重置成功" | tee -a "$LOG" | 84 | + WPID2=$(cat "$WATCHER_PID_FILE" 2>/dev/null) |
| 85 | + if [ -n "$WPID2" ] && su -c "test -r /proc/$WPID2/cmdline" 2>/dev/null; then | ||
| 86 | + echo "[$(date)] 微信 Watcher 重置成功(pid=$WPID2)" | tee -a "$LOG" | ||
| 74 | else | 87 | else |
| 75 | - echo "[$(date)] 微信 Watcher 重置后存活数异常: $WATCHER_COUNT2" | tee -a "$LOG" | 88 | + echo "[$(date)] 微信 Watcher 重置后仍未存活" | tee -a "$LOG" |
| 76 | fi | 89 | fi |
| 77 | fi | 90 | fi |
| 78 | 91 | ||
| @@ -89,3 +102,37 @@ if [ -f "$ORCH_LOCK" ]; then | @@ -89,3 +102,37 @@ if [ -f "$ORCH_LOCK" ]; then | ||
| 89 | fi | 102 | fi |
| 90 | 103 | ||
| 91 | su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_orchestrator.py --drain >> $REPLY_DIR/orchestrator.log 2>&1" | 104 | su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_orchestrator.py --drain >> $REPLY_DIR/orchestrator.log 2>&1" |
| 105 | + | ||
| 106 | +# ── 7. task.hexdo.com 隧道 ────────────────────────── | ||
| 107 | +# Cloudflare 隧道 pixel-wechat-viewer(→ 127.0.0.1:5001),用 termux 用户身份跑, | ||
| 108 | +# 不需要 su。2026-08-07 之前这里完全没人巡检——本地 Flask 一直活着,但对外的 | ||
| 109 | +# task.hexdo.com 挂了一周多都没被发现,靠这次手动排查才补上。 | ||
| 110 | +# 详见 pixelphone 仓库 memory task-hexdo-reverse-proxy-outage.md。 | ||
| 111 | + | ||
| 112 | +CF_DIR=$HOME_DIR/.cloudflared | ||
| 113 | +TUNNEL_ALIVE=$(pgrep -f "cloudflared tunnel --config.*pixel-wechat-viewer" 2>/dev/null | wc -l) | ||
| 114 | + | ||
| 115 | +if [ "$TUNNEL_ALIVE" -eq 0 ]; then | ||
| 116 | + echo "[$(date)] cloudflared 隧道进程不在,启动中..." | tee -a "$LOG" | ||
| 117 | + nohup cloudflared tunnel --config "$CF_DIR/config.yml" run pixel-wechat-viewer \ | ||
| 118 | + > "$CF_DIR/tunnel.log" 2>&1 & | ||
| 119 | + sleep 5 | ||
| 120 | +fi | ||
| 121 | + | ||
| 122 | +# 进程在不代表隧道通(连接可能卡死/边缘握手失败),实测公网端点才算数 | ||
| 123 | +PUBLIC_CODE=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' https://task.hexdo.com/api/doctor 2>/dev/null) | ||
| 124 | + | ||
| 125 | +if [ "$PUBLIC_CODE" != "200" ]; then | ||
| 126 | + echo "[$(date)] task.hexdo.com 探测异常(http_code=$PUBLIC_CODE),重启隧道..." | tee -a "$LOG" | ||
| 127 | + pkill -f 'cloudflared tunnel --config.*pixel-wechat-viewer' 2>/dev/null | ||
| 128 | + sleep 1 | ||
| 129 | + nohup cloudflared tunnel --config "$CF_DIR/config.yml" run pixel-wechat-viewer \ | ||
| 130 | + > "$CF_DIR/tunnel.log" 2>&1 & | ||
| 131 | + sleep 8 | ||
| 132 | + PUBLIC_CODE2=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' https://task.hexdo.com/api/doctor 2>/dev/null) | ||
| 133 | + if [ "$PUBLIC_CODE2" = "200" ]; then | ||
| 134 | + echo "[$(date)] task.hexdo.com 隧道重启后恢复" | tee -a "$LOG" | ||
| 135 | + else | ||
| 136 | + echo "[$(date)] task.hexdo.com 隧道重启后仍异常(http_code=$PUBLIC_CODE2),需要人工看一下 $CF_DIR/tunnel.log(Mac 上 bridge.hexdo.com 是另一条独立隧道,不受影响)" | tee -a "$LOG" | ||
| 137 | + fi | ||
| 138 | +fi |
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
| @@ -44,6 +44,7 @@ STATE_FILE = f"{WORK_DIR}/state.json" | @@ -44,6 +44,7 @@ STATE_FILE = f"{WORK_DIR}/state.json" | ||
| 44 | CONFIG_FILE = f"{WORK_DIR}/config.json" | 44 | CONFIG_FILE = f"{WORK_DIR}/config.json" |
| 45 | QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl" | 45 | QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl" |
| 46 | LOG_FILE = f"{WORK_DIR}/watcher.log" | 46 | LOG_FILE = f"{WORK_DIR}/watcher.log" |
| 47 | +PID_FILE = f"{WORK_DIR}/watcher.pid" | ||
| 47 | IMEI = "1234567890ABCDEF" | 48 | IMEI = "1234567890ABCDEF" |
| 48 | 49 | ||
| 49 | # inotify 常量 | 50 | # inotify 常量 |
| @@ -58,13 +59,11 @@ MTIME_POLL = 4.0 # inotify 不可用时的兜底轮询间隔 | @@ -58,13 +59,11 @@ MTIME_POLL = 4.0 # inotify 不可用时的兜底轮询间隔 | ||
| 58 | 59 | ||
| 59 | 60 | ||
| 60 | def log(msg): | 61 | def log(msg): |
| 62 | + # 只 print:daemon 模式下调用方(watchdog/cron)一律把 stdout+stderr 重定向到 | ||
| 63 | + # LOG_FILE,这里再显式 open().write() 同一个文件会把每一行都写两遍 | ||
| 64 | + # (2026-08 发现 watcher.log 里所有行都成对重复,根因就是这个)。 | ||
| 61 | line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" | 65 | line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" |
| 62 | print(line, flush=True) | 66 | print(line, flush=True) |
| 63 | - try: | ||
| 64 | - with open(LOG_FILE, "a") as f: | ||
| 65 | - f.write(line + "\n") | ||
| 66 | - except OSError: | ||
| 67 | - pass | ||
| 68 | 67 | ||
| 69 | 68 | ||
| 70 | def md5hex(s: str) -> str: | 69 | def md5hex(s: str) -> str: |
| @@ -359,18 +358,38 @@ def make_inotify(watch_dir): | @@ -359,18 +358,38 @@ def make_inotify(watch_dir): | ||
| 359 | return libc, fd | 358 | return libc, fd |
| 360 | 359 | ||
| 361 | 360 | ||
| 361 | +def _write_pidfile(): | ||
| 362 | + # 看门狗靠这个文件 + kill -0 判活,取代不可靠的 `ps -A | grep` | ||
| 363 | + # (2026-08 实测 `su -c 'ps -A'` 经 adb/su 转一手偶发返回空/截断, | ||
| 364 | + # 哪怕进程明明还活着,导致看门狗把健康进程当挂了反复重启)。 | ||
| 365 | + try: | ||
| 366 | + with open(PID_FILE, "w") as f: | ||
| 367 | + f.write(str(os.getpid())) | ||
| 368 | + chown_to_hermes(PID_FILE) | ||
| 369 | + except OSError: | ||
| 370 | + pass | ||
| 371 | + | ||
| 372 | + | ||
| 362 | def daemon(): | 373 | def daemon(): |
| 363 | _, db, _ = account_paths() | 374 | _, db, _ = account_paths() |
| 364 | watch_dir = os.path.dirname(db) | 375 | watch_dir = os.path.dirname(db) |
| 365 | os.makedirs(WORK_DIR, exist_ok=True) | 376 | os.makedirs(WORK_DIR, exist_ok=True) |
| 366 | chown_to_hermes(WORK_DIR) | 377 | chown_to_hermes(WORK_DIR) |
| 367 | - libc, fd = make_inotify(watch_dir) | ||
| 368 | - if fd is not None: | ||
| 369 | - log(f"inotify 监听 {watch_dir}") | ||
| 370 | - _watch_loop_inotify(fd) | ||
| 371 | - else: | ||
| 372 | - log(f"inotify 不可用,退回 mtime 轮询 {db}") | ||
| 373 | - _watch_loop_mtime(db) | 378 | + _write_pidfile() |
| 379 | + try: | ||
| 380 | + libc, fd = make_inotify(watch_dir) | ||
| 381 | + if fd is not None: | ||
| 382 | + log(f"inotify 监听 {watch_dir}") | ||
| 383 | + _watch_loop_inotify(fd) | ||
| 384 | + else: | ||
| 385 | + log(f"inotify 不可用,退回 mtime 轮询 {db}") | ||
| 386 | + _watch_loop_mtime(db) | ||
| 387 | + finally: | ||
| 388 | + try: | ||
| 389 | + if os.path.exists(PID_FILE) and open(PID_FILE).read().strip() == str(os.getpid()): | ||
| 390 | + os.remove(PID_FILE) | ||
| 391 | + except OSError: | ||
| 392 | + pass | ||
| 374 | 393 | ||
| 375 | 394 | ||
| 376 | def _trigger_orchestrator(): | 395 | def _trigger_orchestrator(): |