修复: 集合指纹**不是集合的函数**(顺序敏感 + 只含 name:size);"读不到"曾静默报成"全 0 且指纹正常";"设备忙"的跳过**加界**;criteria-hygiene 两处按字面量裁射程
pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。
## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现)
原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测:
· **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变)
⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效,
**每次清单整理都假变**。
· **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`,
265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的
`ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而
**指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。**
⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`);
等长改内容 ⇒ 变(`89b2aa41`)。
## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟)
隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测:
```
nobody: mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0
```
`e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对
**不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半
照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。
**同一份权限,两个半边给出互相矛盾的结论。**
⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442`
—— 一句话里说"12 个"却一个都没读到。
⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、
并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全**
(原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个");
③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。
④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。
★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError`
会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。
自己查出来并改成"读的时候接住",才让它成为可达的真分支。
## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受)
只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级
—— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成,
**跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。
边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** ——
那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。
账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成",
换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。
自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住,
变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。
## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程"
我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红,
**那条判据是对的、我错了**),接着暴露出该判据自身两个洞:
· **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`,
而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒
假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与
`SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。
· **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了
(实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条
"不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。
两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红;
只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。
## 验证
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发
会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红)
已消。`mutants=48` 不变。
· 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。
This commit is contained in:
@ -58,13 +58,46 @@ def main():
|
||||
for p in glob.glob(os.path.join(JOBS_DIR, 'jobs*.json')))
|
||||
unlisted = [f for f in on_disk if f not in listed]
|
||||
ghosts = [f for f in listed if f not in on_disk]
|
||||
# ★★ "读不到" ≠ "不存在"(pi 2026-09-18 实测的第二个洞)。
|
||||
#
|
||||
# 实测(隔离副本 + `runuser -u nobody` + `chmod 644 jobs/`):
|
||||
# root(jobs/ 755):mutants=48 ran=0 skipped=48 … 原始条目 74 sha=02502771
|
||||
# nobody(jobs/ 644):mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0
|
||||
# `e3b0c442` 是**空字符串的 sha256** ⇒ "什么都没读到"被报成"集合为空且指纹正常",
|
||||
# **而且退出码 0**。它比 `PermissionError` 糟:读不到 = 数字全 0 + 看着像正常读数。
|
||||
#
|
||||
# 根因:`os.path.exists()` 对**不可进入目录里的文件**返回 **False**(实测),
|
||||
# 于是清单里 12 个全被当成"不存在"跳过;而 `glob` 那一半照样列得出 12 个 ⇒
|
||||
# `unlisted`/`ghosts` 都是空 ⇒ 下面那条"清单与磁盘不一致"的警告**一声不响**。
|
||||
# 同一份权限,两个半边给出互相矛盾的结论。
|
||||
#
|
||||
# ⇒ 先判**目录**能不能读(这一层才判得准),再判单个文件:
|
||||
# · 目录不可进入 ⇒ 磁盘上看不清,判不了"有没有"⇒ `blind`,**大声失败**;
|
||||
# · 目录能进、某文件 exists 但读不了 ⇒ 单列 `unreadable`(不是 ghosts)。
|
||||
# 这与 `check-file-modes.sh` 补的"目录可进入性"是**同一件事**:那个只在部署时跑,
|
||||
# 而这里是套件自己的读数器 —— 同一个目录权限,不该在部署门禁里红、在套件里静默绿。
|
||||
dir_readable = os.access(JOBS_DIR, os.R_OK | os.X_OK)
|
||||
blind = not dir_readable
|
||||
# `unreadable` **在读循环里**填(见下面 `except OSError`):先探后读会漏掉"TOCTOU"
|
||||
# 且两处判定要一致 —— 判"读不到"的唯一可靠时机就是**真去读**的那一次。
|
||||
unreadable = []
|
||||
for f in listed: # 只读**清单里**的,顺序也按清单
|
||||
p = os.path.join(JOBS_DIR, f)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
for j in json.load(open(p, encoding='utf-8')):
|
||||
j['_from'] = f
|
||||
entries.append(j)
|
||||
# ★ `open` 要接住 OSError:不接的话,"单个 job 文件不可读"会抛
|
||||
# `PermissionError` 直接死在这儿,**下面那条 `unreadable` 报告永远走不到**
|
||||
# —— 我第一版就是那样,等于留了一段不可达的死代码("判据在,但走不到",
|
||||
# 这次长在报告分支上)。接住之后:读得到的照读,读不到的**攒起来一次报全**,
|
||||
# 而不是死在第一个文件上(后者会让人以为"就这一个有问题")。
|
||||
try:
|
||||
with open(p, encoding='utf-8') as fh:
|
||||
for j in json.load(fh):
|
||||
j['_from'] = f
|
||||
entries.append(j)
|
||||
except OSError:
|
||||
if f not in unreadable:
|
||||
unreadable.append(f)
|
||||
|
||||
active = [e for e in entries if not e.get('retired')]
|
||||
retired = len(entries) - len(active)
|
||||
@ -147,19 +180,61 @@ def main():
|
||||
# ★ 集合自证:数字必须能回答"读的是哪个集合"(pi 2026-09-18 的第二个建议)。
|
||||
# 指纹让"48 还是 52"变成可判的:集合没变而数变了 ⇒ 真算错;
|
||||
# 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。
|
||||
fp = hashlib.sha256('\n'.join(f'{f}:{os.path.getsize(os.path.join(JOBS_DIR, f))}'
|
||||
for f in listed if os.path.exists(os.path.join(JOBS_DIR, f)))
|
||||
.encode()).hexdigest()[:8]
|
||||
#
|
||||
# ★★ 指纹必须是**集合的函数**(pi 2026-09-18 实测的两个洞,都当场复现):
|
||||
# ① **顺序敏感**:原来直接按 `listed` 顺序拼 —— 实测"只把清单反序、集合/内容/计数全不变"
|
||||
# ⇒ `sha=02502771` 变 `sha=3439e049`。于是"集合变了 ⇒ 一眼看得出"失效:
|
||||
# **每次清单整理都假变**。(修:`sorted()`)
|
||||
# ② **只含 name:size** ⇒ 同大小改内容抓不到。实测字节级等长改写
|
||||
# (`jobs-one.json` 的 `name: 'image'` → `name: 'imoge'`,265 字节不变)
|
||||
# ⇒ **指纹仍是 `02502771`**。更尖锐的是把 file 改成等长的 `ApiClienX.ets`
|
||||
# (指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变、**指纹不变** ——
|
||||
# **"集合没变而数变了"恰恰是它声称要抓的情况,而它抓不到。**(修:内容哈希)
|
||||
# ⇒ 指纹的全部存在理由就是"集合变了要看得出来",所以它必须对**顺序不敏感、
|
||||
# 对内容敏感**。名字排序 + 内容哈希两格都补上。
|
||||
#
|
||||
# ⚠️ 但要记准它**不是**什么:它是**集合指纹**(读到的那些文件的身份),
|
||||
# **不是"数字对不对"的证明**。数字由上面那套口径算,指纹只回答"读的是哪一堆"。
|
||||
def _fp_of(names):
|
||||
parts = []
|
||||
for f in sorted(names):
|
||||
p = os.path.join(JOBS_DIR, f)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
try:
|
||||
h = hashlib.sha256(open(p, 'rb').read()).hexdigest()[:8]
|
||||
except OSError:
|
||||
h = 'UNREADABLE' # 读不到也要进指纹(否则"读不到"就静默等于"没有")
|
||||
parts.append(f'{f}:{h}')
|
||||
return hashlib.sha256('\n'.join(parts).encode()).hexdigest()[:8]
|
||||
fp = _fp_of(listed)
|
||||
# 一个 job 都没读到 ⇒ **不许**报成正常读数(`e3b0c442` 是空串的 sha256,
|
||||
# 而"空集合 + 正常指纹 + rc=0"正是最坏的那种读数:看着像成功)。
|
||||
print(f' 集合:清单 {len(listed)} 个 job 文件(未跟踪的**不会**静默进入统计)'
|
||||
f' sha={fp} 集合指纹')
|
||||
if blind:
|
||||
print(f' ✗✗ **读不到 jobs/ 目录**({JOBS_DIR})—— 上面的数字**全部无效**:')
|
||||
print(f' 清单里有 {len(listed)} 个文件,但目录不可读/不可进入,一个都没读成。')
|
||||
print(f' 这一行**不是**"集合为空":`sha=e3b0c442` 是空字符串的 sha256。')
|
||||
print(f' 修法:修 jobs/ 目录权限(至少要 r+x;`check-file-modes.sh` 判的同一件事),'
|
||||
f'或确认是不是在错误的用户/挂载下跑。')
|
||||
if len(entries) == 0 and len(listed) > 0:
|
||||
print(f' ✗✗ 清单里有 {len(listed)} 个 job 文件,却读到 **0 条条目** —— '
|
||||
f'别把 0 当成"没有变异体",先查上面那两条(读不到 / 全是空文件)。')
|
||||
# ★ 清单与磁盘不一致 ⇒ 明说(与 `run-all.mjs` 自检 2 同形状:清单外即红)。
|
||||
if unlisted or ghosts:
|
||||
if unlisted or ghosts or unreadable:
|
||||
print(' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:')
|
||||
for f in unlisted:
|
||||
print(f' 未列入清单:{f}(新加的 job 文件必须显式加进 jobs.manifest.json)')
|
||||
for f in ghosts:
|
||||
print(f' 清单里有、磁盘上没有:{f}')
|
||||
print(' 修法:把该加的文件加进 jobs.manifest.json,或把该删的条目删掉。')
|
||||
# ★ 与 `ghosts` **分开报**:这两个的修法完全不同 ——
|
||||
# 一个是"文件真没了"(删清单条目),一个是"文件在、我读不到"(修权限)。
|
||||
# 合在一起报会让人去删一个其实存在的条目(pi 说的是"读不到 ≠ 不存在")。
|
||||
for f in unreadable:
|
||||
print(f' 清单里有、**在但读不到**:{f}(权限问题,不是缺失 —— 改权限,别删条目)')
|
||||
print(' 修法:把该加的文件加进 jobs.manifest.json,或把该删的条目删掉;'
|
||||
'读不到的那种去修权限。')
|
||||
print(' (为什么不能靠 glob:未跟踪的文件会**静默**进入统计,'
|
||||
'同一段代码两个数、而没人知道读的是哪个集合。)')
|
||||
if skipped_detail:
|
||||
@ -169,6 +244,16 @@ def main():
|
||||
print(f' hits={h} {key[0]} 「{group[0].get("why", "")}」 ({len(group)} 条条目:{srcs})')
|
||||
print(' ⚠️ hits=0 通常是**过期条目**(锚点是旧写法)—— 请标 retired 或删除,')
|
||||
print(' 否则它会把 skipped 一直抬高(方向与"让欠账显形"相反)。')
|
||||
# ★ 读不到 ⇒ **非零退出**(pi 2026-09-18 的洞 2 的关键:原来 rc=0)。
|
||||
# 读不到就是"没读数",而没读数**不是成功** —— 与本仓"失败要说清是环境问题、
|
||||
# 不要让它冒充代码缺陷"是同一套:这里更该退非零,因为它连"是环境还是代码"都判不了。
|
||||
# 退出码按本仓约定用 **2 = 环境问题**(见 `env-defaults.sh:25`):
|
||||
# 目录不可进入 / 有 job 文件读不到,都是环境,不是"变异体少了"。
|
||||
# (`run-all.mjs` 只 grep `RESULT mutants=` 片段、不看退出码,所以那边也会看到
|
||||
# `mutants=0` —— 但这一行现在自己带 ✗✗ 说明,且 `原始条目 0` 与 `清单 12` 并排,
|
||||
# 不再可能被读成"集合为空且一切正常"。)
|
||||
if blind or unreadable:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user