代码里的唯一事实来源是
tiktok-app/src/protocol.js与tiktok-auto/src/protocol.js(两份实体文件,除挂载点一行外逐字节相同,由tiktok-app/tools/protocol-check.js强制)。 本文档是给人读的解释,任何数字与代码冲突时以Protocol.SPEC为准。
两条进程(界面主脚本 + engines.execScriptFile 起的子引擎)之间只有文件通道,
没有任何共享内存。在此之前,两边各自写死判断:
| 症状 | 原因 |
|---|---|
| 引擎在做 25 秒的 watch,界面却报"引擎已退出" | 没有心跳,只能靠 engines.all() + 4 秒宽限期猜 |
| 点了开始、界面说"运行中",其实引擎连文件都还没读 | 启动间隙是模糊态 |
| 点了停止、界面说"正在收尾",其实引擎还没听见 | 停止只有一段超时,说不清是没听见还是没做完 |
状态枚举散在三处(engine.js / main.js / records.js) |
漏改一处就变成零报错的悬挂 |
| 文件 | 写方 | 读方 | 频率 |
|---|---|---|---|
run-state.json |
引擎主流程 bridge.report |
界面 App.pollEngine |
~1 次/条目 + 首尾 |
engine-beat.json |
引擎心跳线程(唯一写者) | 界面 | 每 2 秒 |
engine-config.json |
界面 | 引擎 | 1 次/run |
stop.flag |
界面 | 引擎主流程 + 心跳线程 | 1 次/run |
run-log.jsonl |
引擎 logger sink | 界面(执行日志页) | 流式 |
run-config.json |
界面 | 人(没有程序读者) | 1 次/run |
心跳单独成文件的理由:run-state 由主流程按条目写,心跳每 2 秒一次,
两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
run-state.json(v2 只增不改){
"v": 2,
"runId": "run-1783000000000-12345",
"status": "running",
"phase": "搜索 fashion",
"done": 2,
"total": 5,
"seq": 17,
"startedAt": 1783000001234,
"updatedAt": 1783000045678,
"engine": { "task": "video", "capabilities": { "heartbeat": true } },
"message": "",
"summary": null
}
seq 单调递增(每次 report +1),用来判断"同一毫秒内的两次写谁更新"engine.capabilities.heartbeat 是正向证据:只有引擎真的把心跳线程起起来才会是 trueengine.task 由 bridge.setTask() 在 config.resolve 之后补,用它排障"跑的是哪条链路"engine-beat.json{ "v": 2, "runId": "run-…", "seq": 42, "beatAt": 1783000045000, "stopSeenAt": 0 }
stopSeenAt != 0 = 心跳线程确认"看见了本轮停止请求",是停止 ack 的快速通道| from → | starting | running | stopping | finished/stopped | failed |
|---|---|---|---|---|---|
| (无) | ✅ | ||||
| starting | 幂等 | ✅ | ✅ | ✅(极短任务) | ✅(配置无效) |
| running | 幂等 | ✅ | ✅ | ✅ | |
| stopping | 幂等 | ✅ | ✅ | ||
| 终态 | 无出边 |
Protocol.legality(from, to) 返回:
| 返回值 | 含义 | 调用方应有的反应 |
|---|---|---|
ok |
表里有的合法迁移 | 正常处理 |
idempotent |
原地重复上报 | 忽略 |
regress |
从终态往外走 | 拒绝 |
unknown |
表里没有(多半是前向兼容来的新状态) | 放宽,只在日志留一句,绝不据此结束运行 |
Protocol.SPEC.thresholds)| 常量 | 值 | 语义 |
|---|---|---|
beatIntervalMs |
2000 | 心跳间隔 |
beatFreshMs |
8000 | 新鲜。只用于"加速判死",不产生任何文案 |
beatDeadMs |
15000 | 判死 |
startAckSoftMs |
6000 | 只把文案降成「启动中(较慢)」,不改状态 |
startAckMaxMs |
15000 | 判 failed,并补写 stop.flag(挡住迟到的引擎) |
stopAckMs |
3000 | 等"引擎承认收到停止" |
stopFinishMs |
10000 | 承认之后等"真的收完尾" |
legacyChildGraceMs |
4000 | v1 回退:子引擎宽限期(改造前的值) |
legacyStopTimeoutMs |
8000 | v1 回退:停止超时(改造前的值) |
legacyNoBeatDeadMs |
30000 | 引擎说有 v2 但心跳线程没起来时的判死阈值 |
App.pollEngine)anchor = beatAt>0 ? beatAt : (ackedAt || startedAt)
limit = capabilities.heartbeat ? beatDeadMs : legacyNoBeatDeadMs
accel = capabilities.heartbeat ? beatFreshMs : legacyChildGraceMs
判死 = (now - anchor > limit) // 主判据:心跳
|| (!childAlive() && now - anchor > accel) // 加速:列表里也没了
★ 反过来的方向不成立:心跳新鲜时,engines.all() 说"没有子引擎"绝不能判死。
引擎在做长等待时从列表里消失是常态,这正是要防的那类误判。
★ v1(run-state 里没有 v):完全走老口径 —— sawChild && startedAt > 4s && !childAlive()。
status → 按非终态处理,永不据此结束运行v 高于本机 → 只读降级,只认 runId/status/phase/done/total,不报错run-state.v >= 2,或engine-beat.jsonreadState/readBeat 返回 null,任何状态都不改变(尤其不得据此判死)files 的 stat 语义在各运行时不一致)界面 引擎
│ 写 stop.flag (内容=runId) │
│ 状态条:正在通知引擎 │
│ ────────────────────────────────────▶ │ 心跳线程每 2s 读一次 flag
│ │ 命中 → 写 beat.stopSeenAt(sticky)
│ ◀──────────────────────────────────── │
│ stopAckedAt = now → 正在收尾 │ 主流程 wait.tick → checkStop
│ │ → report(stopping) → throw StopSignal
│ ────────────────────────────────────▶ │ finally: finish(stopped) → 停心跳
│ ◀──────────────────────────────────── │ run-state.status = stopped
│ finishRun() │
stopSeenAt(快,不受主流程阻塞影响)和 run-state.status === 'stopping'(慢,要等主流程走到下一个 tick)业务代码里所有等待都走 wait.tick()(tickMs 在 200~250ms 量级),而 checkStop 节流 500ms
—— 主流程最长约 750ms 就会自己发现停止请求,比心跳的 2 秒更快。
所以正常路径上先到的总是 run-state.status === 'stopping' 那条通道。
心跳 ack 只在主线程被系统层面卡住(GC、IO 抖动、页面假死)时才可能先到。 它的价值是"保险丝",不是"主路"。
★ 因此不要指望在真实流程里抢到观察窗口(抢不到:uiautomator dump 一次就要 700ms,
而窗口只有几百毫秒)。要验这根线通不通,用一个阻塞桩:
主线程 sleep(30000) 完全不看 flag,另起独立线程每 2s 写 beat 并读 flag 写 stopSeenAt。
这样"界面从「正在通知引擎」翻到「正在收尾」"就一定是心跳促成的。
cd tiktok-app && $NODE tools/protocol-check.js # 两份协议一致 + 迁移表自洽 + 阈值关系
$NODE tools/build.js && $NODE tools/smoke.js # 界面行为
cd ../tiktok-auto && $NODE tools/build.js && $NODE tools/pc-selftest-bridge.js
★ protocol-check 里那条"两侧除挂载点外逐字节相同"是硬约束:
要改就改一份再同步另一份(sed 替换挂载行即可),不要两边手改。