Skip to content

Commit 41d9acd

Browse files
committed
feat(calltree): show where a slow callers search spent its time
- The panel's engine label was a prediction; it now shows what the response actually used and how long it took, with the breakdown on hover: index call, file resolution with file count, rg scan, raw hit count, and the server's build stamp - A slow corporate machine cannot be reproduced remotely, so one click there becomes the measurement: a large index number points at the process-spawn path (EDR scans each attempt; the transport probing is known to cost seconds), a large resolve number at storage, a scan number at the rg fallback, and the build stamp settles whether the fix being tested is even running - The binary now knows its own commit via the VCS info go build embeds, shown at startup and in the response header; two rounds of "still slow" reports have already turned out to be a stale binary
1 parent 1080677 commit 41d9acd

6 files changed

Lines changed: 139 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ static 変数・関数呼び出しはテキストパターンに基づくヒュ
105105
- `callers` / `callees` タブで方向を切り替え(タブを戻っても展開状態を保持)
106106
- ノードをクリックして実装箇所へジャンプ(プロトタイプ宣言ではなく定義本体を優先)。`` で再帰的に展開
107107
- Esc で検索欄をクリア(もう一度 Esc でパネルを閉じる)
108-
- GNU Global が利用可能な場合、Callers は `global -xr` による構文解析ベースの参照検索を使用(ripgrep より誤検知が少ない)。使用中エンジンをパネル内に表示
108+
- GNU Global が利用可能な場合、Callers は `global -xr` による構文解析ベースの参照検索を使用(ripgrep より誤検知が少ない)。検索のたびに、実際に使ったエンジンと所要時間をパネルに表示し、ホバーで内訳(索引の呼び出し / ファイル解決 / rg 走査・読んだファイル数・サーバのビルド版)が見える——遅い環境で「どこが遅いか」をその場で切り分けるための計測
109109
- 関数ポインタのテーブルで登録される関数(メソッドテーブル・ops 構造体)は、登録行が「ファイル名:行」のノードで出ます。囲む関数が無い参照は以前は捨てて ripgrep の全木走査へ降格していましたが、登録箇所こそが「誰が呼ぶのか」への答えなので、索引の結果をそのまま返します(実測: openssl の `ssl3_read_bytes` で 1.85 秒 → 0.05 秒)
110110

111111
#### ジャンプマップ

api/handlers_analysis.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,7 @@ func (h *Handler) handleCallers(w http.ResponseWriter, r *http.Request) {
731731
} else if !filepath.IsAbs(dir) {
732732
dir = filepath.Join(hroot, dir)
733733
}
734+
timing := &search.RefTiming{}
734735
hits, engine, truncated, err := search.FindRefSites(r.Context(), search.RefQuery{
735736
Word: word,
736737
Root: hroot,
@@ -740,7 +741,8 @@ func (h *Handler) handleCallers(w http.ResponseWriter, r *http.Request) {
740741
NoIndex: q.Get("gtags") == "0",
741742
// 呼び出し元は「これで全部か」を見る一覧なので上限は高く取り、
742743
// 触れたときは X-Truncated で伝える
743-
Limit: 1000,
744+
Limit: 1000,
745+
Timing: timing,
744746
})
745747
if err != nil {
746748
jsonErr(w, err.Error(), http.StatusInternalServerError)
@@ -752,6 +754,12 @@ func (h *Handler) handleCallers(w http.ResponseWriter, r *http.Request) {
752754
// 呼び出し元が上限で切られたかを伝える。黙って切ると「これで全部」と誤解される。
753755
// gtags は全件返すので、打ち切りが起きるのは rg 経路だけ。
754756
w.Header().Set("X-Engine", engine)
757+
// どこで時間を使ったかを応答に載せる。遅い環境(EDR 入りの社用機・
758+
// ネットワークドライブ)は手元で再現できないので、現地の1クリックが
759+
// そのまま計測になる形にしておく。
760+
w.Header().Set("X-Timing", fmt.Sprintf("index=%dms resolve=%dms scan=%dms raw=%d files=%d",
761+
timing.IndexMS, timing.ResolveMS, timing.ScanMS, timing.RawHits, timing.FilesRead))
762+
w.Header().Set("X-Build", headerSafe(BuildStamp()))
755763
if truncated {
756764
w.Header().Set("X-Truncated", "true")
757765
}

api/version.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package api
2+
3+
import (
4+
"runtime/debug"
5+
"sync"
6+
)
7+
8+
var (
9+
buildOnce sync.Once
10+
buildStamp string
11+
)
12+
13+
// BuildStamp はこのバイナリのコミットとビルド時刻。「動いているのは直した版か」
14+
// が現地で分からないと、修正済みの症状を古いバイナリで再報告する往復が起きる
15+
// (実際に2回起きた)。go build が埋める VCS 情報なので、ソース側の更新は不要。
16+
func BuildStamp() string {
17+
buildOnce.Do(func() {
18+
buildStamp = "unknown"
19+
bi, ok := debug.ReadBuildInfo()
20+
if !ok {
21+
return
22+
}
23+
rev, at, dirty := "", "", ""
24+
for _, s := range bi.Settings {
25+
switch s.Key {
26+
case "vcs.revision":
27+
rev = s.Value
28+
if len(rev) > 9 {
29+
rev = rev[:9]
30+
}
31+
case "vcs.time":
32+
at = s.Value
33+
case "vcs.modified":
34+
if s.Value == "true" {
35+
dirty = "+dirty"
36+
}
37+
}
38+
}
39+
if rev != "" {
40+
buildStamp = rev + dirty + " " + at
41+
}
42+
})
43+
return buildStamp
44+
}

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strings"
1515
"time"
1616

17+
"grepnavi/api"
1718
"grepnavi/desktop"
1819
"grepnavi/proc"
1920
)
@@ -125,7 +126,7 @@ func main() {
125126

126127
srv := newServer(absRoot, rootExplicit, *graphFile, graphExplicit, addr, *debug, *mcp, *tray)
127128

128-
slog.Info("grepnavi started", "root", absRoot, "graph", *graphFile)
129+
slog.Info("grepnavi started", "root", absRoot, "graph", *graphFile, "build", api.BuildStamp())
129130
if *mcp {
130131
slog.Warn("--mcp enabled: non-browser (Origin-less) API access is allowed")
131132
}

search/refsites.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"regexp"
77
"strconv"
88
"strings"
9+
"time"
910
)
1011

1112
// RefQuery は「word はどこで使われているか」の問い合わせ。
@@ -32,6 +33,19 @@ type RefQuery struct {
3233
AssignOnly bool
3334
// NoIndex は索引を使わず ripgrep だけで引く(利用者が明示的に指定したとき)。
3435
NoIndex bool
36+
// Timing を渡すと、どの段階に時間を使ったかを書き込む。遅い環境
37+
// (EDR 入りの社用機・ネットワークドライブ等)は手元で再現できないので、
38+
// 現地の1クリックで原因を切り分けられる数字を応答に載せるためにある。
39+
Timing *RefTiming
40+
}
41+
42+
// RefTiming は参照検索1回の内訳。
43+
type RefTiming struct {
44+
IndexMS int64 // global の呼び出し(初回はプロセス起動経路の検出込み)
45+
ResolveMS int64 // ファイルを読んで囲む関数を解決した時間
46+
ScanMS int64 // ripgrep 降格時の全木走査
47+
RawHits int // 索引が返した生ヒット数
48+
FilesRead int // 解決で開いたファイル数
3549
}
3650

3751
// FindRefSites は索引で引き、答えられなければ ripgrep に落ちる。
@@ -44,7 +58,12 @@ func FindRefSites(ctx context.Context, q RefQuery) ([]CallSite, string, bool, er
4458
q.Scope = q.Root
4559
}
4660
if !q.NoIndex && GtagsAvailable(q.Root) {
61+
t0 := time.Now()
4762
hits, err := gtagsRawRefs(ctx, q.Word, q.Root)
63+
if q.Timing != nil {
64+
q.Timing.IndexMS = time.Since(t0).Milliseconds()
65+
q.Timing.RawHits = len(hits)
66+
}
4867
if err == nil && len(hits) > 0 {
4968
// 絞り込みと上限は解決の前に掛ける。解決はヒットのあるファイルを
5069
// 読む処理なので、後ろに置くと索引が返した全件ぶん働いてしまう。
@@ -67,17 +86,25 @@ func FindRefSites(ctx context.Context, q RefQuery) ([]CallSite, string, bool, er
6786
hits, cut = hits[:budget], true
6887
}
6988
var sites []CallSite
89+
t1 := time.Now()
7090
if q.CallersOnly {
7191
// 解決(ファイル読み + 関数範囲の走査)が一覧の主コスト。
7292
// 全ヒットを解決してから上限で切ると、上限の 20 倍を先に
7393
// 払うことになる(実測: linux の kfree で 3.9s、畳みながら
7494
// 止めれば参照一覧と同等まで落ちる)。
7595
var full bool
76-
sites, full = resolveCallerSites(hits, q.Word, q.Limit)
96+
var files int
97+
sites, full, files = resolveCallerSites(hits, q.Word, q.Limit)
7798
cut = cut || full
99+
if q.Timing != nil {
100+
q.Timing.FilesRead = files
101+
}
78102
} else {
79103
sites = ResolveRefSites(hits, q.Word)
80104
}
105+
if q.Timing != nil {
106+
q.Timing.ResolveMS = time.Since(t1).Milliseconds()
107+
}
81108
MarkIndirectCalls(sites, q.Word)
82109
MarkAssignments(sites, q.Word)
83110

@@ -95,12 +122,19 @@ func FindRefSites(ctx context.Context, q RefQuery) ([]CallSite, string, bool, er
95122
// 呼び出し元の形に整えると 0 件」は降格しない — 索引の参照は全量なので、
96123
// rg の再走査が足せるのは索引対象外ファイルの分だけで、実測ではそこに
97124
// 生成 HTML の誤パースが混ざった。0 件は「呼び出し元なし」という答え。
125+
t2 := time.Now()
98126
if q.CallersOnly {
99127
sites, truncated, err := FindCallers(ctx, q.Word, q.Scope, q.Glob)
128+
if q.Timing != nil {
129+
q.Timing.ScanMS = time.Since(t2).Milliseconds()
130+
}
100131
return sites, "rg", truncated, err
101132
}
102133
sites, truncated, err := rgRefSites(ctx, q.Word, q.Scope, q.Glob, q.Limit,
103134
parseRefFilter(q.Filter), q.AssignOnly)
135+
if q.Timing != nil {
136+
q.Timing.ScanMS = time.Since(t2).Milliseconds()
137+
}
104138
return sites, "rg", truncated, err
105139
}
106140

@@ -178,15 +212,17 @@ func callerKey(s *CallSite, word string, reDecl *regexp.Regexp) (string, bool) {
178212
// そろった時点で止める。第2戻り値は「生ヒットを使い切る前に止めた」。
179213
// 解決はファイル読み + コメント除去 + 関数範囲の走査で、一覧の主コストなので、
180214
// 使わない解決はやらない。
181-
func resolveCallerSites(hits []DefHit, word string, limit int) ([]CallSite, bool) {
215+
// 第3戻り値は開いたファイル数(遅い環境の切り分け用: 解決が遅いのか、
216+
// ファイルが多いのか、1ファイルが重いのかを分ける材料になる)。
217+
func resolveCallerSites(hits []DefHit, word string, limit int) ([]CallSite, bool, int) {
182218
reDecl := callerDeclPattern(word)
183219
code := codeOnlyCache{}
184220
seen := map[string]bool{}
185221
var out []CallSite
186222
for i, h := range hits {
187223
if len(out) >= limit {
188224
_ = hits[i] // まだ残っている = 打ち切り
189-
return out, true
225+
return out, true, len(code)
190226
}
191227
lines, err := CachedLines(h.File)
192228
if err != nil {
@@ -206,7 +242,7 @@ func resolveCallerSites(hits []DefHit, word string, limit int) ([]CallSite, bool
206242
seen[key] = true
207243
out = append(out, s)
208244
}
209-
return out, false
245+
return out, false, len(code)
210246
}
211247

212248
// refTerm は絞り込み条件1つぶん。

static/addons/call-tree/addon.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,45 @@ document.addEventListener('DOMContentLoaded', () => {
149149
});
150150
});
151151

152+
// 応答の実測をラベルへ。予測(gtagsAvailable)だけだと、rg へ降格した・
153+
// 索引呼び出しに何秒かかった、が現地で見えず「遅い」以上の報告ができない。
154+
// EDR 入りの社用機やネットワークドライブは手元で再現できないので、
155+
// 1クリックがそのまま計測になるようにしておく。
156+
// 生の内訳(index=..ms resolve=..ms)を読ませるのではなく、比較と判定は
157+
// こちらでやって日本語の一文にする。数字はその下に残す(報告用)。
158+
function ctTimingVerdict(eng, totalMs, t) {
159+
const ms = (k) => parseInt((t.match(new RegExp(k + '=([0-9]+)')) || [])[1] || '0', 10);
160+
const index = ms('index'), resolve = ms('resolve'), scan = ms('scan'), files = ms('files');
161+
if (totalMs < 1500) return 'この検索は遅くありません';
162+
if (eng !== 'gtags') {
163+
return 'この語は索引で答えられず、rg でツリー全体を走査しました (' + scan + 'ms)。' +
164+
'毎回こうなるなら、索引が無い・古い・プロジェクトルート直下に無い、のどれかです';
165+
}
166+
if (index > 1000 && index >= resolve * 2) {
167+
return '時間の大半は global の呼び出しです (' + index + 'ms)。プロセス起動が遅い環境' +
168+
'(ウイルス対策/EDR がプロセスを検査している)の典型です。2回目も遅いままか見てください';
169+
}
170+
if (resolve > 1000) {
171+
return '時間の大半はファイル読みです (' + resolve + 'ms / ' + files + ' ファイル)。' +
172+
'遅いディスクかネットワークドライブ上のプロジェクトが典型です';
173+
}
174+
return 'サーバ側はどの段階も速いのに合計が遅い場合、通信か描画側です';
175+
}
176+
177+
function ctShowTiming(res, totalMs) {
178+
const label = document.getElementById('ct-engine-label');
179+
if (!label || !res) return;
180+
const eng = res.headers.get('X-Engine');
181+
if (!eng) return;
182+
const name = eng === 'gtags' ? 'GNU Global' : 'ripgrep';
183+
const sec = (totalMs / 1000).toFixed(totalMs >= 9500 ? 0 : 1);
184+
label.textContent = `${name} · ${sec}s`;
185+
const t = res.headers.get('X-Timing') || '';
186+
label.title = ctTimingVerdict(eng, totalMs, t) +
187+
String.fromCharCode(10) + String.fromCharCode(10) + t +
188+
String.fromCharCode(10) + 'build: ' + (res.headers.get('X-Build') || '?');
189+
}
190+
152191
function updateCtEngineLabel(mode) {
153192
const label = document.getElementById('ct-engine-label');
154193
if (!label) return;
@@ -234,7 +273,9 @@ async function ctSearch() {
234273
// 見る一覧なので、別のパネルの設定で黙って件数が減るほうが危ない
235274
const params = new URLSearchParams({ word });
236275
if (!useGtags) params.set('gtags', '0');
276+
const t0 = performance.now();
237277
const res = await fetch('/api/callers?' + params, { signal });
278+
ctShowTiming(res, performance.now() - t0);
238279
if (!res.ok) { body.innerHTML = '<div class="ct-empty">エラー</div>'; return; }
239280
const hits = await res.json();
240281

@@ -512,7 +553,9 @@ async function ctToggle(node, el) {
512553
if (_ctMode === 'callers') {
513554
const params = new URLSearchParams({ word: node.func });
514555
if (typeof gtagsAvailable === 'function' && !gtagsAvailable()) params.set('gtags', '0');
556+
const t0 = performance.now();
515557
const res = await fetch('/api/callers?' + params).catch(() => null);
558+
ctShowTiming(res, performance.now() - t0);
516559
if (res && res.ok) {
517560
const hits = await res.json();
518561
node.truncated = res.headers.get('X-Truncated') === 'true';

0 commit comments

Comments
 (0)