Files
Cyrene_v2/internal/runtime/memory/tidy_service_tuning_test.go
T
AskaEth 97c0c1b94a feat(memory): 调优重排改为唤醒通道,不再 Stop/Start(消灭与到期清扫的耦合)
按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop
都没有了。

- tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel;
  loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只
  重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot,
  在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。
  Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。
- tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。
  唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次
  等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。

两条冲突就此消失,各配一条可复现断言:
- **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已
  到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照
  (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫
  的「启动即扫一轮」会立刻把它删掉。
- **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码
  已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点
  (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop
  各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过
  (不再 panic)。

热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx
取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不
跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。

变异自证(都跑过再还原,`diff` 逐字一致):
- 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红;
- 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。

M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时
返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。
2026-10-13 22:17:22 +08:00

443 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
// 不要求重启后端)的两条判据:
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
// tuningInt 返回面板调优用的 *int。
func tuningInt(v int) *int { return &v }
// tuningBool 返回面板调优用的 *bool。
func tuningBool(v bool) *bool { return &v }
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
t.Parallel()
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: tuningInt(10800),
QuietIntervalSec: tuningInt(36000),
AutoApply: tuningBool(false),
}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
}
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
}
if !svc.cfg.DisableAutoApply {
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
}
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
}
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
}
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
// 而不是留在上一次的 10800。
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if svc.cfg.IntervalSec != 86400 {
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
}
if svc.cfg.QuietIntervalSec != 86400 {
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
}
if svc.cfg.DisableAutoApply {
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
}
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
}
}
// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在
// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被
// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。
type tuneWaitProbe struct {
mu sync.Mutex
delays []time.Duration
calls chan time.Duration
}
func newTuneWaitProbe() *tuneWaitProbe {
return &tuneWaitProbe{calls: make(chan time.Duration, 32)}
}
func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool {
p.mu.Lock()
p.delays = append(p.delays, d)
p.mu.Unlock()
select {
case p.calls <- d:
default:
}
select {
case <-ctx.Done():
return false
case <-time.After(30 * time.Second):
// 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。
return false
}
}
// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。
func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration {
t.Helper()
select {
case d := <-p.calls:
return d
case <-time.After(5 * time.Second):
t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step)
return 0
}
}
// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制**
// (唤醒,而不是 Stop/Start):
//
// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m);
// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完;
// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判);
// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。
func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(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)
// 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
t.Fatalf("ApplyPanelTuning(idle): %v", err)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if running {
t.Fatal("未 Start 时不该因为保存而启动心跳")
}
probe := newTuneWaitProbe()
svc.wait = probe.wait
svc.mu.Lock()
svc.lastRun = at.Add(-30 * time.Minute)
svc.mu.Unlock()
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute {
t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got)
}
// 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning(running): %v", err)
}
if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute {
t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got)
}
svc.mu.Lock()
last, runningAfter := svc.lastRun, svc.running
svc.mu.Unlock()
if !runningAfter {
t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)")
}
if !last.Equal(at.Add(-30 * time.Minute)) {
t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last)
}
}
// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」:
// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。
//
// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。
// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会
// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。
func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) {
ctx := context.Background()
now := tidyTestTime(t)
store := newTestStore(t)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
var (
mu sync.Mutex
audits []string
)
svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now),
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
expireAudits := func() int {
mu.Lock()
defer mu.Unlock()
n := 0
for _, a := range audits {
if strings.HasPrefix(a, expireAuditVerb+" ") {
n++
}
}
return n
}
// ① 先证明清扫确实在跑:启动清扫会把这一行删掉。
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute),
})
svc.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Stop(stopCtx)
})
waitUntilRowGone(t, store, first.ID)
afterStart := expireAudits()
// ② 再造一行已到期的,这次没有任何「启动清扫」来删它。
pending := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute),
})
// ③ 面板保存(值真的变了,因此一定会走重排)。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
// 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。
time.Sleep(200 * time.Millisecond)
if !memoryRowExists(t, store, pending.ID) {
t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期")
}
if got := expireAudits(); got != afterStart {
t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if !running {
t.Fatal("面板保存不该把心跳停掉")
}
}
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
// 时不打扰心跳:那一次保存不该白白停一次心跳。
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
t.Parallel()
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) })
same := normalizeTidyConfig(base)
if svc.cfg != same {
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
}
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(noop): %v", err)
}
if got := svc.nextDelay(); got != 8*time.Hour {
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
}
}
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
// **重算**:清掉即回代码默认(中档结构性为空)。
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
t.Parallel()
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
// 面板关闸 + 1 小时 TTL。
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
}
if reviewer.cfg.TempTTL != time.Hour {
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
}
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
if reviewer.gateEnabled() {
t.Fatal("关闸后 gateEnabled 仍为 true")
}
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
}
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
}
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
}
}
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
t.Parallel()
store := newTestStore(t)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
}
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
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)
}
}