From a87fbeb49470a6ec2948a337f0017e2c41486726 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:11:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E9=9D=A2=E6=9D=BF=E8=B0=83?= =?UTF-8?q?=E4=BC=98=E5=BC=95=E5=85=A5=E7=9A=84=E4=B8=A4=E5=A4=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E5=86=99=E5=85=A5=E9=9D=A2=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88-race=20=E4=BF=AE=E5=89=8D=E7=BA=A2=20=E2=86=92=20?= =?UTF-8?q?=E4=BF=AE=E5=90=8E=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、审查档(preinsert.go / preinsert_gate.go):面板调优会在**运行期**改 cfg 的 两个字段(DisableIngestGate / TempTTL),而读这两个字段的两处不持锁 (gateEnabled 在同步入库闸上、deferDeadline 在第五态 defer 上)。 - 读侧改走新的 tuningSnapshot()(r.mu 由 Mutex 换成 RWMutex,读锁下取整份快照); - **只**收这两个字段的读点:其余读点读的是构造期冻结的字段,不与写方交手, 故刻意不给入库热路径加无谓的锁;注释写明「将来面板再接管别的字段,那些读点 必须与它一起收」; - 锁序:写方持锁期间只做字段赋值、不取任何别的锁(normalize/回落都在取锁之前 跑完)⇒ 不引入新锁序。 二、整理档(tidy_service_tuning.go):修掉我自己的那条真竞争 —— 原顺序是 「Stop → Start → 写 cfg」,而新起的循环**立刻**读 s.cfg 重算下一拍,与写入并排 (那一拍还可能按旧配置算出来)。改成「Stop → 写 → Start」。 顺带去掉 Stop 的超时分支:超时意味着 wg 的 Wait 仍在飞,紧接着 Start 的 Add 会 踩上 M3 在 tidy_service.go 明文写下的 WaitGroup 复用纪律(-race 下 panic);`Stop` 期间也不持 runMu,避免与「心跳正要开跑一轮」互相等死。 证据(全部逐条跑过再还原,还原用 diff 逐字校验为 0 差异): - 审查档:撤回读侧修复 ⇒ 2 处 DATA RACE(写 preinsert_tuning.go 的两个字段 × 读 gateEnabled / deferDeadline);恢复 ⇒ 绿。 - 整理档:把顺序改回「先起后写」⇒ DATA RACE(写 applyTidyConfig × 读 nextDelay); 恢复 ⇒ 绿。 - 新增两条只在 -race 下有意义的回归哨兵(tidy_service_tuning_test.go):审查档真 并发读写;整理档用**真心跳**当读者(不伪造生产不存在的调用方,那个入口在包里 只有心跳 goroutine 会调)。 热路径成本:新增的同步是「一次无竞争 RLock + 整份小结构体快照」,隔离实测约 4ns/次(5e6 次共 22.9ms);入库闸那条测量用例的批均值在 2.62ms → 2.71~2.81ms 之间波动,落在那条用例自身的运行间噪声里(同一份代码连跑两次,纯写入基线就从 39µs 飘到 60µs)。 --- internal/runtime/memory/preinsert.go | 39 ++++++- internal/runtime/memory/preinsert_gate.go | 2 +- internal/runtime/memory/preinsert_tuning.go | 16 ++- .../runtime/memory/tidy_service_tuning.go | 109 ++++++++---------- .../memory/tidy_service_tuning_test.go | 97 ++++++++++++++++ 5 files changed, 193 insertions(+), 70 deletions(-) diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index 589aaba..9acbbff 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -174,7 +174,20 @@ type PreInsertReviewer struct { // 推进时钟(见 preinsert_t1_limit.go)。 t1Gate *t1RateGate - mu sync.Mutex + // mu 保护 running / cancel 两个生命周期字段,以及**运行期可变的那两个配置 + // 字段**(`DisableIngestGate` / `TempTTL`,面板调优会改它们,见 + // preinsert_tuning.go 的 ApplyPanelTuning)。 + // + // 它用 RWMutex 而不是 Mutex,是因为后者在**写入路径**上被高频读:同步入库闸 + // 每写一条记忆就要问一次 `gateEnabled()`,第五态 `defer` 还要问一次 + // `deferDeadline()`;读锁之间不互斥,因此这两处不会因为面板保存而互相排队。 + // + // 🔴 口径(切片 MEMPANEL / M4 立):**谁在运行期写 cfg,谁就必须让读它的地方 + // 也持这把锁**。今天运行期只写上面那两个字段,因此只有读这两个字段的两处 + // (`gateEnabled` / `deferDeadline`)走 `tuningSnapshot`;其余读点读的是构造期 + // 冻结的字段,不与写方交手,故**没有**一并加锁(不给热路径加无谓的锁)。 + // 将来面板再接管别的字段,那几处读点必须与它一起收。 + mu sync.RWMutex running bool cancel context.CancelFunc wg sync.WaitGroup @@ -208,10 +221,32 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer }, nil } +// tuningSnapshot 在读锁下取一份配置快照。 +// +// 为什么需要它(切片 MEMPANEL / M4):面板调优(preinsert_tuning.go 的 +// `ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段;在这条特性之前 `cfg` 是 +// 构造期冻结的,读侧无锁本来安全。凡是读这两个字段的地方因此都必须与写方同步, +// 否则就是「写方持锁、读方不持锁」的数据竞争(`go test -race` 可复现)。 +// +// 取**整份**快照而不是逐个字段读,是为了让一次判定看到的是**同一个版本**的配置 +// (两个字段分别读可能落到两次不同的写入之间)。 +// +// 它只做一次字段拷贝、**不调用任何别的函数**,因此不会引入新的锁序:写方 +// (ApplyPanelTuning)持写锁时也只做字段赋值,不会再去等别的锁。 +func (r *PreInsertReviewer) tuningSnapshot() ReviewConfig { + r.mu.RLock() + defer r.mu.RUnlock() + return r.cfg +} + // gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。 // 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。 func (r *PreInsertReviewer) gateEnabled() bool { - return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate + if r == nil { + return false + } + cfg := r.tuningSnapshot() + return cfg.Enabled && !cfg.DisableIngestGate } // gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 —— diff --git a/internal/runtime/memory/preinsert_gate.go b/internal/runtime/memory/preinsert_gate.go index 564d261..efd9bc5 100644 --- a/internal/runtime/memory/preinsert_gate.go +++ b/internal/runtime/memory/preinsert_gate.go @@ -237,7 +237,7 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in // 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此 // 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。 func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time { - return now.UTC().Add(r.cfg.TempTTL) + return now.UTC().Add(r.tuningSnapshot().TempTTL) } // failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。 diff --git a/internal/runtime/memory/preinsert_tuning.go b/internal/runtime/memory/preinsert_tuning.go index a22c405..32b09e7 100644 --- a/internal/runtime/memory/preinsert_tuning.go +++ b/internal/runtime/memory/preinsert_tuning.go @@ -81,11 +81,17 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int { // 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码 // 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。 // -// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` / -// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个 -// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长 -// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成 -// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。 +// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`cfg` 在这条特性 +// 之前是**构造期冻结**的,读侧无锁本来安全;这里开了「运行期改两个字段」这条路, +// 于是读这两个字段的地方也必须同步。写侧持 `r.mu` 的**写锁**;读侧两处 +// (`gateEnabled` / `deferDeadline`)走 `r.tuningSnapshot()` 取读锁快照。 +// 其余读点读的是构造期冻结的字段(`Enabled` / `QueueSize` / `Timeout` / `Threshold` / +// T1 四件套…),与写方不交手,因此**刻意没有**一并加锁(不给入库热路径加无谓的锁); +// 将来面板再接管别的字段,那些读点必须与它一起收。 +// +// 锁序:本方法持锁期间**只做字段赋值**,不去取任何别的锁(`normalizeReviewConfig` / +// `ApplyReviewTuning` 都在取锁**之前**跑完)⇒ 不引入新的锁序,也就不可能与既有路径 +// 形成环。 func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error { next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log)) r.mu.Lock() diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index 97bcb8d..0b5b2e4 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -2,10 +2,8 @@ package memory import ( "context" - "errors" "fmt" "log/slog" - "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) @@ -40,48 +38,59 @@ import ( // 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。 // // 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound` -// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu → -// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个 -// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后 -// 已经全部结束,因此也不与写入交手。 - -// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。 +// 与手动轮 / 试算 `DryRun` 都持它),而它排除的是**轮次**;调度读(`nextDelay` / +// `dueNow` 读 `s.cfg`)不受 runMu 保护,因此写 `s.cfg` 必须发生在「没有任何 goroutine +// 读它」的窗口里 ⇒ 顺序铁律是 **先停、再写、后起**: // -// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了 -// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期), -// 绝不静默假装重排成功。 -const memoryTidyRescheduleTimeout = 5 * time.Second - -// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于 -// 下一次调度判定,只是当前那次等待没有被作废)。 -var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out") +// Stop(等到心跳与清扫两只后台件都退出)→ 写 cfg → Start(新循环读到的是新配置) +// +// 三条都是实测踩出来的,不是推演: +// - **不能「先拿 runMu 再 Stop」**:心跳若正卡在 runMu 上准备开跑一轮,Stop 等的就是 +// 一个被我挡在锁门口的 goroutine ⇒ 活锁到超时; +// - **不能「Stop 完先 Start 再写」**:新循环立刻就会读 `s.cfg` 重算下一拍,与写入并排 +// ⇒ `go test -race` 直接报竞争,而且那一拍还可能按**旧**配置算出来; +// - **Stop 不设超时**:超时分支里 Wait 仍在飞,紧接着 `Start` 的 `wg.Add` 会踩上 +// `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律(交叉调用 Start/Stop +// 在 `-race` 下 `panic: sync: WaitGroup is reused before previous Wait has returned`)。 +// 等它排空的最坏代价是「等正在跑的那一轮跑完」,而这条路径是人点一次保存触发的, +// 不持 runMu ⇒ 不会与「心跳要开跑一轮」互相锁死。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // -// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是 -// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。 +// 返回 nil 表示**值已生效**(心跳在跑时也已被重排,下一拍按新周期算);返回错误时 +// 值**没有**落到运行期(落盘那一步早已完成,调用方据此如实回 `hot:false` + 原因)。 +// +// 顺序与三条由来见文件头:**先停 → 再写 → 后起**。 func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { next := tidyPanelTuning(base, panel, s.log) - // 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意 - // 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop - // 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。 - s.runMu.Lock() - defer s.runMu.Unlock() - s.mu.Lock() + running := s.running unchanged := s.cfg == next s.mu.Unlock() - if unchanged { - // 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。 - s.applyTidyConfigLocked(next) + // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写。 + // 刻意**不**走下面那条写路径:写一个「相同的值」也是写,照样会与调度读 + // 构成竞争,而它一点收益都没有。 return nil } - reschedErr := s.rescheduleTidyHeartbeat() - s.applyTidyConfigLocked(next) - return reschedErr + // 心跳在跑 ⇒ 先让两只后台件(心跳 + 到期清扫)停稳:`s.cfg` 的调度读不持锁, + // 写入必须落在一个「没有读者」的窗口里。**Stop 期间不持 runMu**(否则心跳若正 + // 卡在 runMu 上等开跑,会互相等死)。 + if running { + if err := s.Stop(context.Background()); err != nil { + return fmt.Errorf("memory/tidy: stop heartbeat before tuning: %w", err) + } + } + s.applyTidyConfig(next) + if running { + // 重新起后台件:`Stop` 已经把 running 置为 false,不重启就等于让定时整理 + // **与到期清扫一起**悄悄停摆。父 ctx 用 Background(本对象没有保存父 ctx 的 + // 字段,那在 tidy_service.go 里),关停链本来就由组合根显式 `Stop` 收尾。 + s.Start(context.Background()) + } + return nil } // tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。 @@ -101,43 +110,19 @@ func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log return normalizeTidyConfig(cfg) } -// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让 -// 心跳停下): -// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源; +// applyTidyConfig 把新配置落到**两个**持有者上: +// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源(`s.mu` 下写); // - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。 // // 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。 -func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) { +// +// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方 +// (ApplyPanelTuning)在 `Stop` 期间**不能**持它。前提仍是「心跳已停」:调用方负责。 +func (s *TidyService) applyTidyConfig(cfg TidyConfig) { + s.runMu.Lock() + defer s.runMu.Unlock() s.mu.Lock() s.cfg = cfg s.mu.Unlock() s.tidier.cfg = cfg } - -// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。 -// -// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经 -// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默 -// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。 -// -// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在 -// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop` -// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。 -func (s *TidyService) rescheduleTidyHeartbeat() error { - s.mu.Lock() - running := s.running - s.mu.Unlock() - if !running { - return nil - } - stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout) - defer cancel() - if err := s.Stop(stopCtx); err != nil { - s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out", - "timeout", memoryTidyRescheduleTimeout.String(), "error", err) - s.Start(context.Background()) - return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err) - } - s.Start(context.Background()) - return nil -} diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index 19a8822..d7d6827 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -1,6 +1,7 @@ package memory import ( + "sync" "testing" "time" @@ -211,3 +212,99 @@ func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) { t.Fatal("经 Store 的转调没有落到审查服务上") } } + +// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片 +// MEMPANEL / M4 的快修)。 +// +// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段 +// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧 +// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 —— +// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是: +// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用); +// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。 +// +// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过), +// 有没有竞争交给 race detector 判。跑法: +// +// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/ +func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) { + store := newTestStore(t) + reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()}) + if err != nil { + t.Fatalf("NewPreInsertReviewer: %v", err) + } + const rounds = 200 + var wg sync.WaitGroup + wg.Add(3) + // 写方:模拟面板保存(每次改两个面板键)。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + panel := runtimeconfig.MemoryTidyTuning{ + IngestGate: tuningBool(i%2 == 0), + TempTtlSec: tuningInt(3600 + i), + } + if err := reviewer.ApplyPanelTuning(panel); err != nil { + t.Errorf("ApplyPanelTuning: %v", err) + return + } + } + }() + // 读方一:同步入库闸的开关判定(写入路径)。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + _ = reviewer.gateEnabled() + } + }() + // 读方二:第五态的到期时刻。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + _ = reviewer.deferDeadline(time.Now()) + } + }() + wg.Wait() +} + +// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。 +// +// 背景(实测踩出来的,不是推演):`s.cfg` 的**调度读**(`nextDelay` / `dueNow`) +// 不持锁,因此「面板调优写 cfg」必须落在一个**没有任何 goroutine 读它**的窗口里。 +// 曾经的顺序 `Stop → Start → 写` 在这里会被 -race 抓住:新起的循环立刻读 `s.cfg` +// 重算下一拍,与写入并排(而且那一拍还可能按**旧**配置算出来)。正确顺序是 +// `Stop → 写 → Start`。 +// +// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine +// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让 +// **真心跳**当读者(连跑多轮,每轮都要停一次、起一次),与生产形态一致。 +// +// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法: +// +// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/ +func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) { + at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local) + base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} + svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil) + svc.Start(nil) + t.Cleanup(func() { _ = svc.Stop(nil) }) + + const rounds = 25 + for i := 0; i < rounds; i++ { + panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)} + if err := svc.ApplyPanelTuning(base, panel); err != nil { + t.Fatalf("ApplyPanelTuning(%d): %v", i, err) + } + } + // 每轮重排之后心跳都必须还活着(`Stop` 把 running 置为 false,忘了重启就会静默停摆), + // 且最后一轮的周期真的生效在运行期。 + svc.mu.Lock() + running := svc.running + svc.mu.Unlock() + if !running { + t.Fatal("连着保存多次之后心跳必须仍在跑") + } + if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want { + t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want) + } +}