From d9c2e51f616db9e492898f9e7124ade2b12dffde Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 18 Oct 2026 13:07:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20cancel=20=E5=81=9C=E4=B8=8D?= =?UTF-8?q?=E4=B8=8B=E8=B0=83=E5=BA=A6=E5=BE=AA=E7=8E=AF=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=98=9F=E5=88=97=E9=9D=9E=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E6=B0=B8=E8=BF=9C=E4=B8=8D=E7=9C=8B=20ctx.Done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷(真缺陷,不是测试问题) `schedulerLoop` 只在"队列空"的分支里 select `ctx.Done()`: if kind == nextNone { select { ... case <-a.ctx.Done(): return } } 于是**只要队列里有任务,循环就永远不看 ctx.Done** —— 一直取、一直执行, 直到队列被取空才可能退出。 生产后果(`reclaim` / `destroy`):销毁流程先回收残余任务、再销毁子。 而回收到销毁之间子仍在运行,继续把队列里的任务取走执行 —— 那些任务的 结果**永远无人认领**(子随即被销毁),且日志无痕迹:既不在"残余清单"里, 也没跑到出结果。带 ResponseCh 的同步调用方(cli/a2a)更糟:Stop 的 drainPendingInterrupts 给它补一个 "skipped",用户看到"已跳过", 而任务其实执行过。 ## 修法 1. `schedulerLoop` 循环顶部(**取任务之前**)检查 ctx.Done。 2. 新增 `waitSchedulerStopped`:cancel 之后等 running 归零(上限 5s, 超时如实记日志,不让控制面永久挂住)。 3. 新增 `FinishResident`:停止调度 → 回收残余 → 处置 → 收割记忆 → 销毁, **一步完成**,消除"取完到销毁"之间的窗口。`reclaim` / `destroy` 改走它。 4. 记忆收割抽成 `harvestResidentMemory`,供 FinishResident 的 onDestroy 复用 (必须在 Destroy 之前 —— Destroy 会关掉 temp)。 ## 判据(2 条,都做了"撤销修复必须变红"的验证) · TestSchedulerStopsTakingTasksAfterCancelWithNonEmptyQueue 用阻塞 provider 把循环钉在 executeNewTask 里,此时塞 5 条进队列、 cancel、放行。修复在位 ⇒ 5 条一条都不执行;撤销修复 ⇒ 取走 5 条。 **实测撤销后报「仍取走了 5 条(剩 0/5)」,恢复后 10/10 绿。** · TestFinishResidentIsAtomic 断言返回后子已从登记表移除(销毁确实在同一事务内完成)。 原 flaky 的 TestResidualKeepReturnsTasksToParent 与 TestResidualDropNotifiesSyncCaller 也修了:先 cancel + 等静止再入队, 得到确定的初始状态(它们验的是转交逻辑,不是子的生命周期)。30/30 绿。 ## 我踩过的两条弯路(都写进判据注释了) 1. 加 `scheduler.freeze` 绕过。错在:调度器本来就有停止语义(ctx), 再引入一个状态位会让"谁负责停"含糊,且忘了解除就永久僵住。已删。 2. 判据第一版写成「先 Start、再 cancel、然后入队」—— 那时队列是**空的**, 循环正好走 nextNone 看到 ctx.Done 退出,于是**撤掉产品修复判据依然 30/30 全绿**。是用户追问"凭什么说修好了"才发现这是假绿。 必须造出"队列非空时 cancel"这个确切场景才测得到。 --- internal/agent/core/offload_test.go | 21 +++ internal/agent/core/resident.go | 124 +++++++++++++--- internal/agent/core/resident_tools.go | 40 +++--- internal/agent/core/residual_freeze_test.go | 152 ++++++++++++++++++++ internal/agent/core/scheduler.go | 50 +++++++ 5 files changed, 352 insertions(+), 35 deletions(-) create mode 100644 internal/agent/core/residual_freeze_test.go diff --git a/internal/agent/core/offload_test.go b/internal/agent/core/offload_test.go index 38bd81c4..17c266f1 100644 --- a/internal/agent/core/offload_test.go +++ b/internal/agent/core/offload_test.go @@ -484,6 +484,22 @@ func TestResidualKeepReturnsTasksToParent(t *testing.T) { child := root.residents[info.ID].agent root.residentMu.Unlock() + // ★ 必须先停住子的调度器,再入队 —— 否则它不是确定性测试。 + // + // SpawnResident 末尾会 child.Start()(产品行为:子创建即开工), + // 于是子会在两次 enqueue 之间取走一条开始执行。而"正在执行"的那条 + // **不属于"尚未处理"**(TakeResidual 只取 queue/interrupt/immediate, + // 不取 s.running)⇒ 本测试断言的 2 条就只剩 1 条可回收。 + // 实测:约 1/30 概率失败于 `应处置 2 条,实际 1`。 + // + // 这不是产品缺陷:真实场景里残余任务是"子已停止、队列里剩下的", + // 天然静止;本测试要验的是**转交逻辑**(含 ResponseCh 保留), + // 不是子的生命周期。先 cancel + 等静止,就得到确定的初始状态。 + // + // 注意必须用 cancel(调度器本来就有停止语义)而不是任何自定义标志位。 + child.cancel() + child.sched.waitSchedulerStopped(schedStopTimeout, info.ID) + // 给子塞两条尚未处理的残余任务(一条带同步回执、一条不带=模拟 qq) syncCh := make(chan *agentIO.OutputEvent, 1) child.sched.enqueue(newInputTask(&agentIO.InputEvent{ @@ -536,6 +552,11 @@ func TestResidualDropNotifiesSyncCaller(t *testing.T) { child := root.residents[info.ID].agent root.residentMu.Unlock() + // ★ 同 TestResidualKeepReturnsTasksToParent:先停住子调度器再入队。 + // 否则子可能先把这条取走执行,于是"残余任务"为 0 条,断言失效。 + child.cancel() + child.sched.waitSchedulerStopped(schedStopTimeout, info.ID) + respCh := make(chan *agentIO.OutputEvent, 1) child.sched.enqueue(newInputTask(&agentIO.InputEvent{ RequestID: "d1", Source: "cli", OutputChannel: "cli", diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 9ed26427..4da5c577 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -315,6 +315,65 @@ func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) { return rc.agent.sched.takeAllPendingEvents(), nil } +// FinishResident 是**驻留子终结的唯一入口**:停止调度 → 回收残余任务 → +// 处置 → 收割记忆 → 销毁。 +// +// ## 它修的是什么竞态(2026-10-18) +// +// 原实现把这件事拆成三步,由控制面工具依次调用: +// +// ApplyResidual(id, policy) // 取队列 +// ReclaimResident / DestroyResident(id) +// +// 于是取完到销毁之间有一个窗口:子仍在运行、继续收新消息、开始执行, +// 而它马上会被销毁 —— 那些任务的结果**永远无人认领**,且日志无痕迹 +// (既不在"残余清单"里,也没跑到出结果)。用 ResponseCh 的同步调用方 +// 更糟:Stop 的 drainPendingInterrupts 会给它补一个 "skipped", +// 调用方看到"已跳过",而任务其实执行过。 +// +// ## 为什么是"停止调度"而不是"冻结队列" +// +// 我第一版加过 `scheduler.freeze`(让 nextRef 返回 nextNone)。那是错的: +// +// · 它凭空多出一个状态位,而调度器**本来就有**停止语义 —— `ctx.Done()`; +// · 真正的问题是 schedulerLoop 只在"队列空"时才 select ctx.Done, +// 只要队列非空就永远不看它(见 schedulerLoop 里的修复)。 +// · 一个状态机的缺陷用另一个状态去掩盖,会让"到底谁负责停"变得含糊。 +// +// 现在:cancel 关 ctx.Done ⇒ 调度循环在**下一次取任务前**就退出 +// (不再取新任务),已在执行的那一条跑完后循环自然结束。 +// 等它真正停下,再回收队列 —— 此刻队列是静止的,回收没有歧义。 +func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(rc *residentChild) error) (n int, msg string, err error) { + a.residentMu.Lock() + rc := a.residents[id] + a.residentMu.Unlock() + if rc == nil || rc.agent == nil { + return 0, "", fmt.Errorf("驻留子 %s 不存在", id) + } + child := rc.agent + + // ① 停止调度:关 ctx.Done。调度循环在下次取任务前退出。 + child.cancel() + // ② 等它真的停下(已在执行的那条会跑完,不再取新的)。 + child.sched.waitSchedulerStopped(schedStopTimeout, id) + + // ③ 此刻队列静止,回收残余任务没有歧义。 + events := child.sched.takeAllPendingEvents() + n, msg, err = a.applyResidualEvents(id, events, policy) + if err != nil { + return n, msg, err + } + + // ④ 收割记忆(必须在 Destroy 之前 —— Destroy 会关掉 temp)。 + if onDestroy != nil { + if herr := onDestroy(rc); herr != nil { + return n, msg, herr + } + } + // ⑤ 销毁(内部会 Stop 子、关 temp、放通道)。 + return n, msg, a.DestroyResident(id) +} + // ApplyResidual 按父给出的策略处置一个驻留子的残余任务。 // // keep:逐条转回父自己的队列(保留原事件,含 ResponseCh 与来源/通道); @@ -323,10 +382,23 @@ func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) { // 无论哪种,带 ResponseCh 的都要给终态,否则 cli/a2a 这类无超时同步调用方 // 会永久挂起(设计 §7 I5)。 func (a *Agent) ApplyResidual(id string, policy ResidualPolicy) (int, string, error) { - events, err := a.TakeResidual(id) - if err != nil { - return 0, "", err + // ⚠️ 本函数只取残余、**不终止子**,因此取完之后子仍在运行 —— + // 控制面工具请用 FinishResident(停止调度 + 取 + 处置 + 销毁,一步完成), + // 否则"取完到销毁之间"子还会收新任务,那些任务的结果随销毁丢失。 + // + // 保留它是为了 TakeResidual 的配对语义(先取后自己决定),以及既有判据。 + a.residentMu.Lock() + rc := a.residents[id] + a.residentMu.Unlock() + if rc == nil || rc.agent == nil { + return 0, "", fmt.Errorf("驻留子 %s 不存在", id) } + events := rc.agent.sched.takeAllPendingEvents() + return a.applyResidualEvents(id, events, policy) +} + +// applyResidualEvents 按策略处置已取走的残余任务(keep 转回父 / drop 留痕丢弃)。 +func (a *Agent) applyResidualEvents(id string, events []*agentIO.InputEvent, policy ResidualPolicy) (int, string, error) { if len(events) == 0 { return 0, "无残余任务", nil } @@ -475,6 +547,30 @@ func (a *Agent) CompressResident(id string) (int, error) { // → 丢弃 temp → **取消**该驻留子。 // // keep 由父决定"哪些纳入记忆";为 nil 时表示全部合入。 +// harvestResidentMemory 把子 temp 里的活跃三元组收割进主记忆,返回合入条数。 +// +// 必须在 Destroy **之前**调用:Destroy 会 Close 掉 temp。 +func (a *Agent) harvestResidentMemory(rc *residentChild, keep ...func([]InputchRecord, []memory.Triple) []memory.Triple) (int, error) { + if rc == nil || rc.light == nil || rc.light.Temp() == nil || a.mainGraph() == nil { + return 0, nil + } + exported, err := rc.light.Temp().ExportTriples(0) + if err != nil { + return 0, fmt.Errorf("读取子 temp 失败: %w", err) + } + selected := exported + if len(keep) > 0 && keep[0] != nil { + selected = keep[0](rc.info().Table, exported) + } + if len(selected) == 0 { + return 0, nil + } + if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+rc.id, 0); err != nil { + return 0, fmt.Errorf("合入主记忆失败: %w", err) + } + return len(selected), nil +} + func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) { a.residentMu.Lock() rc, ok := a.residents[id] @@ -484,23 +580,13 @@ func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.T } info := rc.info() - var promoted int // 收割:读 temp 的全部活跃三元组(比通过图记录选择更直接)。 - if rc.light != nil && rc.light.Temp() != nil && a.mainGraph() != nil { - exported, err := rc.light.Temp().ExportTriples(0) - if err != nil { - return info, fmt.Errorf("读取子 temp 失败: %w", err) - } - selected := exported - if keep != nil { - selected = keep(info.Table, exported) - } - if len(selected) > 0 { - if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+id, 0); err != nil { - return info, fmt.Errorf("合入主记忆失败: %w", err) - } - promoted = len(selected) - } + // + // 逻辑抽到 harvestResidentMemory,供 FinishResident 的 onDestroy 复用 —— + // 那里也必须**在 Destroy 之前**收割(Destroy 会关掉 temp)。 + promoted, herr := a.harvestResidentMemory(rc, keep) + if herr != nil { + return info, herr } // 收割完成 ⇒ 取消该驻留子(回收是取消语义,≠ 压缩)。 diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go index 3a6431a4..8660df18 100644 --- a/internal/agent/core/resident_tools.go +++ b/internal/agent/core/resident_tools.go @@ -125,27 +125,35 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string { case "reclaim": id := strArg(tc, "id") // 残余任务先由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。 - // 不传 residual 时默认 keep:宁可多做一件,不可默默丢一条。 - residual, rmsg, rerr := a.ApplyResidual(id, residualPolicyOf(tc)) - if rerr != nil { - return fmt.Sprintf("处置残余任务失败: %v", rerr) + // + // ★ 用 FinishResident 一把做完,不要拆成 ApplyResidual + ReclaimResident: + // 拆开时"解冻到销毁"之间子会继续收新任务,那些任务的结果随子一起丢失 + // 且不留痕迹(详见 FinishResident 的说明)。 + // onDestroy 里做记忆收割:读子 temp 的活跃三元组并合入主记忆。 + // 必须在 Destroy 之前 —— Destroy 会关掉 temp(这正是原先 + // ReclaimResident 内部做的顺序)。 + var promoted int + residual, rmsg, ferr := a.FinishResident(id, residualPolicyOf(tc), + func(rc *residentChild) error { + p, herr := a.harvestResidentMemory(rc) + if herr != nil { + return fmt.Errorf("合入主记忆失败: %w", herr) + } + promoted = p + return nil + }) + if ferr != nil { + return fmt.Sprintf("回收失败: %v", ferr) } - info, err := a.ReclaimResident(id, reclaimKeepAll) - if err != nil { - return fmt.Sprintf("回收失败: %v", err) - } - return fmt.Sprintf("残余任务(%d 条):%s\n已回收(temp 中选中的记录已合入主记忆,该驻留子已取消): %s", - residual, rmsg, MarshalResidentInfo(info)) + return fmt.Sprintf("残余任务(%d 条):%s\n已回收(temp 中选中的记录已合入主记忆,promoted=%d,该驻留子已取消)", + residual, rmsg, promoted) case "destroy": id := strArg(tc, "id") // 同上:销毁前先把残余任务交出去,否则它们会随子一起无声消失。 - residual, rmsg, rerr := a.ApplyResidual(id, residualPolicyOf(tc)) - if rerr != nil { - return fmt.Sprintf("处置残余任务失败: %v", rerr) - } - if err := a.DestroyResident(id); err != nil { - return fmt.Sprintf("销毁失败: %v", err) + residual, rmsg, ferr := a.FinishResident(id, residualPolicyOf(tc), nil) + if ferr != nil { + return fmt.Sprintf("销毁失败: %v", ferr) } return fmt.Sprintf("残余任务(%d 条):%s\n已销毁并移除该驻留子", residual, rmsg) diff --git a/internal/agent/core/residual_freeze_test.go b/internal/agent/core/residual_freeze_test.go new file mode 100644 index 00000000..a8f6171f --- /dev/null +++ b/internal/agent/core/residual_freeze_test.go @@ -0,0 +1,152 @@ +package core + +// 判据:`cancel` 之后调度循环**不再取新任务**(2026-10-18 修的真缺陷)。 +// +// ## 缺陷 +// +// `schedulerLoop` 原来只在"队列空"的分支里 select `ctx.Done()`: +// +// if kind == nextNone { +// select { ... case <-a.ctx.Done(): return } +// } +// +// 于是**只要队列里有任务,循环就永远不看 ctx.Done** —— 一直取、一直执行, +// 直到队列被取空才可能退出。 +// +// 后果(生产路径 `reclaim`/`destroy`):销毁流程先取残余任务、再销毁子, +// 而取完到销毁之间子仍在运行,继续把队列里的任务取走执行 —— 那些任务的 +// 结果**永远无人认领**(子随即被销毁),且日志无痕迹:既不在"残余清单"里, +// 也没跑到出结果。 +// +// ## 判据必须造出"队列非空时 cancel"这个确切场景 +// +// 我第一版判据犯过两个错,都记在这里: +// +// 1. 加了 `scheduler.freeze` 绕过 —— 调度器本来就有停止语义(ctx), +// 再引入一个状态位会让"谁负责停"含糊,且忘了解除就永久僵住。已删。 +// 2. 判据写成「先 Start、再 cancel、然后入队」。但那时队列是**空的**, +// 循环正好走 nextNone 分支看到 ctx.Done 退出 —— 于是**撤掉产品修复 +// 判据依然通过**(实测 30/30 全绿)。那是假绿,测的是"空队列能退出", +// 而不是真正的缺陷("非空队列不会退出")。 +// +// 现在的做法:让**第一条任务正在执行且阻塞住**(provider 卡在 Chat 里), +// 于是循环停在 executeNewTask 内;此时再塞入若干任务并 cancel、放行。 +// 若修复在位 ⇒ 循环处理完当前任务后看到 ctx.Done 就退出,队列里的新任务 +// **一条都不会被执行**。若修复不在 ⇒ 它会继续把队列取空。 + +import ( + "context" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// blockingProvider 的 Chat 会一直阻塞,直到 release 被关闭。 +// +// 用途:把调度循环**钉在 executeNewTask 里**,从而在"队列非空 + 任务正在跑" +// 这个确切时刻做 cancel,复现原缺陷的场景。 +type blockingProvider struct { + entered chan struct{} // 第一次进入 Chat 时关闭(供判据等待) + release chan struct{} // 关闭后 Chat 返回 +} + +func (p *blockingProvider) Name() string { return "blocking" } +func (p *blockingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + select { + case <-p.entered: + default: + close(p.entered) + } + <-p.release + return &agentAPI.CompletionResponse{Content: "ok"}, nil +} +func (p *blockingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + return nil, context.Canceled +} +func (p *blockingProvider) MaxContextTokens() int { return 8192 } + +// ★★★ 判据:队列非空时 cancel ⇒ 剩余任务一条都不被执行。 +func TestSchedulerStopsTakingTasksAfterCancelWithNonEmptyQueue(t *testing.T) { + prov := &blockingProvider{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + a := New(AgentConfig{ + ID: "sched-nonempty", + Provider: prov, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + DataDir: t.TempDir(), + }) + defer a.Stop() + + // ① 第一条:让循环进去执行并卡在 Chat 里。 + a.sched.enqueue(newInputTask(&agentIO.InputEvent{ + RequestID: "running", Source: "cli", + Payload: map[string]interface{}{"content": "hi"}, + })) + a.Start() + + select { + case <-prov.entered: + case <-time.After(5 * time.Second): + t.Fatal("provider 未被调用 —— 调度循环没跑起来") + } + + // ② 此刻:循环卡在 executeNewTask(running 非空),再塞 5 条进队列。 + const pending = 5 + for i := 0; i < pending; i++ { + a.sched.enqueue(newInputTask(&agentIO.InputEvent{ + RequestID: "pending", Source: "qq", + Payload: map[string]interface{}{"content": "x"}, + })) + } + + // ③ 在这个时刻 cancel(**队列非空** —— 这正是原缺陷的场景),然后放行。 + a.cancel() + close(prov.release) + + // ④ 给它足够时间:修复在位时循环处理完当前任务即退出; + // 修复缺失时它会把 5 条全部取走执行。 + time.Sleep(1200 * time.Millisecond) + + if got := a.sched.queueLen(); got != pending { + t.Fatalf("★ 队列非空时 cancel 后仍取走了 %d 条(剩 %d / %d)——\n"+ + " 调度循环没有在取任务前检查 ctx.Done。\n"+ + " 生产后果:reclaim/destroy 取完残余到销毁之间,子继续执行那些任务,\n"+ + " 结果随销毁永久丢失且日志无痕迹。", + pending-got, got, pending) + } +} + +// ★★★ 判据:FinishResident 把「停止调度 → 回收残余 → 销毁」做成一次事务。 +// +// 它防的是:调用方拆成 ApplyResidual + Destroy 两步时,中间那段窗口里 +// 子仍在收新任务。断言"返回后子已从登记表移除",即销毁确实在同一事务内完成。 +func TestFinishResidentIsAtomic(t *testing.T) { + root, main := newRootWithoutSchedulerLoop(t) + defer main.Close() + defer root.Stop() + + info, err := root.SpawnResident(ResidentOptions{ + ID: "res-fin", TempPath: root.residentTempPath("res-fin"), + }) + if err != nil { + t.Fatal(err) + } + + if _, _, err := root.FinishResident(info.ID, ResidualKeep, nil); err != nil { + t.Fatalf("FinishResident 失败: %v", err) + } + + root.residentMu.Lock() + _, still := root.residents[info.ID] + root.residentMu.Unlock() + if still { + t.Fatal("★ FinishResident 之后子仍在登记表 —— 销毁没被纳入同一事务," + + "取残余到销毁之间的窗口仍存在") + } +} diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index a1cd5860..1fbf7b1d 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -1233,6 +1233,27 @@ func (a *Agent) schedulerLoop() { }() for { + // ★★ 退出检查必须在**取任务之前**(2026-10-18 修)。 + // + // 原实现只在"队列空"分支里 select ctx.Done: + // + // if kind == nextNone { + // select { ... case <-a.ctx.Done(): return } + // } + // + // 于是只要队列里有任务,循环就**永远不会看 ctx.Done** —— + // 一直取、一直执行,直到队列被取空才可能退出。 + // + // 这就是"cancel 了却停不下来"的根因:销毁流程调 child.cancel() + // 之后,子仍把队列里的任务全取走执行,而那些任务的结果随销毁丢失。 + // (之前我为此加了一个 freeze 标志绕过 —— 那是把一个状态机的 + // 缺陷用另一个状态掩盖;正确的修法是在这里尊重 ctx。) + select { + case <-a.ctx.Done(): + return + default: + } + a.pumpInbox() t, f, kind := a.sched.nextRef() @@ -1261,6 +1282,35 @@ func (a *Agent) schedulerLoop() { } } +// schedStopTimeout 是等调度器停下的上限。 +// +// 不能无限等:子可能正跑一个长工具调用(几十秒)。超时就如实记日志并继续 —— +// 宁可让那条正在执行的任务由 Stop 的 drainPendingInterrupts 补一个终态, +// 也不要让父的控制面调用永久挂住。 +const schedStopTimeout = 5 * time.Second + +// waitSchedulerStopped 等调度循环不再持有运行中的任务。 +// +// 前提:调用方已 cancel(关 ctx.Done)。调度循环在**下次取任务前**看到 +// ctx.Done 就退出(见 schedulerLoop 顶部的修复),于是 running 归零。 +// +// 为什么不用一个 frozen 标志:调度器本来就有停止语义(ctx), +// 再引入一个状态会让"谁负责停"含糊,且忘了解除就永久僵住。 +func (s *scheduler) waitSchedulerStopped(timeout time.Duration, name string) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + s.mu.Lock() + running := s.running + s.mu.Unlock() + if running == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + log.Printf("[resident] %s 调度器 %s 内未停止(仍在执行任务),"+ + "残余任务可能少回收一条(那条会由 Stop 补终态)", name, timeout) +} + // pumpInbox 把 channel 里**已就绪**的输入搬进就绪队列(非阻塞)。 // // 为什么不直接边收边执行:先把已到达的输入收进队列,选择函数才有意义——