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

@@ -227,14 +227,17 @@ func tuningUnitLabel(spec memoryTidyTuningSpec) string {
}
}
// tuningValueClipRunes 是错误文案里回显「收到的值」的 rune 上限。
const tuningValueClipRunes = 40
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
//
// 🔴 按 **rune** 截断,**不按字节**:值里完全可能是中文(主人就爱填中文),按字节切
// 会把一个多字节字符切成两半 —— 回显里因此出现半截 UTF-8(JSON 编码后变成 U+FFFD
// 替换符),而这条文案的用途正是让人**看清自己填错了什么**。手法与同包的
// `clipRunes`(notifications.go)同源,直接复用而不是再写一份。
func clipJSON(raw json.RawMessage) string {
const maxClip = 40
s := strings.TrimSpace(string(raw))
if len(s) <= maxClip {
return s
}
return s[:maxClip] + "…"
return clipRunes(strings.TrimSpace(string(raw)), tuningValueClipRunes)
}
// isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。
@@ -2,11 +2,14 @@ package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -261,3 +264,52 @@ func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
t.Fatal("没有数据目录时不该解析出落点")
}
}
// TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe 「非法值」的文案会把**收到的
// 值**回显出来(这是它的用途:让人看清自己填错了什么),而主人完全可能填中文 ——
// 回显必须按 rune 截断:按**字节**切会把一个汉字切成半截(非法 UTF-8,经 JSON 编码后
// 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。
//
// 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是
// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,第 40 字节正好切在第 13 个
// 汉字的**第三个字节**上。若只堆汉字(引号 1 + 3n 字节),40 会恰好落在 rune 边界上,
// 那样这条用例会**假绿**(实测过:按字节截断也照样通过)。
func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) {
t.Parallel()
// ① 纯函数层:截断结果必须是合法 UTF-8(这一条与 JSON 层无关,最直接)。
long := json.RawMessage(`"a` + strings.Repeat("三", 60) + `"`)
clipped := clipJSON(long)
if !utf8.ValidString(clipped) {
t.Fatalf("clipJSON 切出了非法 UTF-8:%q", clipped)
}
if strings.ContainsRune(clipped, utf8.RuneError) {
t.Fatalf("clipJSON 结果里有替换符:%q", clipped)
}
// ② 端点层:400 文案里不许出现替换符,且开头几个字仍要如实回显。
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400}`)
payload := `a` + strings.Repeat("三", 60)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"quietStartHour": "`+payload+`"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if !utf8.ValidString(eobj.Message) || strings.ContainsRune(eobj.Message, utf8.RuneError) {
t.Fatalf("回显里出现了替换符/非法 UTF-8(半个汉字被切了):%q", eobj.Message)
}
if !strings.Contains(eobj.Message, "a三三三") {
t.Fatalf("文案应当仍回显开头几个字:%q", eobj.Message)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
}