Skip to content

Commit 523ff0a

Browse files
committed
fix(建图): 建图标志卡死 —— 服务端取消不落终态 + 轮询加墙钟兜底
- kl-server:ingest/improve 任务被取消时 CancelledError 继承 BaseException、不走 except Exception,/status 恒报 running,桌面端轮询方永远等不到终态;两个 job 补取消分支(写终态后 re-raise),队列 finally 再兜底补写 error —— building 标志不再永久卡死(此前仅重启应用可恢复) - 桌面端:awaitIngest 轮询加 3 小时墙钟上限(正常建图最坏实测 129 分钟,永远碰不到);到点按 cancelled 收场并记 WARN、复位 buildProgress,不报 error —— error 会进 consecutiveFailures 触发 30 分钟退避,正是当年删掉 45min 超时的原因 - 测试:readStatus 恒返 running + 时钟快进超限 → 断言 cancelled 收场、building 复位、落 WARN
1 parent 81b3c7a commit 523ff0a

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

‎apps/desktop/src/main/services/kl-server.service.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ const INGEST_PROBE_FAILURE_LIMIT = 5
114114
/** 建图进度轮询间隔。分钟级任务,3s 足够且不会刷屏 UI。 */
115115
const INGEST_POLL_INTERVAL_MS = 3_000
116116

117+
/**
118+
* 建图轮询的**墙钟上限**(3 小时)—— 挂死保险丝,不是性能判据。
119+
*
120+
* ★★ 为什么在"刻意不设时间上限"之后又要加一个、而且加这么大:
121+
*
122+
* 正常建图最坏实测约 129 分钟,3 小时留了一倍以上的裕量 —— 正常建图
123+
* 永远碰不到它。它兜的是另一类故障:server 侧任务被取消却没落终态
124+
* (CancelledError 不走 except Exception),`/status` 恒报 running,轮询方
125+
* 就永远等不到 done/error —— `building` 卡 true、自动建图入口永久禁用,
126+
* 只能重启应用。那种挂死没有任何**进度**判据能识别(Phase A 整块提交,
127+
* 中途无增量可看),只能靠墙钟。
128+
*
129+
* ★ 触发后按 cancelled 收场(WARN + 复位),绝不报 error:error 会进上层
130+
* 的 `consecutiveFailures` → 触发 30 分钟退避 → 后续自动建图被推迟
131+
* —— 45min 超时当年正是因此被删掉的(见 `awaitIngest` 里的注释)。
132+
* "放弃观察"与"建图失败"是两件事。
133+
*/
134+
const INGEST_WALL_CLOCK_LIMIT_MS = 10_800_000
135+
117136
/** `/status` 里 ingest 那一段(我们只取用得上的字段)。 */
118137
export interface KlIngestSnapshot {
119138
state: "idle" | "running" | "done" | "error"
@@ -1610,7 +1629,7 @@ export class KlServerService {
16101629
let consecutiveProbeFailures = 0
16111630

16121631
/**
1613-
* ★★ 这里**没有时间上限**,是刻意的。
1632+
* ★★ 这里没有"太慢就算失败"的上限 —— 但有一条 3 小时的墙钟兜底。
16141633
*
16151634
* ## 原来有一个 45min 的 `INGEST_TIMEOUT_MS`,它是个假失败源
16161635
*
@@ -1639,12 +1658,14 @@ export class KlServerService {
16391658
*
16401659
* ## 那靠什么结束
16411660
*
1642-
* 只靠**明确的终态**:`state` 变成 `done` / `error`。加一条兜底:
1643-
* 连续多次连不上 `/status`(进程真没了),那时 `handle.alive` 也会是 false。
1644-
* 这两个都是"事实"而不是"推测"。
1645-
*
1646-
* 真出现挂死时的表现是"建图一直显示在跑" —— 而那与事实一致(进程确实在、
1647-
* 只是不出结果),用户仍可停服务或重启应用。比谎报一个失败诚实。
1661+
* 只靠**事实**而不是推测:
1662+
* · `state` 变成 `done` / `error`(server 自己报的终态);
1663+
* · 连续多次连不上 `/status` 且子进程句柄已死(进程真没了);
1664+
* · 墙钟超过 `INGEST_WALL_CLOCK_LIMIT_MS`(3 小时)—— 兜的是 server 侧
1665+
* 任务被取消却没落终态、`/status` 恒报 running 的那种挂死(见常量
1666+
* 的注释)。到点按 cancelled 收场(WARN + 复位),绝不报 error:
1667+
* error 会进 `consecutiveFailures` 触发退避 —— 45min 超时当年正是
1668+
* 因此被删的。3 小时远超正常建图最坏的 129 分钟,正常建图碰不到它。
16481669
*/
16491670
for (;;) {
16501671
/**
@@ -1663,6 +1684,22 @@ export class KlServerService {
16631684
return { error: null, cancelled: true, ...counts, volume }
16641685
}
16651686

1687+
/**
1688+
* ★★ 墙钟兜底 —— 见循环外注释的最后一节。
1689+
*
1690+
* 放在探测**之前**:等满 3 小时还没终态,多探一次也不会改变结论。
1691+
* 返回形状与 stopping 同款(cancelled、无失败原因),但记 WARN ——
1692+
* 这不是预期路径,值得留一条痕去查 server 侧为什么 3 小时不出终态。
1693+
* `buildProgress` 必须一并复位,否则 UI 上永远挂着停在半路的百分比。
1694+
*/
1695+
if (this.options.clock.now() - startedAt >= INGEST_WALL_CLOCK_LIMIT_MS) {
1696+
this.buildProgress = null
1697+
this.options.logger.warn("graph build wall-clock limit reached; giving up waiting", {
1698+
elapsedMs: this.options.clock.now() - startedAt,
1699+
})
1700+
return { error: null, cancelled: true, ...counts, volume }
1701+
}
1702+
16661703
let snapshot: KlIngestSnapshot | null
16671704
try {
16681705
snapshot = await readStatus(this.port)

‎kl-graph/kl_server.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,15 @@ async def _run_single_ingest_job(req: IngestRequest):
696696
detail = f"ingest complete with warning: {result.warning}"
697697
_set_progress("done", "", 1.0, detail)
698698
logger.info("Background ingest complete.")
699+
except asyncio.CancelledError:
700+
# 任务被取消(清场路径会对整个 ingest 任务 task.cancel())。
701+
# CancelledError 继承自 BaseException,不会被下面的 except Exception
702+
# 接住 —— 若不在这一支落终态,/status 将永远停在 running,轮询方
703+
# 再也等不到 done/error,桌面端的建图标志就此卡死(只能重启恢复)。
704+
# 写完终态必须 re-raise:吞掉取消会让任务以正常状态结束,
705+
# 等待方无从知道它其实是被掐断的。
706+
_set_progress("error", "", 0.0, "cancelled", "ingest cancelled")
707+
raise
699708
except Exception as e:
700709
logger.exception("Background ingest failed")
701710
_set_progress("error", "", 0.0, "", str(e))
@@ -724,6 +733,12 @@ async def _run_single_improve_job(req: ImproveRequest) -> None:
724733
)
725734
_set_progress("done", "", 1.0, "full improvement complete")
726735
logger.info("Background full improvement complete.")
736+
except asyncio.CancelledError:
737+
# 与 ingest job 同一个坑:CancelledError 不走 except Exception,
738+
# 不在这里落终态的话 /status 会恒报 running,轮询方永远等不到终态。
739+
# 写完终态再 re-raise,理由见上面那个 job 里的注释。
740+
_set_progress("error", "", 0.0, "cancelled", "improvement cancelled")
741+
raise
727742
except Exception as e:
728743
logger.exception("Background full improvement failed")
729744
_set_progress("error", "", 0.0, "", str(e))
@@ -753,6 +768,14 @@ async def _run_ingest_queue(first: tuple[str, object]) -> None:
753768
pending = state.ingest_queue.pop(0) if state.ingest_queue else None
754769
finally:
755770
state.ingest_task = None
771+
# 兜底终态:正常路径每个 job 自己会写 done/error,但取消等异常路径
772+
# 可能从任意 await 点逃出 while 而没落到终态。progress 仍停在
773+
# running 的话,/status 会永远报 running —— 轮询方的建图标志随之
774+
# 卡死。宁可补一个 error 终态让轮询方能收敛;文案刻意写「没到终态」,
775+
# 与 job 自己落库的报错区分开。
776+
progress = state.ingest_progress or {}
777+
if progress.get("state") == "running":
778+
_set_progress("error", "", 0.0, "", "ingest queue ended without terminal state")
756779

757780

758781
@asynccontextmanager

‎tests/unit/desktop/kl-server.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,69 @@ describe("KlServerService · 建图(rebuildGraph 走 server 的 /ingest)", (
885885
expect(polls).toBeGreaterThanOrEqual(1000)
886886
})
887887

888+
/**
889+
* ★★ 墙钟兜底:/status 恒 running(server 侧任务被取消却没落终态的形状)
890+
* → 超过 3 小时后放弃观察,按 cancelled 收场且 building 复位。
891+
*
892+
* ## 这条守的是「building 卡 true」的死锁
893+
*
894+
* server 侧任务被取消时若没落终态,/status 会永远报 running;而
895+
* `awaitIngest` 又只认终态 —— 两个"各自正确"的行为合起来就是死锁:
896+
* building 恒 true → 自动建图入口永久禁用,只能重启应用。
897+
*
898+
* ## 为什么断言 cancelled 而不是 error
899+
*
900+
* error 会被上层记进 `consecutiveFailures` → 触发 30 分钟退避 → 后续
901+
* 自动建图被推迟 —— 45min 超时当年正是因此被删掉的。墙钟兜底只是
902+
* "放弃观察",不是"建图失败",必须走 cancelled 那条路。
903+
*/
904+
it("★★ /status 恒 running 超过墙钟上限 → cancelled 收场且 building 复位", async () => {
905+
process.env[KL_PYTHON] = "/fake/python"
906+
// 记下 WARN:超时不是预期路径,必须留痕才能从日志查到"为什么放弃了"
907+
const warns: string[] = []
908+
const recorder = {
909+
debug: () => {},
910+
info: () => {},
911+
warn: (message: string) => {
912+
warns.push(message)
913+
},
914+
error: () => {},
915+
child: () => recorder,
916+
} as unknown as ConstructorParameters<typeof KlServerService>[0]["logger"]
917+
const clock = new ManualClock(1_000)
918+
let polls = 0
919+
const svc = makeService({
920+
runner: fakeRunner(),
921+
probeHealth: async () => true,
922+
clock,
923+
logger: recorder,
924+
exportDir: "/tmp/exports/dws",
925+
postIngest: async () => 200,
926+
readStatus: async () => {
927+
polls += 1
928+
// 第 3 轮把时钟快进 3 小时+ —— 等价于"server 挂死、/status 恒
929+
// running"跑满墙钟上限(真实耗时仍是毫秒级)。之后永远 running。
930+
if (polls === 3) clock.advance(3 * 60 * 60 * 1000 + 1)
931+
return snap({ state: "running", phase: "phase_a", percent: 0.4 })
932+
},
933+
})
934+
935+
const r = await svc.rebuildGraph()
936+
937+
// 确实走了轮询循环(不是 POST/前置闸的早退)
938+
expect(polls).toBeGreaterThanOrEqual(3)
939+
// 与 stop() 打断同款:不是失败、没有失败原因
940+
expect(r.ok).toBe(false)
941+
expect(r.cancelled).toBe(true)
942+
expect(r.reason).toBeNull()
943+
// ★ 核心:building 复位,否则建图入口永久禁用(正是这个 bug 的形态)
944+
expect(svc.status().building).toBe(false)
945+
// 进度也要清掉,否则 UI 上永远挂着停在半路的百分比
946+
expect(svc.status().buildProgress).toBeNull()
947+
// 超时记 WARN(不是 error —— error 会进上层的失败计数触发退避)
948+
expect(warns.some((w) => w.includes("wall-clock"))).toBe(true)
949+
})
950+
888951
/**
889952
* ★ 那靠什么结束 —— **进程真没了**才算失败。
890953
*

0 commit comments

Comments
 (0)