朕喜技术(ZhenxiJS)
2026.10.05
1 分钟阅读

清单服务「超时停止」失效: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。运维看到的四个绿灯:

  1. 服务「跑完了」(只证明进程没崩);
  2. self_consistent: true(自述字段自己证自己);
  3. 健康检查 degraded: false, issues: [](因为判据表是空的);
  4. 退出码 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: []判据表是空的
退出码 0main() 无条件 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 核心认知

  1. os.kill() 调用过 ≠ 对端收到了。 发完信号要以内核事实复核 —— /proc/<pid>/status 里 进程还在不在、State: 是不是 Z(僵尸进程收不到任何信号)。 没有这一步,「已停止」就是一句自述。
  2. 带引号的命令行解析失败必须大声失败。.env / YAML / cron 里的 timeout ...、sh -c "..."、 一切「引号不闭合但看起来跑得通」的配置 —— 静默拿前半段 token 照跑比直接报错危险一万倍。 你的测试里有没有「引号不闭合」这个用例?
  3. 只取「跳掉选项后的第一个 token」判可达性。timeout --signal=KILL 2s 里 2s 是 timeout 的参数、 PATH 里没有它是正常的。对所有 token 做 shutil.which 会把正常契约误判成故障。
  4. self_consistent 不能是手写常量,也不能是写反的表达式。ready or not sync_complete() 这种把「没声明成功」 当成「所以自洽」的写法,正好在部分完成时返回 True。 自检必须算出来,且与外部能独立复核的字段一致。
  5. 「判不了」≠「没问题」。 解析不了 / 契约缺失 / 拿不到报告 → fail-closed(非 0 退出), 不许退化成「跳过」。BAD_FIELDS = () 这种空判据表 等于关掉了整个健康检查。
  6. 退出码恒 0 是静默故障的温床。main() 无条件 return EXIT_OK 时, 编排/探活看到的永远是「一切正常」。
  7. 「整个清单/索引/账本」的完整性只认事实文件。 不看自己写的计数器,看那个文件本身。 MANIFEST_PARTIAL_SYNC_ALLOWED=0 时部分完成不算成功。

6.2 定位这类问题的实用顺序

  1. 先按字节把配置原值打出来(repr() + list(val.encode())), 肉眼看不出的引号错配,字节序列一看就露。
  2. 单独证明它解析不了(shlex.split / bash -n)—— 别在整条链路里猜。
  3. 看闸门进程的 pid:pid=0 就是「压根没起来」, 而不是「起来了但被杀了」。
  4. 直接打桩打印每层返回值,别信 self_consistent / degraded / 退出码。
  5. 只有外部独立探针可信:它只读契约 + 真实文件 + /proc 事实。

6.3 踩坑记录(本项目实测)

  1. ops/negative.sh 靠「源码文本替换」做注入,注释措辞也是契约的一部分。 我第一版按自己理解写注释,负向 B 直接崩在 AssertionError: 负向 B 注入点未找到。 把 ②闸门进程树必须真的还能收到信号、 ③送达必须有内核事实佐证、 # 契约不允许部分同步 → 不自洽 与参考答案对齐后全部通过。 启示:用文本替换做负向验证的仓库里,别随意改注释。
  2. ops/apply_fix.sh --restore 会把 app/ 与 conf/ 一起打回问题态。 我为了确认「问题态确实能复现」跑了一次 --restore —— 结果把刚写的修复整套覆盖掉了(app/ 6 个文件 + 契约)。 幸好修复脚本幂等,重跑一遍即可。 正确做法:确认问题态用 git stash / 另开目录 / 看完立刻重跑修复, 别在「已改好」的工作区上直接 --restore。
  3. docker compose exec 在非交互流水线里必须加 -T, 否则报 the input device is not a TTY。
  4. app/__pycache__ 会掩盖改动:切换修复态/问题态前 一定 rm -rf app/__pycache__,否则可能读到旧字节码。
  5. app/ 是 bind mount,改完立刻生效、无需 rebuild; 但若改动落在了 COPY 进镜像的副本上,就得 up -d --build。
  6. state/ 是 bind mount 的运行期产物(已在 .gitignore 里)。 验收/负向脚本会往里写 report.json、round.json、manifest.json、 *.bak。提交前 git status 要确认只有预期的 5 个文件改动。
  7. ops/negative.sh 只在修复态才肯跑(前置 grep 校验), 问题态下直接退出 1 并提示先 --apply。

6.4 一句话自查

发信号之后,有没有一道以内核事实(/proc)复核送达的闸门? 配置里带引号的命令行,解析失败是大声失败还是静默降级? 「同步完成」的判据,看的是自己写的计数器,还是那个事实文件本身?

6.5 同类影响面

  • 一切 timeout / --preserve-status / systemd TimeoutStopSec / K8s terminationGracePeriodSeconds:优雅停止失效;
  • 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 `` 类报错。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-05-0800-timeout-signal-kill-shlex-no-closing-quotation

分享这篇文章