diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 56cc81e..8a78960 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -198,6 +198,15 @@ export function jsonTestOnlyChange(repoText, snapText) { return { testOnly: diffs.length > 0 && diffs.every((k) => k.startsWith('scripts.test')), keys: diffs }; } +/** + * ⚠️ **带 `ctx` 时会读文件;不带是纯内存比较**(pi 提醒 2026-09-14)。 + * + * 判 `scripts.test` 这类字段必须读两侧**原文**,所以 `diffSummary(d, {repoDir, snapDir})` + * 不是纯函数 —— 它会对 `d.changed.runtime` 里的每个文件各读一次。 + * 拿它当纯函数用(例如在大树上反复调、或放进"只算不读"的路径)会意外吃到 I/O。 + * 不带 ctx 时 `testOnly` 恒为 `[]`、不读任何文件,行为与旧版逐字相同 + * (既有那批自检样本正是走这条路,必须保持住)。 + */ export function diffSummary(d, ctx = {}) { // `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。 const testOnly = (ctx.repoDir && ctx.snapDir) ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) : []; diff --git a/deploy/prune-deploy-artifacts.sh b/deploy/prune-deploy-artifacts.sh index c7a5d12..23cd443 100755 --- a/deploy/prune-deploy-artifacts.sh +++ b/deploy/prune-deploy-artifacts.sh @@ -37,6 +37,8 @@ KEEP_BUILD_STAGES="${KEEP_BUILD_STAGES:-1}" TMPD="${PRUNE_TMP_DIR:-/tmp}" APPLY=0 [ "${1:-}" = "--apply" ] && APPLY=1 +# 只影响 `del()` 用哪个删除命令(默认真 rm)。自检用它喂"删不动"的样本。 +RM="${RM:-rm}" freed_kb=0 removed=0 @@ -90,10 +92,17 @@ del() { # 所以:判失败即报错并 `exit 2`(环境/权限问题),且 `removed`/`freed_kb` # **只在成功后才累加**,收尾汇总不再虚报。 if [ "$APPLY" = "1" ]; then - if ! rm -rf "$path"; then + # `$RM` 只是为了**让自检能喂一个"删不动"的失败**(见 --self-check 那一段): + # 默认就是 `rm`,生产行为逐字不变。不用 PATH shim 是因为 `in_use` 会把 + # 命令行里含该路径的进程判成"在用",而自检必须把路径写在命令行上。 + if ! "$RM" -rf "$path"; then printf ' [FAIL] 删不掉:%s(权限/只读挂载/immutable?)\n' "$path" >&2 - printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会报"已删除"。\n' >&2 + # ⚠️ 这句**不要**把收尾汇总的原话("已删除 N 项…")抄进来: + # 抄进来之后 `grep -c '已删除'` 会命中**这句报错自己**, + # 于是"有没有虚报成功"这个检查把自己的措辞当成了证据 + # (与"`grep -c 用例名` 数出假数"是同一族:判据锚在了元文本上)。 + printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会执行。\n' >&2 exit 2 fi # 删完还要**抽验**:`rm -rf` 对某些情况会"成功"却留东西(例如非空目录里 @@ -140,11 +149,32 @@ if [ "${1:-}" = "--self-check" ]; then for i in 1 2 3; do : > "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db" touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db" - : > "$t/tmp/agentmail-gateway-build-2026010$i-000000" + # ★ 必须是**有内容的目录**,不能是 `: >` 造的空文件! + # 真实的构建暂存是 `mktemp -d` 出来的目录、里面有 `agentmail-gateway` 等产物。 + # 夹具原先用 `: >` 造**普通文件**,而这个形状**恰好把下面那个 bug 藏住了**: + # `ls -1t <多个目录>` 会打 `路径:` 头 + 内容(不是裸名字),于是 collect 拿到 + # 一堆不是文件名的行 ⇒ 每个都"文件名无时间戳"⇒ 全跳过 ⇒ 一个都不删, + # 而判据 197 行只查"窗口内的还在、窗口外的不在",用的是**文件名**判的, + # 于是**夹具自己认了错形状、判据自己放行了**(与 `user-question` 那次 + # "判据的量纲没覆盖真实形状"是同一族)。改成真目录后这条 bug 立刻可见。 + mkdir -p "$t/tmp/agentmail-gateway-build-2026010$i-000000" + : > "$t/tmp/agentmail-gateway-build-2026010$i-000000/agentmail-gateway" touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-gateway-build-2026010$i-000000" done echo "$t" } + # 给"删除失败"那条样本用的最小树:两个构建暂存(窗口 1 份 ⇒ 旧的应被删) + mkdtree_fail() { + local t; t="$(mktemp -d)" + mkdir -p "$t/data" "$t/tmp" + for ts in 20260101-000000 20260105-000000; do + mkdir -p "$t/tmp/agentmail-gateway-build-$ts" + : > "$t/tmp/agentmail-gateway-build-$ts/agentmail-gateway" + done + touch -d "2026-01-01 00:00:00" "$t/tmp/agentmail-gateway-build-20260101-000000" + touch -d "2026-01-05 00:00:00" "$t/tmp/agentmail-gateway-build-20260105-000000" + echo "$t" + } run() { local t="$1" mode="${2:-}" # ★ 自检只允许在**自己刚建的临时假根**上跑。这条闸门是事后补的,因为它真出过事: @@ -204,6 +234,39 @@ if [ "${1:-}" = "--self-check" ]; then ck "坏样本:拒绝理由要点名在线数据库" "$(printf '%s' "$bout" | grep -q "在线数据库" && echo 1 || echo 0)" ck "坏样本:在线库仍在" "$([ -f "$B/data/agentmail.db" ] && echo 1 || echo 0)" # 自检最要紧的一条:生产根在这两轮自检里**一个文件都没动**。 + # ★ `del()` 的**失败分支**:喂一个"删不动"的 rm,verifying 三件事 —— + # 退出码 2(环境问题,不是 1)、报错点名那条路径、且**不虚报成功**。 + # + # 为什么必须单独喂失败:这条分支平时不跑,而"错误分支的区分力只有喂它一个失败才存在"。 + # 这一整轮修的恰好都是"错误分支第一次被调用时报错了地方"(`describeEnvError` 只覆盖 + # 六处写点之一、被拒之后照样打 `[ OK ]`、go 的环境问题被说成代码问题)。 + # + # 早先我以为覆盖不了它:本机以 root 跑,权限拦不住 `rm`;`unshare -r` 被拒 + # (`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。 + # pi 指出第三条路是**让 rm 自己失败**,而不是去改机器的权限 —— 于是有了 `$RM` 覆盖点。 + # (不用 PATH shim:`in_use` 会把命令行里含该路径的进程判成"在用", + # 而自检必须把路径写在命令行上。) + T2="$(mkdtree_fail)" + cat > "$T2/fake-rm" <<'FAKERM' +#!/usr/bin/env bash +case "$*" in *20260101-000000*) echo "fake rm: 模拟删除失败" >&2; exit 1;; esac +exec /bin/rm "$@" +FAKERM + chmod +x "$T2/fake-rm" + out2="$(AGENTMAIL_ROOT="$T2" PRUNE_TMP_DIR="$T2/tmp" KEEP_BUILD_STAGES=1 RM="$T2/fake-rm" \ + bash "$0" --apply 2>&1)"; rc2=$? + # ⚠️ 注意这条**单独看是弱的**:变异验证时(把 `del()` 的失败判定整个去掉) + # 它**照样通过** —— 因为脚本在别处也有按 2 退出的路径。留着它是因为"退 2 而不是 1" + # 这个口径本身要守(环境问题 ≠ 判据失败),但真正的区分力来自下面两条断言。 + ck "坏样本(rm 删不动):按环境问题退出(实际退出码 $rc2)" "$([ "$rc2" = 2 ] && echo 1 || echo 0)" + ck "坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]" \ + "$(printf '%s' "$out2" | grep -qF '[FAIL] 删不掉' && echo 1 || echo 0)" + ck "坏样本(rm 删不动):报错要点名删不掉的那条路径" \ + "$(printf '%s' "$out2" | grep -qF '删不掉' && printf '%s' "$out2" | grep -qF 'agentmail-gateway-build-20260101-000000' && echo 1 || echo 0)" + ck "坏样本(rm 删不动):不许虚报成功(收尾汇总不该出现)" \ + "$(printf '%s' "$out2" | grep -qE '^ 已删除 [0-9]+ 项' && echo 0 || echo 1)" + ck "坏样本(rm 删不动):那条路径必须还在" "$([ -e "$T2/tmp/agentmail-gateway-build-20260101-000000" ] && echo 1 || echo 0)" + ck "自检没有碰生产根($ROOT 清单不变)" "$([ "$(_tree_sig "$ROOT")" = "$SIG_BEFORE" ] && echo 1 || echo 0)" rm -rf "$T" "$B" @@ -310,7 +373,16 @@ fi # 窗口留 1 份而不是 0:正常路径下部署脚本自己会收(见 redeploy-gateway.sh 末尾), # 留下的只可能是**失败**的那次;失败时那一份正好用来事后查,删了就没现场了。 echo "==> $TMPD 的构建暂存(保留最新 $KEEP_BUILD_STAGES 份)" -mapfile -t stages < <(ls -1t "$TMPD"/agentmail-gateway-build-* 2>/dev/null) +# ★ `-d` 是**必须的**,不是风格:`agentmail-gateway-build-*` 是**目录**(`mktemp -d` 造的), +# 而 `ls -1t <多个目录>` 的默认行为是**列出每个目录的内容并打 `路径:` 头** —— +# 于是 `mapfile` 收到的是一堆 `…000000:`、`agentmail-gateway` 这样的行, +# 全都不是文件名 ⇒ 后面 `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳 +# ⇒ 每个都判"文件名无时间戳,判定不了"⇒ **一个都不删**。 +# 后果正是本段注释开头写的那个问题("每次部署留下一个 24MB")**从来没被清理过**: +# 2026-09-14 实测 /tmp 里确实还躺着 1 份 24MB(`…-20260914-190530`), +# 而 162MB 那次的现场早就没了 —— 也就是说这段清理**一直是空转的**。 +# 之所以一直没人发现:自检夹具用 `: >` 造的是**普通文件**,形状与生产不一致(已一并修正)。 +mapfile -t stages < <(ls -1dt "$TMPD"/agentmail-gateway-build-* 2>/dev/null) for i in "${!stages[@]}"; do [ "$i" -lt "$KEEP_BUILD_STAGES" ] && { report " 保留" "${stages[$i]}"; continue; } # 二进制可能正被某个部署进程 install 到一半 —— 与快照同理,用过的路径一律先问一句。 diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index ef52c73..7a8cb10 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -201,13 +201,20 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, `set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。 退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 —— 这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。 -13. **锚点必须与结论一一对应 —— 连"文件名"都会骗你。** 实例(2026-09-14): - 我写了个探针判"通知脚本内容与仓库不一致 ⇒ 必须红",探针按**文件名**取文件 - (`p.endsWith('service-failure-notify.mjs')`),可两个路径的 basename **相同** - (`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/service-failure-notify.mjs`) - ⇒ 两侧读到同一个串、探针**自己没分辨力**,样本"通过"得毫无意义。 - 与 `grep -c 用例名` 那个假数、与"名字出现在 `# Subtest:` 头"是同一族但不同形: - **锚在"名字"而没有锚在"哪一侧"**。 +13. **自检样本必须"独立":每条样本都要说清它锚在哪一条检查、用的是哪一棵树。** + 这一族我 2026-09-14 一天里踩了三次,合起来记比拆成三条好,因为**修法是同一个** + (显式命名 + 显式复位)—— 三者都是"断言的语义被当前状态悄悄改掉": + + | 形态 | 实例 | + |---|---| + | **位置选择器** | 自检里 `bad[0]` / `badBak[0]`:前面插一条新检查之后,锚点指的就不是原来那条了 | + | **共享夹具状态泄漏** | 新补的四条豁免样本第一版**全红**:它们继承了两棵树里既有的改动(`extra.mjs`、只存在于 `a` 的 `test/`),红的理由根本不是要测的那件事 | + | **探针自身没有分辨力** | E 那次的探针按**文件名**判两侧,而两条路径 basename 相同(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/…`)⇒ 两侧读到同一个串、样本"通过"得毫无意义。与 `grep -c 用例名` 那个假数、"名字出现在 `# Subtest:` 头"同族不同形:**锚在"名字"而没有锚在"哪一侧"** | + + 配套一条(同一天踩到):**自检的夹具形状必须与生产形状一致**,否则夹具会把真 bug 藏住 —— + `prune-deploy-artifacts.sh` 的构建暂存自检用 `: >` 造**普通文件**,而生产是 + **有内容的目录**;正是这个差异让"`ls -1t` 对多个目录打 `路径:` 头 ⇒ 那段清理一直空转" + 这个 bug 藏了很久(判据自己在用文件名判"删了没有",夹具认了错形状,于是它也认了)。 14. **退出码也有量纲。** `node deploy/check-deploy-drift.mjs --self-check` 的退出码 **不是**"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、 于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。