fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断

review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁
访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**:

- TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与
  ?async=1 那条会起后台 goroutine 的版本(真机 90s 级);
- TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run;
- (原本也漏的)Enabled() 与 Start 的启动日志块。

按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器
内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处;
s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。

新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。
修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回
ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原
(diff 0 差异)。

顺手两条(塔指定):
- 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成
  唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。
- 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经
  JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的
  clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀
  错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
This commit is contained in:
AskaEth committed 2026-10-13 22:33:07 +08:00
1 parent 97c0c1b94a
commit 4df8d6f53a
6 files changed
+167 -29

No files matched your search

@@ -47,10 +47,12 @@ import (
// (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous
// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。
//
// 并发口径:写 `s.cfg` 与调度读(`nextDelay` / `dueNow`)由 `s.mu` 互斥
// (调度读走 `scheduleSnapshot`,与本次改动的同一把锁下取同一版本);写 `tidier.cfg`
// 与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、试算都持它)。因此这里只需要
// 「持 runMu 写 → 发一次唤醒」,不与任何既有锁序冲突。
// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
// 运行期整体覆写,它的**每一个读点**都必须与写方同步,因此所有读点都收进了持锁访问器
// (`enabled()` / `cfgSnapshot()` / `scheduleSnapshot()`:调度读 `nextDelay` /
// `dueNow`、两个活端点的开关判定 `DryRun` / `ManualRun`、以及 `Enabled()` 与 `Start`
// 的启动日志)。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
//