清单服务「超时停止」失效:timeout --signal=KILL 参数里引号不闭合,SIGKILL 发不到任何进程,清单不完整却自检全绿、退出码 0
日期:2026-10-05 分支:
2026-10-05-0800期数:第105题 技术栈标签:timeout/--signal=KILL/ 超时停止 timeout stop / graceful stop / 信号发不到 / 不闭合的引号No closing quotation/shlex.split/ shell 词法解析 /bash -n unexpected EOF while looking for matching \`` / 部分同步被当成同步完成 / 清单不完整 /self_consistent: true自述自证 / 退出码恒 0 / 健康检查没有判据 //proc//status State: Z僵尸进程 / Python.env` 配置解析 / Docker Compose 复现
一、问题背景
一条分片同步链路(清单服务 manifestd):
源数据目录 /srv/payload/part-*.bin(4 个分片)
│ open() + read() + 逐条 flush「已同步清单」
▼
清单服务 app/manifestd.py
│ 读完全部分片 → 落盘 manifest.json → 才允许声明 READY
▼
运维侧:超时停止要用 MANIFEST_SOURCE_TIMEOUT_SIGNAL 这道闸门收口整条链路
契约 conf/manifestd.env 里那行闸门是这样的:
MANIFEST_SOURCE_TIMEOUT_SIGNAL="timeout --signal=KILL 2s'
注意最后两个字符:开头是 "(U+0022),结尾是 '(U+0027)。
一开一收不是同一对引号。
现象:清单服务每轮都「跑完了」,可它一个分片都没能完整记进清单, 而所有自述口径全是绿灯:
$ bash ops/repro.sh
契约 MANIFEST_SOURCE_TIMEOUT_SIGNAL = '"timeout --signal=KILL 2s\''
闸门解析后的 token = [] ← 一个 token 都没有
闸门进程 pid = 0,闸门进程树还活着吗 = False
闸门启动信息 = 闸门命令行无法解析: No closing quotation ← 真正的报错
停止要求送达的 pid = [] ← SIGKILL 一个都没发出去
缺失分片 = ['part-003.bin']
{"..., "self_consistent": true, ...} ← 自述却「自洽」
RESULT: BAD
python3 -m app.health 打印 {"degraded": false, "issues": [], "self_consistent": true}
并退出码 0。运维看到的四个绿灯:
- 服务「跑完了」(只证明进程没崩);
self_consistent: true(自述字段自己证自己);- 健康检查
degraded: false, issues: [](因为判据表是空的); - 退出码 0(
main()无条件return EXIT_OK)。
「超时停止」这道闸门从第一条配置就不可用 —— 这才是根因, 不是磁盘慢、不是网络抖。
二、复现步骤
环境:Docker 可用(docker version 有 Server),或本机 python3 + coreutils 的 timeout。
cd scenario/2026-10-05-0800
# 方式 A:本机直接跑(只需 python3 + timeout)
bash ops/repro.sh # 一键复现:跑链路 → 外部探针取证 → 结论
bash ops/verify.sh # 验收:问题态 PASS=6 FAIL=6 / RESULT: BAD
# 方式 B:docker 一键(推荐)
docker compose up -d --build
docker compose exec -T manifestd bash ops/repro.sh
docker compose exec -T manifestd bash ops/verify.sh
问题态验收输出:
== A. 超时信号闸门必须可按契约解析(不闭合的引号 = 信号发不到) ==
[FAIL] 闸门命令可按契约执行(1=可执行) (期望=1 实际=unparsable)
== B. 超时停止的要求必须真的送达闸门 ==
[FAIL] 停止信号送达的进程数 (期望=1 实际=0)
[FAIL] 「超时停止」相关的错误数 (期望=0 实际=1)
== F. 自述字段不得自证;健康检查要有牙 ==
[FAIL] 自述 self_consistent(False=正确) (期望=False 实际=True)
[FAIL] 健康检查在同步不完整时非 0 退出 (退出码=0)
== G. 外部独立探针 ==
RESULT: BAD
PASS=6 FAIL=6
RESULT: BAD
手动取证两行(定位的第一步)
# 1) 把契约原值按字节打出来
docker compose exec -T manifestd python3 -c "
raw=open('conf/manifestd.env').read()
line=[l for l in raw.splitlines() if l.startswith('MANIFEST_SOURCE_TIMEOUT_SIGNAL')][0]
val=line.split('=',1)[1]
print('raw value :', repr(val))
print('bytes :', list(val.encode()))
print('first==last :', val[0]==val[-1])
"
# raw value : '"timeout --signal=KILL 2s\''
# bytes : [34, 116, ..., 50, 115, 39] ← 34=双引号,39=单引号
# first==last : False
# 2) 证明它真的解析不了(Python 与 shell 给出同一个结论)
docker compose exec -T manifestd python3 -c "
import shlex
try: print(shlex.split('\"timeout --signal=KILL 2s\''))
except ValueError as e: print('shlex.split ERR:', e)"
# shlex.split ERR: No closing quotation
docker compose exec -T manifestd bash -c \
'printf "%s" "timeout --signal=KILL 2s'"'"'" > /tmp/x; bash -n /tmp/x; echo rc=$?'
# /tmp/x: line 1: unexpected EOF while looking for matching `''
三、根本原因
四个缺陷叠加,前一个把后一个藏起来,最后所有监控口径一起变成绿灯。
3.1 引号不闭合 → 命令行词法层面就不合法
"timeout --signal=KILL 2s' 一开一收不是同一对。
shlex.split() 抛 ValueError: No closing quotation;
shell 的 bash -n 也直接报
unexpected EOF while looking for matching \'``。
契约加载器 contract._unquote() 的行为是对的
(只脱「最外层成对的一层引号」,不猜):
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
value[0]=='"'、value[-1]=="'" → 不相等 → 一个字符都不脱,
原样把非法值交给消费方。错的是值本身,不是加载器。
3.2 解析失败被「静默降级」(题眼之一)
问题态 Gate.build() 捕获 ValueError 后:
except ValueError as exc:
self.start_error = "闸门命令行无法解析: %s" % exc
self.tokens = []
return self # ← 记一笔就继续,不抛,不中止
上层 Reader.__init__ 照常往下跑,
把一个「闸门根本不存在」的会话当成了「正常会话」。
「解析失败」被降级成了「配置长得怪」。
3.3 停止要求「记一笔」就当完成了(题眼之二)
if not pid:
self.stats["errors"].append("要求超时停止,但闸门进程不存在")
return False
pid=0(闸门没起来)→ 记一条 errors 就 return。
signalled=[] —— SIGKILL 一个都没发出去,
但内部逻辑继续收口,没有任何「不许静默成功」的收口。
3.4 自检表达式写反 + 判据表为空 + 退出码恒 0
self_consistent() 问题态实现:
return self.stats["ready"] or not self.sync_complete()
ready=False、sync_complete()=False → False or not False = True。
它把「我没声明成功」当成了「所以我是自洽的」——
而契约 MANIFEST_PARTIAL_SYNC_ALLOWED=0 明写部分同步就不算成功。
health.py 里:
BAD_FIELDS = () # 判据表是空的
for field in BAD_FIELDS: # 一次都不循环
...
degraded = bool(issues) # 恒 False
...
return EXIT_OK # main() 无条件退出 0
「没有判据」被当成了「没有毛病」。
3.5 因果链与三个「绿灯」的含义
契约引号不闭合
└─ _unquote() 不脱(首尾不成对)
└─ shlex.split() 抛 No closing quotation
└─ Gate.build() 静默降级(记 start_error,tokens=[],return self)
└─ 闸门进程压根没起(pid=0)
└─ request_stop() 记一笔 errors 就 return False → signalled=[]
└─ self_consistent() 写反 → 返回 True(自述全绿)
└─ health.BAD_FIELDS=() + return EXIT_OK → 退出码恒 0
| 绿灯 | 它实际证明了什么 |
|---|---|
| 服务「跑完了」 | 进程没崩 |
self_consistent: true | 一个写反的布尔表达式 |
degraded: false, issues: [] | 判据表是空的 |
| 退出码 0 | main() 无条件 return EXIT_OK |
**没一件指向真相。**唯一说真话的是外部独立探针
(只读契约 + 源目录真实文件 + /proc 事实 + 清单,不读任何自述字段)。
四、解决办法
改 app/ 下 4 个文件 + conf/manifestd.env 那一行。
client/sync_probe.py、ops/verify.sh、ops/negative.sh 一字未动。
4.1 契约:把引号闭合
-MANIFEST_SOURCE_TIMEOUT_SIGNAL="timeout --signal=KILL 2s'
+MANIFEST_SOURCE_TIMEOUT_SIGNAL="timeout --signal=KILL 2s"
4.2 解析必须「大声失败」(app/gate.py)
删掉 build() 里那段静默 return,把解析提到 __init__,
新增 GateError / parse_gate_cmdline():
def parse_gate_cmdline(cmdline: str):
"""按契约原文解析闸门命令行;解析不了就大声失败,不静默降级。"""
try:
tokens = shlex.split(cmdline)
except ValueError as exc:
raise GateError(
"MANIFEST_SOURCE_TIMEOUT_SIGNAL 无法解析(引号不闭合?):%r → %s"
% (cmdline, exc)) from exc
if not tokens:
raise GateError("MANIFEST_SOURCE_TIMEOUT_SIGNAL 为空")
execs = _exec_tokens(tokens) # 跳掉 -x / --key=val
if not execs:
raise GateError("... 里没有可执行的命令:%r" % cmdline)
if not shutil.which(execs[0]): # 闸门命令本身必须可用
raise GateError("... 里被执行的命令 %r 在 PATH 里找不到" % execs[0])
return tokens
判据细节(很容易写错):契约
timeout --signal=KILL 2s里的2s不是要执行的命令,它是timeout的参数。 正确规则是「跳掉选项后第一个剩下的 token 才是闸门命令」, 它必须shutil.which命中;后面的 token 找不到是正常的。 只有引号解析错位才会让2s抢到「命令」位置 —— 这正是本题的指纹。
4.3 送达要有内核事实佐证(app/manifestd.py)
# __init__:闸门不可用 → 大声失败
try:
self.gate = Gate(cfg["source_timeout_signal"], workdir=work_dir).build()
except GateError as exc:
raise ContractError(
"MANIFEST_SOURCE_TIMEOUT_SIGNAL 导致闸门不可用:%s" % exc) from exc
# request_stop():先确认闸门还活着,再发;发完以内核事实复核
if not obs["gate_live"]:
self.stats["errors"].append(
"要求超时停止,但闸门进程树已不在(pid=%d 收不到信号)" % pid)
return False
...
self.stats["signalled"].append(pid)
time.sleep(0.05)
self.stats["stop_delivered"] = not self.gate._is_live() # /proc 复核
if not self.stats["stop_delivered"]:
self.stats["errors"].append("信号已发出但闸门进程树仍然活着(送达未生效)")
_is_live() 只看内核事实:读 /proc/<pid>/status,
State: 非 Z(僵尸进程收不到任何信号)且进程存在才算「还能收到信号」。
4.4 自检必须「算出来」(app/manifestd.py)
def self_consistent(self) -> bool:
complete = self.sync_complete()
if self.stats["ready"] and not complete:
return False # 虚报成功
if not complete:
return False # 契约不允许部分同步 → 不自洽
if self.stats["stop_requested"]:
if not self.stats["stop_delivered"]:
return False
if not self.stats["cleanup_within_budget"]:
return False
if self.stats["errors"]:
return False
return True
同时 finish() 把「清理超预算」写进 errors;
ready 收紧为 sync_complete() and not stop_requested and not errors。
4.5 健康检查填上判据表、退出码分层(app/health.py)
BAD_FIELDS = ("gate_start_error", "gate_note")
def check(stats: dict) -> dict:
issues = []
missing = stats.get("missing_shards") or []
if missing: # 判据落在外部能独立复核的字段上
issues.append("已同步清单缺 %d 个分片:%r" % (len(missing), missing))
...
return {..., "self_consistent": not issues} # 算出来,不抄自述
main() 先过契约闸门(文件缺失 / 预算为负 / 闸门解析失败 → EXIT_CONTRACT=3),
拿不到 report.json → EXIT_BAD=2(判不了 ≠ 没问题),
有真问题 → 2,正常 → 0。
4.6 契约不可用时退出码 3(app/demo.py)
try:
rd = Reader(cfg, shards, payload, policy, slow_ms=SLOW_MS, work_dir=".")
except manifestd.ContractError as exc:
print("[FATAL] %s" % exc)
return manifestd.EXIT_CONTRACT
五、验证
5.1 验收脚本:6/6 BAD → 12/0 OK
docker compose exec -T manifestd bash -c \
'rm -rf app/__pycache__; bash ops/verify.sh'
== A. 超时信号闸门必须可按契约解析 ==
[PASS] 闸门命令可按契约执行(1=可执行) (期望=1 实际=1)
== B. 超时停止的要求必须真的送达闸门 ==
[PASS] 停止信号送达的进程数 (期望=1 实际=1)
[PASS] 「超时停止」相关的错误数 (期望=0 实际=0)
== C. 信号送达必须能被内核事实复核 ==
[PASS] 被停止后闸门进程树内核状态 (期望=gone 实际=gone)
== E. 同步不完整时不许声明 READY,退出码必须非 0 ==
[PASS] 同步未完成时非 0 退出 (退出码=4)
[PASS] 缺失分片必须被如实列出(非空数组) (期望=nonempty 实际=nonempty)
== F. 自述字段不得自证;健康检查要有牙 ==
[PASS] 自述 self_consistent(False=正确) (期望=False 实际=False)
[PASS] 健康检查在同步不完整时非 0 退出 (退出码=2)
== G. 外部独立探针 ==
RESULT: OK
PASS=12 FAIL=0
RESULT: OK
5.2 ops/repro.sh(修复态)
== B. 超时信号闸门是否可按契约解析 ==
闸门命令 token timeout → PATH 命中 /usr/bin/timeout
== C. 超时停止的要求是否真的送达 ==
要求过超时停止 = True,送达的 pid = [118] ← 问题态是 []
== E. 闸门进程树内核事实 ==
pid=118 /proc 还在且非僵尸 = False ← 内核事实佐证「真的停了」
RESULT: OK
== 清单服务自述 ==
{"degraded": true,
"issues": ["已同步清单缺 1 个分片:['part-003.bin']", "只同步了 3 / 4 个分片"],
"self_consistent": false, "ready": false, "expected": 4, "synced": 3}
自述从 true / 退出码 0 变成 false + 退出码 2,
并如实列出缺失分片 —— 诚实上报,不再假绿。
5.3 独立退出码复核
docker compose exec -T manifestd bash -c \
'python3 app/demo.py >/dev/null 2>&1; echo "demo.py rc=$?"; \
python3 -m app.health >/dev/null 2>&1; echo "health rc=$?"'
# demo.py rc=4 (停止未送达 / 同步不完整)
# health rc=2 (有真实问题,不再恒 0)
5.4 负向验证:证明闸门真的有牙
docker compose exec -T manifestd bash ops/negative.sh
== 负向 A:把契约的引号改回不闭合 ==
health 退出码 = 3(应为 3)
[FATAL] 契约不可用(闸门不可用):MANIFEST_SOURCE_TIMEOUT_SIGNAL 无法解析
(引号不闭合?):'"timeout --signal=KILL 2s\'' → No closing quotation
demo.py 退出码 = 3(应非 0)
== 负向 B:超时停止「只记一笔」不校验送达 ==
stop_delivered = True (注入后恒为 True) ← 与内核事实脱钩,被识别
== 负向 C:把「闸门进程不存在」当成「已停止」 ==
PASS=9 FAIL=3 / RESULT: BAD ← 闸门立刻转红
== 负向 D:契约自相矛盾(清理预算为负) ==
health 退出码 = 3
[FATAL] 契约自相矛盾:MANIFEST_STOP_CLEANUP_BUDGET_SECONDS 必须为正
== 负向 E:删掉契约文件(判不了 ≠ 没问题) ==
health 退出码 = 3
[FATAL] 契约不可用:[Errno 2] No such file or directory: '/srv/conf/stop-policy.env'
== 恢复并复验 ==
PASS=12 FAIL=0 / RESULT: OK
用 tokenize 剥掉注释与 docstring 后逐 token 与 ops/expected-fixed/ 参考答案对账:
manifestd.py / health.py / demo.py CODE IDENTICAL,
gate.py 仅内嵌 _ATTACH 字符串里一行装饰性注释不同。逻辑等价。
六、经验与避坑
6.1 核心认知
os.kill()调用过 ≠ 对端收到了。 发完信号要以内核事实复核 ——/proc/<pid>/status里 进程还在不在、State:是不是Z(僵尸进程收不到任何信号)。 没有这一步,「已停止」就是一句自述。- 带引号的命令行解析失败必须大声失败。
.env/ YAML / cron 里的timeout ...、sh -c "..."、 一切「引号不闭合但看起来跑得通」的配置 —— 静默拿前半段 token 照跑比直接报错危险一万倍。 你的测试里有没有「引号不闭合」这个用例? - 只取「跳掉选项后的第一个 token」判可达性。
timeout --signal=KILL 2s里2s是timeout的参数、 PATH 里没有它是正常的。对所有 token 做shutil.which会把正常契约误判成故障。 self_consistent不能是手写常量,也不能是写反的表达式。ready or not sync_complete()这种把「没声明成功」 当成「所以自洽」的写法,正好在部分完成时返回True。 自检必须算出来,且与外部能独立复核的字段一致。- 「判不了」≠「没问题」。
解析不了 / 契约缺失 / 拿不到报告 → fail-closed(非 0 退出),
不许退化成「跳过」。
BAD_FIELDS = ()这种空判据表 等于关掉了整个健康检查。 - 退出码恒 0 是静默故障的温床。
main()无条件return EXIT_OK时, 编排/探活看到的永远是「一切正常」。 - 「整个清单/索引/账本」的完整性只认事实文件。
不看自己写的计数器,看那个文件本身。
MANIFEST_PARTIAL_SYNC_ALLOWED=0时部分完成不算成功。
6.2 定位这类问题的实用顺序
- 先按字节把配置原值打出来(
repr()+list(val.encode())), 肉眼看不出的引号错配,字节序列一看就露。 - 单独证明它解析不了(
shlex.split/bash -n)—— 别在整条链路里猜。 - 看闸门进程的
pid:pid=0就是「压根没起来」, 而不是「起来了但被杀了」。 - 直接打桩打印每层返回值,别信
self_consistent/degraded/ 退出码。 - 只有外部独立探针可信:它只读契约 + 真实文件 +
/proc事实。
6.3 踩坑记录(本项目实测)
ops/negative.sh靠「源码文本替换」做注入,注释措辞也是契约的一部分。 我第一版按自己理解写注释,负向 B直接崩在AssertionError: 负向 B 注入点未找到。 把②闸门进程树必须真的还能收到信号、③送达必须有内核事实佐证、# 契约不允许部分同步 → 不自洽与参考答案对齐后全部通过。 启示:用文本替换做负向验证的仓库里,别随意改注释。ops/apply_fix.sh --restore会把app/与conf/一起打回问题态。 我为了确认「问题态确实能复现」跑了一次--restore—— 结果把刚写的修复整套覆盖掉了(app/6 个文件 + 契约)。 幸好修复脚本幂等,重跑一遍即可。 正确做法:确认问题态用git stash/ 另开目录 / 看完立刻重跑修复, 别在「已改好」的工作区上直接--restore。docker compose exec在非交互流水线里必须加-T, 否则报the input device is not a TTY。app/__pycache__会掩盖改动:切换修复态/问题态前 一定rm -rf app/__pycache__,否则可能读到旧字节码。app/是 bind mount,改完立刻生效、无需 rebuild; 但若改动落在了COPY进镜像的副本上,就得up -d --build。state/是 bind mount 的运行期产物(已在.gitignore里)。 验收/负向脚本会往里写report.json、round.json、manifest.json、*.bak。提交前git status要确认只有预期的 5 个文件改动。ops/negative.sh只在修复态才肯跑(前置grep校验), 问题态下直接退出 1 并提示先--apply。
6.4 一句话自查
发信号之后,有没有一道以内核事实(
/proc)复核送达的闸门? 配置里带引号的命令行,解析失败是大声失败还是静默降级? 「同步完成」的判据,看的是自己写的计数器,还是那个事实文件本身?
6.5 同类影响面
- 一切
timeout/--preserve-status/ systemdTimeoutStopSec/ K8sterminationGracePeriodSeconds:优雅停止失效; shlex.split、bash -c、.env/ YAML / cron 里的带引号命令行;- supervisor /
docker stop/ SIGTERM 转发链路(PID 1 吞信号); - 增量同步 / 断点续拉 / 分片上传的完整性判定 (rsync、restic、S3 multipart、Kafka 消费位点);
- 一切「自述状态自证」的健康检查与「退出码恒 0」的巡检脚本;
- shell 脚本里的
No closing quotation/ `unexpected EOF while looking for matching `` 类报错。
