From 743e397916bb9617a1bd976b13c549ff3b88cef4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:53:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(prune)!:=20=E6=9E=84=E5=BB=BA=E6=9A=82?= =?UTF-8?q?=E5=AD=98=E9=82=A3=E6=AE=B5=E6=B8=85=E7=90=86**=E4=B8=80?= =?UTF-8?q?=E7=9B=B4=E6=98=AF=E7=A9=BA=E8=BD=AC=E7=9A=84**=EF=BC=88`ls=20-?= =?UTF-8?q?1t`=20=E5=AF=B9=E7=9B=AE=E5=BD=95=E6=89=93=20`=E8=B7=AF?= =?UTF-8?q?=E5=BE=84:`=20=E5=A4=B4=EF=BC=89+=20del()=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=A1=A5=E9=BD=90=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 那个真 bug:`ls -1t <多个目录>` 不打裸名字 追 pi 的 shim 建议时撞出来的,与 shim 无关 —— 是我为了给它造样本才发现的: ls -1t /tmp/agentmail-gateway-build-* # 这些是**目录**(mktemp -d 造的) /tmp/…-20260101-000000: agentmail-gateway /tmp/…-20260105-000000: agentmail-gateway `ls -1t` 收到**多个目录参数**时会列出**每个目录的内容**并打 `路径:` 头 —— 于是 `mapfile` 拿到的全是 `…000000:` / `agentmail-gateway` 这类行,都不是文件名 ⇒ `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳 ⇒ 每条都判 "文件名无时间戳,判定不了" ⇒ **一个都不删**。 也就是说本段注释里写的那个问题("每次部署留下一个 24MB")**从来没被清理过**。 修法:`ls -1dt`(`-d` 让目录自身作为条目,不打头)。 **为什么一直没人发现**:自检夹具用 `: >` 造的是**普通文件**,而生产是**有内容的目录**。 夹具形状与生产不一致 ⇒ 夹具自己认了错形状,而判据 197 行又只按**文件名**判 "窗口内的还在、窗口外的不在",于是判据也认了。这是 docs 第 13 条那一族。 → 夹具已改成真目录 + 里面放 `agentmail-gateway`;**改完立刻变红**("干净样本:/tmp 构建暂存 只留窗口内那 1 份"失败),证明夹具现在真的有分辨力,然后加 `-d` 转绿。 顺带确认:其余三处 `ls -1t`(`agentmail-gateway.bak-*`、`pre-deploy-*.db`、`pre-prune-*.db`) glob 到的是**文件**,不受影响;插件快照那处(第 294 行)本来就已经写了 `-1dt`。 生产现场实测:`/tmp` 下确实还躺着 1 份 24MB 暂存没被收掉。 ## `del()` 的失败分支:采纳 pi 的"让 rm 自己失败" 他指出的第三条路(我原先只想到 immutable 与注入点)是对的:本机以 root 跑、权限拦不住; `unshare -r` 被拒(`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。 **改机器的权限**不如**让 rm 失败**。 实现上走了 `RM="${RM:-rm}"` 而不是 PATH shim,理由:`in_use` 会把命令行里含该路径的进程 判成"在用",而自检必须把路径写在命令行上 —— 实测评 PATH shim 时确实被 `in_use` 挡掉、 `del()` 根本没被调用(那次"测试通过"是假的)。`$RM` 默认就是 `rm`,生产行为逐字不变。 自检新增 4 条(并通过变异确认有区分力:去掉失败判定 ⇒ 强断言变红): 退出码 2、必须打出 `[FAIL] 删不掉`、不许出现收尾汇总、那条路径必须还在。 ★ 变异还暴露出一条**弱断言**:单看"退出码 = 2"在变异后**照样通过**(脚本别处也有退 2 的路径) —— 已在注释里注明它弱、区分力来自另两条,没有让它冒充证据。 ★ 顺手修掉一处自指的措辞:我原先在报错里抄了收尾汇总的原话("已删除 N 项"), 于是 `grep -c '已删除'` 命中**这句报错自己** ⇒ "有没有虚报成功"这个检查把自己的措辞 当成了证据。改写成不含该字面量的说法(与 `grep -c 用例名` 是同一族:判据锚在元文本上)。 ## pi 的另两点 · `diffSummary` 带 `ctx` 时**会读文件**(判 `scripts.test` 要读两侧原文),不带是纯内存比较 —— 已写进函数头,免得以后有人当纯函数用而在大树上意外吃到 I/O。 · "自检样本不独立"的三种形态(位置选择器 / 共享夹具状态泄漏 / 探针无分辨力) **合成 docs 第 13 条**(修法同一个:显式命名 + 显式复位),并把上面"夹具形状必须与生产 一致"作为配套一条写进同一条 —— 今天的真 bug 正是它。 验证:prune 自检 22/22、干跑 exit 0;npm test exit 0;drift 自检 35/0;check-shared-libs exit 0。 --- deploy/check-deploy-drift.mjs | 9 ++++ deploy/prune-deploy-artifacts.sh | 80 ++++++++++++++++++++++++++++++-- docs/DEV-TOOLING.md | 21 ++++++--- 3 files changed, 99 insertions(+), 11 deletions(-) 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。报"自检失败"就是把两个量纲混成一个。