Skip to content

Commit 87c26b2

Browse files
tae2089claude
andcommitted
Replace test-only package globals with per-instance injection
Three package-level mutable var seams existed only so tests could swap behavior, and reassigning a package global in a long-running MCP server is not goroutine-safe: - testBuildBatchReleaseHook and resolveBuildEdges (service) become GraphService fields (onBatchRelease, resolveEdges); edgeResolver() defaults to edgeresolve.ResolveWithOptions when unset. - refreshSearchDocuments (mcp) becomes a Deps field; the handler method defaults to service.RefreshSearchDocuments when unset. Tests now set the field on the instance instead of mutating a global. The production var literally named testBuildBatchReleaseHook is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 471ec4c commit 87c26b2

6 files changed

Lines changed: 44 additions & 34 deletions

File tree

internal/mcp/deps.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,8 @@ type Deps struct {
183183

184184
MaxFileBytes int64
185185
MaxTotalParsedBytes int64
186+
187+
// RefreshSearchDocuments overrides the search-document refresh used after a build; nil uses
188+
// service.RefreshSearchDocuments. Kept as an injectable field so tests need no package globals.
189+
RefreshSearchDocuments func(ctx context.Context, db *gorm.DB) (int, error)
186190
}

internal/mcp/handler_parse.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import (
1818
"github.com/tae2089/code-context-graph/internal/service"
1919
)
2020

21-
var refreshSearchDocuments = service.RefreshSearchDocuments
21+
// @intent refresh search documents through the injected override, defaulting to the service impl.
22+
func (h *handlers) refreshSearchDocuments(ctx context.Context) (int, error) {
23+
if h.deps.RefreshSearchDocuments != nil {
24+
return h.deps.RefreshSearchDocuments(ctx, h.deps.DB)
25+
}
26+
return service.RefreshSearchDocuments(ctx, h.deps.DB)
27+
}
2228

2329
// @intent serialize build_or_update_graph results with a fixed JSON schema without changing the wire format.
2430
type buildOrUpdateGraphResponse struct {
@@ -250,7 +256,7 @@ func (h *handlers) buildOrUpdateGraph(ctx context.Context, request mcp.CallToolR
250256
}
251257
// search rebuild
252258
if h.deps.SearchBackend != nil && h.deps.DB != nil {
253-
if _, err := refreshSearchDocuments(ctx, h.deps.DB); err != nil {
259+
if _, err := h.refreshSearchDocuments(ctx); err != nil {
254260
if failClosed {
255261
failClosedErr = err
256262
failedSteps = append(failedSteps, "search_documents")
@@ -274,7 +280,7 @@ func (h *handlers) buildOrUpdateGraph(ctx context.Context, request mcp.CallToolR
274280
skippedSteps = appendUniqueStrings(skippedSteps, "communities", "flows")
275281
// search only rebuild
276282
if h.deps.SearchBackend != nil && h.deps.DB != nil {
277-
if _, err := refreshSearchDocuments(ctx, h.deps.DB); err != nil {
283+
if _, err := h.refreshSearchDocuments(ctx); err != nil {
278284
if failClosed {
279285
failClosedErr = err
280286
failedSteps = append(failedSteps, "search_documents")
@@ -433,7 +439,7 @@ func (h *handlers) runPostprocess(ctx context.Context, request mcp.CallToolReque
433439

434440
if doFTS {
435441
if h.deps.SearchBackend != nil && h.deps.DB != nil {
436-
if _, err := refreshSearchDocuments(ctx, h.deps.DB); err != nil {
442+
if _, err := h.refreshSearchDocuments(ctx); err != nil {
437443
if failClosed {
438444
failClosedErr = err
439445
failedSteps = append(failedSteps, "search_documents")

internal/mcp/handlers_test.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,9 +1404,7 @@ func TestBuildOrUpdateGraph_DegradedOnSearchDocumentRefreshFailure(t *testing.T)
14041404
deps := setupTestDeps(t)
14051405
backend := &failSearchBackend{}
14061406
deps.SearchBackend = backend
1407-
origRefresh := refreshSearchDocuments
1408-
defer func() { refreshSearchDocuments = origRefresh }()
1409-
refreshSearchDocuments = func(ctx context.Context, db *gorm.DB) (int, error) {
1407+
deps.RefreshSearchDocuments = func(ctx context.Context, db *gorm.DB) (int, error) {
14101408
return 0, errors.New("search document refresh boom")
14111409
}
14121410

@@ -1451,9 +1449,7 @@ func TestBuildOrUpdateGraph_MinimalSkipsFTSRebuildOnSearchDocumentRefreshFailure
14511449
deps := setupTestDeps(t)
14521450
backend := &failSearchBackend{}
14531451
deps.SearchBackend = backend
1454-
origRefresh := refreshSearchDocuments
1455-
defer func() { refreshSearchDocuments = origRefresh }()
1456-
refreshSearchDocuments = func(ctx context.Context, db *gorm.DB) (int, error) {
1452+
deps.RefreshSearchDocuments = func(ctx context.Context, db *gorm.DB) (int, error) {
14571453
return 0, errors.New("search document refresh boom")
14581454
}
14591455

internal/service/build.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ func (s *GraphService) bindAndReleaseNodeBatch(ctx context.Context, txStore stor
100100

101101
parsed.tsComments = nil
102102
parsed.sourceLines = nil
103-
if testBuildBatchReleaseHook != nil {
104-
testBuildBatchReleaseHook(batches, idx)
103+
if s.onBatchRelease != nil {
104+
s.onBatchRelease(batches, idx)
105105
}
106106
return nil
107107
}
@@ -450,7 +450,7 @@ func (s *GraphService) flushBuildEdges(ctx context.Context, txStore store.GraphS
450450
return err
451451
}
452452
end := min(start+buildEdgeResolveChunkSize, len(implementsEdges))
453-
resolved, err := resolveBuildEdges(ctx, txStore, implementsEdges[start:end], resolveOptions)
453+
resolved, err := s.edgeResolver()(ctx, txStore, implementsEdges[start:end], resolveOptions)
454454
if err != nil {
455455
s.logger().ErrorContext(ctx, "resolve deferred implements edges failed", append(obs.TraceLogArgs(ctx), "start", start, "end", end, "error", err)...)
456456
return trace.Wrap(err, "resolve deferred implements edges")
@@ -474,7 +474,7 @@ func (s *GraphService) flushBuildEdges(ctx context.Context, txStore store.GraphS
474474
end := min(start+buildEdgeResolveChunkSize, len(parsed.edges))
475475
chunk := parsed.edges[start:end]
476476
resolveInput := chunkWithImportWarmup(chunk, importsByPath[parsed.relPath])
477-
resolved, err := resolveBuildEdges(ctx, txStore, resolveInput, resolveOptions)
477+
resolved, err := s.edgeResolver()(ctx, txStore, resolveInput, resolveOptions)
478478
if err != nil {
479479
s.logger().ErrorContext(ctx, "resolve deferred edges failed", append(obs.TraceLogArgs(ctx), "file", parsed.relPath, "error", err)...)
480480
return trace.Wrap(err, "resolve deferred edges for "+parsed.relPath)

internal/service/indexer.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ import (
1818

1919
type languagePackageInfo = treesitter.PackageInfo
2020

21-
var (
22-
testBuildBatchReleaseHook func([]parsedBuildNodeBatch, int)
23-
resolveBuildEdges resolveBuildEdgesFn = edgeresolve.ResolveWithOptions
24-
)
25-
26-
// @intent abstract build-time edge resolution so tests and build paths can swap resolver behavior without rewiring callers.
21+
// @intent abstract build-time edge resolution so tests can inject resolver behavior per GraphService.
2722
type resolveBuildEdgesFn func(ctx context.Context, lookup edgeresolve.NodeLookup, edges []model.Edge, options edgeresolve.ResolveOptions) ([]model.Edge, error)
2823

2924
const (
@@ -89,6 +84,20 @@ type GraphService struct {
8984
Walkers map[string]*treesitter.Walker
9085
Parsers map[string]Parser
9186
Logger *slog.Logger
87+
88+
// resolveEdges overrides build-time edge resolution; nil uses edgeresolve.ResolveWithOptions.
89+
// onBatchRelease, when set, is notified after each node batch is persisted and its buffers
90+
// are released. Both are test seams kept per-instance so the server has no mutable globals.
91+
resolveEdges resolveBuildEdgesFn
92+
onBatchRelease func([]parsedBuildNodeBatch, int)
93+
}
94+
95+
// @intent resolve build edges through the injected resolver, defaulting to the production resolver.
96+
func (s *GraphService) edgeResolver() resolveBuildEdgesFn {
97+
if s.resolveEdges != nil {
98+
return s.resolveEdges
99+
}
100+
return edgeresolve.ResolveWithOptions
92101
}
93102

94103
// BuildOptions configures one graph build run.

internal/service/indexer_test.go

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -498,22 +498,20 @@ func TestFlushBuildEdges_ResolvesAndUpsertsBoundedBatches(t *testing.T) {
498498
}
499499

500500
var resolveSizes []int
501-
oldResolve := resolveBuildEdges
502-
resolveBuildEdges = func(ctx context.Context, lookup edgeresolve.NodeLookup, edges []model.Edge, options edgeresolve.ResolveOptions) ([]model.Edge, error) {
501+
resolver := func(ctx context.Context, lookup edgeresolve.NodeLookup, edges []model.Edge, options edgeresolve.ResolveOptions) ([]model.Edge, error) {
503502
resolveSizes = append(resolveSizes, len(edges))
504503
if len(edges) > buildEdgeResolveChunkSize {
505504
t.Fatalf("resolve batch exceeded limit: got %d want <= %d", len(edges), buildEdgeResolveChunkSize)
506505
}
507-
return oldResolve(ctx, lookup, edges, options)
506+
return edgeresolve.ResolveWithOptions(ctx, lookup, edges, options)
508507
}
509-
t.Cleanup(func() { resolveBuildEdges = oldResolve })
510508

511509
batches := []parsedBuildEdgeBatch{
512510
{relPath: "cmd/main.go", edges: []model.Edge{{Kind: model.EdgeKindImportsFrom, FilePath: "cmd/main.go", Line: 1, Fingerprint: "imports_from:cmd/main.go:github.com/example/project/mcp:1"}, {Kind: model.EdgeKindCalls, FilePath: "cmd/main.go", Line: 2, Fingerprint: "calls:cmd/main.go:h.deps.FlowTracer.TraceFlow:2"}}},
513511
{relPath: "flows/tracer.go", edges: []model.Edge{{Kind: model.EdgeKindImplements, FilePath: "flows/tracer.go", Line: 7, Fingerprint: "implements:flows/tracer.go:flows.Tracer:mcp.FlowTracer"}}},
514512
}
515513

516-
svc := &GraphService{}
514+
svc := &GraphService{resolveEdges: resolver}
517515
if err := svc.flushBuildEdges(ctx, st, batches, nil, edgeresolve.ResolveOptions{}); err != nil {
518516
t.Fatalf("flushBuildEdges: %v", err)
519517
}
@@ -543,26 +541,24 @@ func TestFlushBuildEdges_ResolvesImplementsOnlyOnce(t *testing.T) {
543541
st.nodesByFP["flows/tracer.go"] = []model.Node{{ID: 30, QualifiedName: "flows/tracer.go", Name: "flows/tracer.go", Kind: model.NodeKindFile, FilePath: "flows/tracer.go", Language: "go"}, {ID: 3, QualifiedName: "flows.Tracer", Name: "Tracer", Kind: model.NodeKindClass, FilePath: "flows/tracer.go", StartLine: 7, EndLine: 7, Language: "go"}, {ID: 4, QualifiedName: "flows.Tracer.TraceFlow", Name: "TraceFlow", Kind: model.NodeKindFunction, FilePath: "flows/tracer.go", StartLine: 9, EndLine: 11, Language: "go"}}
544542

545543
var implementsSeen []int
546-
oldResolve := resolveBuildEdges
547-
resolveBuildEdges = func(ctx context.Context, lookup edgeresolve.NodeLookup, edges []model.Edge, options edgeresolve.ResolveOptions) ([]model.Edge, error) {
544+
resolver := func(ctx context.Context, lookup edgeresolve.NodeLookup, edges []model.Edge, options edgeresolve.ResolveOptions) ([]model.Edge, error) {
548545
count := 0
549546
for _, edge := range edges {
550547
if edge.Kind == model.EdgeKindImplements {
551548
count++
552549
}
553550
}
554551
implementsSeen = append(implementsSeen, count)
555-
return oldResolve(ctx, lookup, edges, options)
552+
return edgeresolve.ResolveWithOptions(ctx, lookup, edges, options)
556553
}
557-
t.Cleanup(func() { resolveBuildEdges = oldResolve })
558554

559555
batches := []parsedBuildEdgeBatch{
560556
{relPath: "flows/tracer.go", edges: []model.Edge{{Kind: model.EdgeKindImplements, FilePath: "flows/tracer.go", Line: 7, Fingerprint: "implements:flows/tracer.go:flows.Tracer:mcp.FlowTracer"}}},
561557
{relPath: "cmd/main.go", edges: []model.Edge{{Kind: model.EdgeKindImportsFrom, FilePath: "cmd/main.go", Line: 1, Fingerprint: "imports_from:cmd/main.go:github.com/example/project/mcp:1"}, {Kind: model.EdgeKindCalls, FilePath: "cmd/main.go", Line: 2, Fingerprint: "calls:cmd/main.go:h.deps.FlowTracer.TraceFlow:2"}}},
562558
{relPath: "cmd/main.go", edges: []model.Edge{{Kind: model.EdgeKindContains, FilePath: "cmd/main.go", Line: 1, Fingerprint: "contains:cmd/main.go:main.Run"}}},
563559
}
564560

565-
svc := &GraphService{}
561+
svc := &GraphService{resolveEdges: resolver}
566562
if err := svc.flushBuildEdges(ctx, st, batches, nil, edgeresolve.ResolveOptions{}); err != nil {
567563
t.Fatalf("flushBuildEdges: %v", err)
568564
}
@@ -2100,8 +2096,7 @@ func TestBuild_ReleasesBatchCommentStateAfterBinding(t *testing.T) {
21002096
tsCommentsNil bool
21012097
sourceNil bool
21022098
}
2103-
prevHook := testBuildBatchReleaseHook
2104-
testBuildBatchReleaseHook = func(batches []parsedBuildNodeBatch, idx int) {
2099+
recordRelease := func(batches []parsedBuildNodeBatch, idx int) {
21052100
snapshots = append(snapshots, struct {
21062101
batch int
21072102
tsCommentsNil bool
@@ -2112,15 +2107,15 @@ func TestBuild_ReleasesBatchCommentStateAfterBinding(t *testing.T) {
21122107
sourceNil: batches[idx].sourceLines == nil,
21132108
})
21142109
}
2115-
defer func() { testBuildBatchReleaseHook = prevHook }()
21162110

21172111
fakeStore := newRecordingGraphStore(t)
21182112
svc := &GraphService{
21192113
Store: fakeStore,
21202114
Walkers: map[string]*treesitter.Walker{
21212115
".go": treesitter.NewWalker(treesitter.GoSpec),
21222116
},
2123-
Logger: slog.Default(),
2117+
Logger: slog.Default(),
2118+
onBatchRelease: recordRelease,
21242119
}
21252120

21262121
dir := t.TempDir()

0 commit comments

Comments
 (0)