Skip to content

Commit bf0368d

Browse files
committed
🐛 fix: codegraph-oom
1 parent a1f8e7e commit bf0368d

8 files changed

Lines changed: 416 additions & 33 deletions

File tree

codegraph/go_calls.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package codegraph
22

33
import (
4+
"context"
45
"go/ast"
56
"go/token"
67
"go/types"
@@ -16,7 +17,12 @@ import (
1617
// 调用方应退回语法层近似——绝不因为代码不可编译而漏掉调用边。
1718
func goPreciseCallEdges(root string) ([]Edge, bool) {
1819
fset := token.NewFileSet()
20+
// 超时兜底(issue #115):体量门控已在上游(maybePrecise)拦掉过大项目,这里再加一道
21+
// 时间上限,防止 go list / 类型检查在病态依赖下长时间挂住。
22+
loadCtx, cancel := context.WithTimeout(context.Background(), maxIndexDuration)
23+
defer cancel()
1924
cfg := &packages.Config{
25+
Context: loadCtx,
2026
Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
2127
packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports,
2228
Dir: root,

codegraph/index.go

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,47 @@ func (s Status) Token() string {
4242
}
4343

4444
// 遍历时跳过的目录名(版本控制 / 依赖 / 构建产物 / 缓存 / deepx 自身数据)。
45-
// 注意:按目录"名"匹配,只列不易和源码目录重名的;以 "." 开头的目录在遍历时统一跳过,不必在此重复列。
45+
// 注意:1) 按目录"名"匹配,只列不易和源码目录重名的;以 "." 开头的目录统一跳过,不必在此重复列。
46+
// 2. **键必须全小写** —— shouldSkipDir 用 strings.ToLower(name) 查找以做到大小写不敏感(issue #115)。
4647
var skipDirs = map[string]bool{
4748
".git": true, ".hg": true, ".svn": true,
4849
"node_modules": true, "vendor": true, ".deepx": true,
4950
"dist": true, "build": true, "target": true, ".next": true,
5051
// 各语言依赖 / 缓存 / 构建产物
51-
"bower_components": true, "Pods": true, "__pycache__": true,
52+
"bower_components": true, "pods": true, "__pycache__": true,
5253
"venv": true, "site-packages": true, ".nuxt": true,
5354
"out": true, "obj": true, "coverage": true, "__snapshots__": true,
5455
}
5556

57+
// shouldSkipDir 判断遍历时是否跳过该目录:命中 skipDirs(**大小写不敏感**)或以 "." 开头。
58+
// 大小写不敏感是关键(issue #115):Windows/macOS 文件系统大小写不敏感,目录可能叫 Vendor /
59+
// Node_Modules / VENDOR 等;若按原大小写精确匹配,这些依赖目录会漏跳被索引,大仓直接 OOM。
60+
func shouldSkipDir(name string) bool {
61+
return skipDirs[strings.ToLower(name)] || strings.HasPrefix(name, ".")
62+
}
63+
5664
// 单文件大小上限:超过视为生成 / 压缩产物,跳过,避免拖慢索引。
5765
const maxFileSize = 1 << 20 // 1 MiB
5866

67+
// 单行字节上限(issue #115):任一行超过即视为压缩 / 打包 / 生成 / 数据文件,解析前跳过。
68+
// tree-sitter 的 GLR 在这类超长行上会内存爆炸,且其超时杀不掉已在 native 解析的 goroutine
69+
// (遗弃的 runaway 会持续吃内存直到 OOM)→ 只能从源头不喂这类文件。用 var 便于测试调小。
70+
var maxLineBytes = 40 << 10 // 40 KiB
71+
72+
// hasOverlongLine 报告 src 是否含超过 max 字节的行(O(n) 扫一遍,不分配)。
73+
func hasOverlongLine(src []byte, max int) bool {
74+
start := 0
75+
for i, b := range src {
76+
if b == '\n' {
77+
if i-start > max {
78+
return true
79+
}
80+
start = i + 1
81+
}
82+
}
83+
return len(src)-start > max
84+
}
85+
5986
// 整次构建的扫描预算:任一触顶即停,图谱标记为降级(StatusDegraded)。
6087
// 这是与"根目录是什么"无关的硬兜底 —— 即使 gate 漏判、或撞上病态大仓,
6188
// 也保证构建有界:不会再 CPU 焊死 / 内存无限上涨 / 永不结束。
@@ -66,6 +93,16 @@ var (
6693
maxIndexDuration = 60 * time.Second // 单次遍历耗时上限
6794
)
6895

96+
// 精确 go/types pass 的体量门控(issue #115):go/packages.Load("./...") 带 NeedSyntax|
97+
// NeedTypesInfo 会把整个 module + 依赖树的语法树/类型信息一次性全 load 进内存,无内置上限,
98+
// 大项目下可吃几十 GB 直接 OOM。快图有 512MiB 预算约束,精确 pass 也必须有对应的体量上限:
99+
// 模块自身 Go 源码超过任一阈值,就**跳过精确 pass**、保留已建好的有界快图(Go 调用边退化为
100+
// 语法近似,功能可用)。用 var 便于测试调小。
101+
var (
102+
maxPreciseGoFiles = 2000 // 模块内被精确解析的 .go 文件数上限
103+
maxPreciseGoBytes = int64(32 << 20) // 模块内 .go 源码总字节上限(32 MiB)
104+
)
105+
69106
// projectMarkers:出现在根目录顶层即认为"这是一个项目根",可放心自动预热。
70107
// 正向识别比黑名单健壮 —— home 顶层通常没有这些,真实项目几乎都有。
71108
var projectMarkers = []string{
@@ -156,9 +193,7 @@ type Index struct {
156193

157194
gPrecise bool // 当前 ix.g 是否已是精确图
158195
goSig string // ix.g 精确图对应的 Go 文件签名
159-
cachedPrecise []Edge // 上次算出的精确 Go 调用边(按 cachedSig 缓存)
160-
cachedSig string
161-
preciseRunning bool // 后台精确解析是否在跑(避免并发重复)
196+
preciseRunning bool // 后台精确解析是否在跑(避免并发重复)
162197

163198
rebuildMu sync.Mutex // 保护 rebuildTimer
164199
rebuildTimer *time.Timer // Invalidate 后的防抖重建定时器(连续编辑只在静默后重建一次)
@@ -194,8 +229,8 @@ func NewIndex(root string) *Index {
194229
}
195230

196231
// Disabled 报告图谱是否因危险根被禁用;Reason 返回禁用 / 不自动预热的原因。
197-
func (ix *Index) Disabled() bool { return ix.forbidden }
198-
func (ix *Index) Reason() string { return ix.reason }
232+
func (ix *Index) Disabled() bool { return ix.forbidden }
233+
func (ix *Index) Reason() string { return ix.reason }
199234

200235
// Prewarm 后台预热:开机只建"快图"(语法层,便宜),不阻塞调用方。
201236
// 刻意不在此跑精确解析(go/packages 较重、可能联网)—— 那留到模型真正调用 CodeGraph 时
@@ -215,7 +250,7 @@ func (ix *Index) Prewarm() {
215250
go func() {
216251
ix.mu.Lock()
217252
if ix.g == nil {
218-
g, degraded, err := ix.assemble(nil, false)
253+
g, degraded, err := buildViaSubprocess(ix.root, "quick")
219254
if err != nil {
220255
ix.mu.Unlock()
221256
ix.setStatus(StatusIdle)
@@ -245,7 +280,7 @@ func (ix *Index) Graph() (*Graph, error) {
245280
ix.maybePrecise()
246281
return g, nil
247282
}
248-
g, degraded, err := ix.assemble(nil, false) // 快图:语法近似
283+
g, degraded, err := buildViaSubprocess(ix.root, "quick") // 快图:语法近似
249284
if err != nil {
250285
ix.mu.Unlock()
251286
ix.setStatus(StatusIdle)
@@ -267,7 +302,7 @@ func (ix *Index) Reindex() (int, error) {
267302
return 0, fmt.Errorf("代码图谱已禁用:%s", ix.reason)
268303
}
269304
ix.mu.Lock()
270-
g, degraded, err := ix.assemble(nil, false)
305+
g, degraded, err := buildViaSubprocess(ix.root, "quick")
271306
if err != nil {
272307
ix.mu.Unlock()
273308
return 0, err
@@ -319,7 +354,7 @@ func (ix *Index) scheduleRebuild() {
319354
ix.setStatus(StatusReady)
320355
return
321356
}
322-
g, degraded, err := ix.assemble(nil, false)
357+
g, degraded, err := buildViaSubprocess(ix.root, "quick")
323358
if err != nil {
324359
ix.mu.Unlock()
325360
return // 建失败:保持"更新",留待下次编辑或查询再试
@@ -340,6 +375,11 @@ func (ix *Index) maybePrecise() {
340375
if ix.forbidden {
341376
return // 危险根:绝不跑 go/types(会扫整棵依赖树)
342377
}
378+
// 体量门控(issue #115):Go 源码过大就跳过精确 pass,避免 go/packages 把整棵依赖树
379+
// load 进内存导致 OOM。保留已建好的有界快图即可。
380+
if files, bytes := ix.goCorpusStats(); files == 0 || files > maxPreciseGoFiles || bytes > maxPreciseGoBytes {
381+
return
382+
}
343383
sig := ix.goSignature()
344384
if sig == "" {
345385
return // 没有 Go 文件,免跑
@@ -350,29 +390,18 @@ func (ix *Index) maybePrecise() {
350390
return
351391
}
352392
ix.preciseRunning = true
353-
reuse := ix.cachedSig == sig && ix.cachedPrecise != nil
354-
cached := ix.cachedPrecise
355393
ix.mu.Unlock()
356394

357395
go func() {
358-
edges, ok := cached, reuse
359-
if !ok {
360-
edges, ok = goPreciseCallEdges(ix.root) // 慢:类型检查依赖树
361-
}
362-
if ok {
363-
if g2, _, err := ix.assemble(edges, true); err == nil {
364-
ix.mu.Lock()
365-
ix.g = g2
366-
ix.gPrecise = true
367-
ix.goSig = sig
368-
ix.cachedPrecise = edges
369-
ix.cachedSig = sig
370-
ix.preciseRunning = false
371-
ix.mu.Unlock()
372-
return
373-
}
374-
}
396+
// 精确解析(goPreciseCallEdges + 重新 assemble)整体在受内存限额的子进程里跑,
397+
// 成功才原子换上精确图;失败 / 降级则保持当前快图。
398+
g2, _, err := buildViaSubprocess(ix.root, "precise")
375399
ix.mu.Lock()
400+
if err == nil && g2 != nil {
401+
ix.g = g2
402+
ix.gPrecise = true
403+
ix.goSig = sig
404+
}
376405
ix.preciseRunning = false
377406
ix.mu.Unlock()
378407
}()
@@ -399,7 +428,7 @@ func (ix *Index) assemble(preciseGoCalls []Edge, usePrecise bool) (_ *Graph, deg
399428
}
400429
name := d.Name()
401430
if d.IsDir() {
402-
if path != ix.root && (skipDirs[name] || strings.HasPrefix(name, ".")) {
431+
if path != ix.root && shouldSkipDir(name) {
403432
return filepath.SkipDir
404433
}
405434
return nil
@@ -418,6 +447,12 @@ func (ix *Index) assemble(preciseGoCalls []Edge, usePrecise bool) (_ *Graph, deg
418447
}
419448
files++
420449
bytes += int64(len(src))
450+
// 超长行 = 压缩 / 打包 / 生成 / 数据文件:tree-sitter 的 GLR 解析对这类输入会内存爆炸
451+
// (issue #115),且其超时杀不掉已在 native 解析的 goroutine,遗弃的 runaway 会持续吃内存。
452+
// 这类文件对代码图谱也几乎无价值 —— 解析前直接跳过,从源头不喂炸弹。
453+
if hasOverlongLine(src, maxLineBytes) {
454+
return nil
455+
}
421456
rel, relErr := filepath.Rel(ix.root, path)
422457
if relErr != nil {
423458
rel = path
@@ -460,6 +495,32 @@ func (ix *Index) assemble(preciseGoCalls []Edge, usePrecise bool) (_ *Graph, deg
460495
return g, degraded, nil
461496
}
462497

498+
// goCorpusStats 统计 workspace 内(跳过 skipDirs/隐藏目录)的 .go 文件数与总字节,只 stat 不读。
499+
// 用于精确 pass 的体量门控(issue #115)。skip 规则与 assemble/goSignature 一致,
500+
// 因此不数 vendor/依赖/.git,约等于「module 自身会被 ./... 精确解析的 Go 源码量」。
501+
func (ix *Index) goCorpusStats() (files int, bytes int64) {
502+
_ = filepath.WalkDir(ix.root, func(path string, d fs.DirEntry, err error) error {
503+
if err != nil {
504+
return nil
505+
}
506+
if d.IsDir() {
507+
name := d.Name()
508+
if path != ix.root && shouldSkipDir(name) {
509+
return filepath.SkipDir
510+
}
511+
return nil
512+
}
513+
if strings.HasSuffix(path, ".go") {
514+
if info, e := d.Info(); e == nil {
515+
files++
516+
bytes += info.Size()
517+
}
518+
}
519+
return nil
520+
})
521+
return files, bytes
522+
}
523+
463524
// goSignature 扫 workspace 里所有 .go 文件的大小+修改时间,拼成签名;Go 没变签名就不变。
464525
// 只 stat 不读内容,便宜。
465526
func (ix *Index) goSignature() string {
@@ -470,7 +531,7 @@ func (ix *Index) goSignature() string {
470531
}
471532
if d.IsDir() {
472533
name := d.Name()
473-
if path != ix.root && (skipDirs[name] || strings.HasPrefix(name, ".")) {
534+
if path != ix.root && shouldSkipDir(name) {
474535
return filepath.SkipDir
475536
}
476537
return nil

codegraph/leak_guard_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package codegraph
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// issue #115:goCorpusStats 只统计会被精确解析的模块 Go 源码,跳过 .git/vendor 等,
10+
// 作为精确 pass 体量门控的输入。
11+
func TestGoCorpusStats_SkipsHeavyDirs(t *testing.T) {
12+
root := t.TempDir()
13+
mk := func(rel, content string) {
14+
p := filepath.Join(root, rel)
15+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
16+
t.Fatal(err)
17+
}
18+
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
19+
t.Fatal(err)
20+
}
21+
}
22+
mk("a.go", "package x\n") // 计入
23+
mk("sub/b.go", "package y\n") // 计入
24+
mk(".git/c.go", "package z\n") // 跳过(.git)
25+
mk("vendor/d.go", "package v\n") // 跳过(vendor)
26+
mk("node_modules/e.go", "package n\n") // 跳过(node_modules)
27+
mk("readme.md", "hi") // 非 .go 不计
28+
29+
ix := &Index{root: root}
30+
files, bytes := ix.goCorpusStats()
31+
if files != 2 {
32+
t.Fatalf("应只数 2 个模块内 .go(跳过 .git/vendor/node_modules),got %d", files)
33+
}
34+
if bytes <= 0 {
35+
t.Fatalf("bytes 应 > 0, got %d", bytes)
36+
}
37+
}
38+
39+
func TestHasOverlongLine(t *testing.T) {
40+
short := []byte("package x\nfunc f() {}\n")
41+
if hasOverlongLine(short, 40<<10) {
42+
t.Error("正常源码不该判定为超长行")
43+
}
44+
// 一个 100KB 的单行(模拟压缩/生成文件)
45+
big := make([]byte, 100<<10)
46+
for i := range big {
47+
big[i] = 'a'
48+
}
49+
if !hasOverlongLine(big, 40<<10) {
50+
t.Error("100KB 单行应判定为超长行")
51+
}
52+
// 末行无换行也要检测
53+
if !hasOverlongLine(append([]byte("ok\n"), big...), 40<<10) {
54+
t.Error("末行超长(无结尾换行)应被检测")
55+
}
56+
}
57+
58+
func TestShouldSkipDir_CaseInsensitive(t *testing.T) {
59+
// issue #115:Windows/macOS 大小写不敏感,Vendor/VENDOR/Node_Modules 也必须跳过。
60+
for _, n := range []string{"vendor", "Vendor", "VENDOR", "node_modules", "Node_Modules", ".git", ".Git", "Pods", "pods"} {
61+
if !shouldSkipDir(n) {
62+
t.Errorf("%q 应被跳过", n)
63+
}
64+
}
65+
// 正常源码目录不跳过。
66+
for _, n := range []string{"agent", "src", "internal", "vendors", "buildscripts"} {
67+
if shouldSkipDir(n) {
68+
t.Errorf("%q 不该被跳过", n)
69+
}
70+
}
71+
}

codegraph/maintest_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package codegraph
2+
3+
// 测试里禁用「子进程建图」:re-exec 的是测试二进制(无 __codegraph-build 子命令),
4+
// 会递归/失败。测试要验证的是建图逻辑本身,走进程内即可;子进程的 gob 往返另有专测。
5+
func init() { useSubprocessBuild = false }

codegraph/model.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,12 @@ type ParseResult struct {
8989

9090
// Graph 是整个 workspace 的符号图谱。查询方法都只读,构建完后并发读安全。
9191
type Graph struct {
92-
Symbols []Symbol
92+
Symbols []Symbol
93+
// RawRefs / RawEdges 是原始引用 / 边列表(导出,供子进程建图后 gob 序列化回父进程;
94+
// 父进程重放 add* 重建下方索引)。索引 map 未导出、gob 不传,靠重放还原。
95+
RawRefs []Ref
96+
RawEdges []Edge
97+
9398
defByName map[string][]int // 符号名(非限定)→ Symbols 下标
9499
refsByName map[string][]Ref // 标识符名 → 引用点
95100
calleeIdx map[string][]Edge // 被调用短名 → 调用它的 call 边(查 callers)
@@ -122,10 +127,12 @@ func (g *Graph) addSymbol(s Symbol) {
122127
}
123128

124129
func (g *Graph) addRef(r Ref) {
130+
g.RawRefs = append(g.RawRefs, r)
125131
g.refsByName[r.Name] = append(g.refsByName[r.Name], r)
126132
}
127133

128134
func (g *Graph) addEdge(e Edge) {
135+
g.RawEdges = append(g.RawEdges, e) // 原始边留底,供序列化重放
129136
switch e.Kind {
130137
case EdgeCall:
131138
if e.ToQual == "" {

0 commit comments

Comments
 (0)