diff --git a/tools/annotate_parallel/main.go b/tools/annotate_parallel/main.go index 468e34c..1b1d3b4 100644 --- a/tools/annotate_parallel/main.go +++ b/tools/annotate_parallel/main.go @@ -51,6 +51,14 @@ func main() { fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err) continue } + // 幂等:块内已有并发声明就跳过。 + // + // 不加这条时,重跑会在已标注的工具上**再插一份** —— 而 + // duplicate field name 是编译期错误,跨文件批量跑时定位成本很高。 + if blockHasDecl(lines, j.tool) { + fmt.Printf(" %s 已有声明,跳过\n", j.tool) + continue + } field := "ParallelSafe: true," if j.kind == "serial" { field = "Serial: true," @@ -67,7 +75,12 @@ func main() { // findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。 func findInsertPoint(lines []string, tool string) (int, error) { + // 匹配两种注册形式: + // RegisterTool("get_article", ...) 字面量 + // RegisterTool(tp+"get_article", ...) 变量前缀 + 字面量 + // 只认字面量会漏掉后者 —— example 里绝大多数是变量前缀形式。 head := fmt.Sprintf(`RegisterTool("%s"`, tool) + alt := fmt.Sprintf(`+"%s"`, tool) start := -1 for i, l := range lines { if strings.Contains(l, head) { @@ -75,6 +88,23 @@ func findInsertPoint(lines []string, tool string) (int, error) { break } } + if start < 0 { + // ★ 必须 RegisterTool( 与字面量在**同一行**。 + // + // 我第一版只找含 `+"name"` 的行,命中了函数体里的散落字面量 + // (vanblog 的 handleAuth 里满是 "restore"/"update" 这类 case 分支), + // 起点错到函数体中间,深度追踪再也回不到 2 ⇒ 插入点落在 1300+ 行, + // 把文件改坏。 + // + // 症状离原因很远:报错说"expected 1 expression",指向的是一处 + // 看起来完全正常的 case 分支。 + for i, l := range lines { + if strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(") { + start = i + break + } + } + } if start < 0 { return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool) } @@ -85,6 +115,11 @@ func findInsertPoint(lines []string, tool string) (int, error) { inStr := false esc := false for i := start; i < len(lines); i++ { + // peak = 本行内的峰值深度。 + // + // 每行重置:它表示"这一行曾深入到多深",不是全程最大值 —— + // 全程最大值一旦到过 3 就永远是 3,"曾进入 Parameters"判据随之失效。 + peak := depth for _, ch := range lines[i] { if esc { esc = false @@ -105,6 +140,9 @@ func findInsertPoint(lines []string, tool string) (int, error) { case '(', '{', '[': depth++ started = true + if depth > peak { + peak = depth + } case ')', '}', ']': depth-- } @@ -133,16 +171,53 @@ func findInsertPoint(lines []string, tool string) (int, error) { // 2. Name:/Description: 本来就在深度 2,早于 Parameters。 // 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前, // 编译报 "expected 1 expression"。 - if started && depth >= 3 { + // 判定"进入过 Parameters"要按**行内峰值深度**,不能只看行末净深度。 + // + // get_meta 的 Parameters 全在一行: + // Parameters: map[string]interface{}{"type":"object","properties":map[string]interface{}{}}, + // 这行净深度变化是 0(进去又出来)—— 只看净深就永远察觉不到曾进入 + // 深度 3 ⇒ 追踪一路跑到 1305 行才"收敛",插入点落在某个 case 分支 + // 中间,文件改坏。症状离原因很远:报错指向一处看起来完全正常的 + // switch case。 + if started && peak >= 3 { enteredAt = i } - if enteredAt >= 0 && i > enteredAt && depth <= 2 { + // 闭合判定:进入过 Parameters(enteredAt)之后,深度回到 2 的那一行 + // **就是** Parameters 的闭合行;插入点取它的**下一行**。 + // + // ★ 不能要求 i > enteredAt:Parameters 写在单行时(get_meta 就是) + // enteredAt 与闭合行是**同一行**,加上这个条件会跳到再下一行, + // 插到 log.Printf 之前 —— 不报错,但声明落在了字面量外面。 + if enteredAt >= 0 && i >= enteredAt && depth <= 2 { return i + 1, nil } } return 0, fmt.Errorf("括号深度追踪未收敛") } +// blockHasDecl 报告该工具的字面量里是否已有并发声明。 +func blockHasDecl(lines []string, tool string) bool { + head := fmt.Sprintf(`RegisterTool("%s"`, tool) + alt := fmt.Sprintf(`+"%s"`, tool) + start := -1 + for i, l := range lines { + if strings.Contains(l, head) || (strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(")) { + start = i + break + } + } + if start < 0 { + return false + } + // 从注册行往后找 30 行(工具定义不会更长) + for i := start; i < len(lines) && i <= start+30; i++ { + if strings.Contains(lines[i], "ParallelSafe:") || strings.Contains(lines[i], "Serial:") { + return true + } + } + return false +} + func readLines(p string) []string { f, err := os.Open(p) if err != nil { diff --git a/tools/audit_parallel.go b/tools/audit_parallel.go index ed9e50e..ffe5211 100644 --- a/tools/audit_parallel.go +++ b/tools/audit_parallel.go @@ -300,18 +300,35 @@ func indexMethods(fset *token.FileSet, files map[string]*ast.File) map[string]*m continue } name := fd.Name.Name + recv := "" if fd.Recv != nil && len(fd.Recv.List) > 0 { - if t, ok := fd.Recv.List[0].Type.(*ast.StarExpr); ok { + rt := fd.Recv.List[0].Type + if t, ok := rt.(*ast.StarExpr); ok { if id, ok := t.X.(*ast.Ident); ok { - name = "*" + id.Name + "." + name + recv = id.Name } + } else if id, ok := rt.(*ast.Ident); ok { + recv = id.Name + } + if recv != "" { + name = "*" + recv + "." + name } } - out[name] = &methodImpl{ - file: path, - line: fset.Position(fd.Pos()).Line, - body: fd.Body, - locked: hasMutexIn(fd.Body), + // ★ recv 与 selfCalls 都必须填:scanWritesDeep 用 recv 组候选键去 + // 跟进下层方法,用 selfCalls 才知道要跟进谁。缺任何一个, + // 递归跟进就是空转 —— handlerRestart → p.stopServer/startServer + // 这类"只调两个方法"的写法会静默漏判成只读。 + mi := &methodImpl{ + file: path, + line: fset.Position(fd.Pos()).Line, + body: fd.Body, + recv: recv, + locked: hasMutexIn(fd.Body), + selfCalls: selfCallsOf(fd.Body), + } + out[name] = mi + if recv != "" { + out[recv+"."+fd.Name.Name] = mi } } } @@ -325,7 +342,11 @@ func handlerImplOf(call *ast.CallExpr, fset *token.FileSet, methods map[string]* } switch a := call.Args[2].(type) { case *ast.FuncLit: // 闭包 - return &methodImpl{file: "", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body)} + // ⚠️ recv 留空是有代价的:scanWritesDeep 跟进 selfCalls 时用 + // "*"+recv+"."+name 找方法实现,recv 为空就永远找不到 ⇒ + // 闭包里对 p.xxx(...) 的调用全部追不下去,write 被静默漏掉。 + // 症状是"看起来有扫描、实际漏判"。 + return &methodImpl{file: "", line: fset.Position(a.Pos()).Line, body: a.Body, locked: hasMutexIn(a.Body), selfCalls: selfCallsOf(a.Body)} case *ast.SelectorExpr: // p.handleRead // // ★ 这里踩过一个隐蔽的坑:调用点的接收者是**变量名**(p), @@ -380,7 +401,15 @@ func toolNameOf(call *ast.CallExpr, fset *token.FileSet) string { if lit, ok := a.Y.(*ast.BasicLit); ok { rhs = strings.Trim(lit.Value, `"`) } - return lhs + "_" + rhs + // 归一化:变量前缀(p.name / tp / p.tp)在运行期才确定, + // 静态只保留字面量那一段,前缀分隔符一并去掉。 + if _, isIdent := a.X.(*ast.Ident); isIdent { + return rhs + } + if i := strings.Index(lhs, "."); i >= 0 { + lhs = lhs[i+1:] + } + return lhs + rhs case *ast.Ident: return a.Name case *ast.SelectorExpr: @@ -447,19 +476,25 @@ var writePatterns = []struct{ what, pat string }{ {"mkdir", "os.MkdirAll"}, {"http-post", "http.Post"}, {"http-do", "client.Do"}, - {"sdk-set", ".Set("}, - {"sdk-save", ".Save("}, - {"sdk-update", ".Update("}, - {"sdk-delete", ".Delete("}, - {"sdk-add", ".Add("}, - {"sdk-install", ".Install("}, - {"sdk-restart", ".Restart("}, - {"sdk-shutdown", ".Shutdown("}, - {"write", ".Write("}, - {"write", ".WriteString("}, - {"start", ".Start("}, - {"stop", ".Stop("}, - {"kill", ".Kill("}, + // ⚠️ 这些模式**不带括号**:callString 收集的是链上方法名并用点连起来 + // ("Settings.Set"),不是完整调用文本。我第一版写成 ".Set(", + // 于是永远匹配不上 —— handleConfigure(写配置 + 启停服务)被判只读。 + // 症状是"漏判",比误判更难发现:结果看起来仍然合理。 + {"sdk-set", ".Set"}, + {"sdk-save", ".Save"}, + {"sdk-update", ".Update"}, + {"sdk-delete", ".Delete"}, + {"sdk-add", ".Add"}, + {"sdk-install", ".Install"}, + {"sdk-restart", ".Restart"}, + {"sdk-shutdown", ".Shutdown"}, + {"write", ".Write"}, + {"start", ".Start"}, + {"stop", ".Stop"}, + {"kill", ".Kill"}, + {"os-write", "os.WriteFile"}, + {"os-remove", "os.Remove"}, + {"exec", "exec.Command"}, } func scanWrites(body *ast.BlockStmt) []finding { @@ -588,12 +623,56 @@ func diagOf(call *ast.CallExpr) string { return fmt.Sprintf("%T", call.Args[2]) } +// knownReadOnlyCalls 是**确认无副作用**的调用/构造器。 +// +// ⚠️ 这张表必须显式列出,不能靠"名字不像写操作"来猜:我第一版用 +// looksLikeExternal(黑名单),结果 Marshal / ReadAll / NewRequest / +// NewReader 这些**纯读**的标准库调用全被判"可能写" ⇒ 只读的 +// get_article 变成 SERIAL,120 个工具里 119 个被判串行 —— +// 等于工具没在工作,却看上去在工作(保守方向不会引起怀疑)。 +// +// 判定原则:**默认怀疑,明确信任**。写不动的东西要逐个列出来。 +var knownReadOnlyCalls = map[string]bool{ + // 格式化 + "Sprintf": true, "Fprintf": true, "Errorf": true, "Fatalf": true, + "Printf": true, "Sprintln": true, "Sprint": true, "Sscanf": true, + // 字符串(纯函数) + "String": true, "TrimSpace": true, "Trim": true, "TrimPrefix": true, + "TrimSuffix": true, "Split": true, "SplitN": true, "Join": true, + "Replace": true, "ReplaceAll": true, "ToLower": true, "ToUpper": true, + "Contains": true, "HasPrefix": true, "HasSuffix": true, "Fields": true, + "Repeat": true, "EqualFold": true, "Title": true, + // 数值 + "Min": true, "Max": true, "Abs": true, "Round": true, "Floor": true, "Ceil": true, + // 编解码(纯变换) + "Marshal": true, "Unmarshal": true, "NewDecoder": true, "NewEncoder": true, + // JSON 读取(只读文件/流,不写) + "NewReader": true, "ReadAll": true, "Read": true, "Decode": true, + // HTTP 只读侧 + "NewRequest": true, "NewRequestWithContext": true, "Parse": true, "ParseForm": true, + // 时间 + "Now": true, "Unix": true, "ParseDuration": true, "After": true, + // 容器 + "New": true, "NewMap": true, "Keys": true, "Values": true, + "Len": true, "Cap": true, "Copy": true, "Append": true, + // 容器查 + "Get": true, "Load": true, "Exists": true, "List": true, "Query": true, + // 文件只读 + "Stat": true, "ReadDir": true, "ReadFile": true, "Glob": true, "Walk": true, + // 错误 + "Is": true, "As": true, "Unwrap": true, "Error": true, +} + func looksLikeExternal(name string) bool { - switch name { - case "Errorf", "Sprintf", "Fatalf", "Printf", "String", "TrimSpace", - "Split", "Join", "New", "Now", "Unix", "Abs", "Min", "Max", "Len", "Cap": + if knownReadOnlyCalls[name] { return false } + // 指针/包前缀形式(strings.TrimSpace)取最后一段 + if i := strings.LastIndex(name, "."); i >= 0 { + if knownReadOnlyCalls[name[i+1:]] { + return false + } + } return true }