Files
zzet--gortex/internal/mcp/tools_analyze_concurrency.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

476 lines
15 KiB
Go

// N11 concurrency analyzers — language-agnostic detectors that ride
// the substrate edges existing extractors already emit
// (`EdgeSpawns`, `EdgeSends`, `EdgeRecvs`, `EdgeWrites`, `EdgeCalls`)
// so no new parser work is required for the languages the spawn /
// channel / write edges already cover (Go full; TS / Python / Kotlin
// / Rust / C# for spawns + writes).
//
// Two analyzers ship in this file:
//
// - `race_writes`: KindField writes from inside a goroutine-
// reachable function whose writer holds no detectable
// synchronisation primitive (Lock / RLock / Mutex.Do / RWMutex
// siblings). Conservative: a writer that calls any of the known
// lock methods is treated as guarded for the whole function.
// Missed-lock false positives (lock in caller, not in writer)
// are tolerated — the report is reviewed, not enforced.
//
// - `unclosed_channels`: channels that have at least one
// `EdgeSends` but no `close()` call anywhere in the codebase.
// Detection rides the existing `EdgeCalls` edges whose target
// resolves (or fails to resolve) to a function named `close`.
// A channel with a single sender + no close that nobody ranges
// over is fine and not flagged; the analyzer reports
// `risk` levels so callers see which closures actually matter.
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// ---------------------------------------------------------------------------
// race_writes — field writes from goroutine-reachable functions without
// synchronisation. Walks the spawn-closure once, then field writes once.
// ---------------------------------------------------------------------------
// handleAnalyzeRaceWrites lists potentially-racy field writes —
// every `EdgeWrites` to a KindField whose source function is
// reachable (via `EdgeCalls`) from at least one goroutine-spawn
// target AND whose writer has no detectable lock acquisition in
// its outgoing call set.
//
// Args:
//
// - `path_prefix`: scope to writes originating in a directory
// subtree.
// - `limit`: max rows (default 50).
func (s *Server) handleAnalyzeRaceWrites(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
limit := 50
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
goroutineReachable := s.buildGoroutineReachableSet()
guarded := map[string]bool{}
type raceRow struct {
Field string `json:"field"`
Writer string `json:"writer"`
FilePath string `json:"file_path"`
Line int `json:"line"`
Reason string `json:"reason"`
SpawnPath []string `json:"spawn_path,omitempty"`
}
var rows []raceRow
for e := range edgesByKinds(s.graph, graph.EdgeWrites) {
if !goroutineReachable[e.From] {
continue
}
target := s.graph.GetNode(e.To)
if target == nil || target.Kind != graph.KindField {
continue
}
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
continue
}
// Lock-guard check is cached per writer because it walks the
// writer's out-edges once and the writer is reused across
// every field it touches.
if _, cached := guarded[e.From]; !cached {
guarded[e.From] = s.writerHoldsLock(e.From)
}
if guarded[e.From] {
continue
}
rows = append(rows, raceRow{
Field: e.To,
Writer: e.From,
FilePath: e.FilePath,
Line: e.Line,
Reason: "field write inside goroutine-reachable function with no detected lock guard",
})
}
// Scope filter: when the request narrows below the global graph
// (workspace-bound session or repo allow-set), keep only races
// whose field AND writer node are both visible. total/truncated
// below recompute over the kept rows. No-op for an unbound request.
if s.scopeFiltersActive(ctx) {
kept := make([]raceRow, 0, len(rows))
for _, r := range rows {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) &&
s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Writer)) {
kept = append(kept, r)
}
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Field != rows[j].Field {
return rows[i].Field < rows[j].Field
}
if rows[i].Writer != rows[j].Writer {
return rows[i].Writer < rows[j].Writer
}
return rows[i].Line < rows[j].Line
})
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
items := make([]raceWriteItem, 0, len(rows))
for _, r := range rows {
items = append(items, raceWriteItem{
Field: r.Field,
Writer: r.Writer,
FilePath: r.FilePath,
Line: r.Line,
Reason: r.Reason,
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("race_writes", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%s -> %s %s:%d\n", r.Writer, r.Field, r.FilePath, r.Line)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no unguarded field writes from goroutine-reachable code\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"race_writes": rows,
"total": len(rows),
"truncated": truncated,
})
}
// buildGoroutineReachableSet returns the closure of every function /
// method reachable via `EdgeCalls` (transitively) from any node that
// is the target of an `EdgeSpawns` edge. Membership in this set is
// the precondition for a write to be considered "happens on another
// goroutine"; an unguarded write to a shared field by such a writer
// is a data race candidate.
func (s *Server) buildGoroutineReachableSet() map[string]bool {
reach := map[string]bool{}
var roots []string
for e := range edgesByKinds(s.graph, graph.EdgeSpawns) {
if !reach[e.To] {
reach[e.To] = true
roots = append(roots, e.To)
}
}
// BFS over out-edges of every spawned root; only EdgeCalls and
// EdgeSpawns (a goroutine spawning another goroutine extends the
// closure too) advance the frontier.
frontier := roots
for len(frontier) > 0 {
var next []string
for _, id := range frontier {
for _, e := range s.graph.GetOutEdges(id) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeSpawns {
continue
}
if reach[e.To] {
continue
}
reach[e.To] = true
next = append(next, e.To)
}
}
frontier = next
}
return reach
}
// writerHoldsLock returns true when writer's outgoing call set
// touches any of the known synchronisation primitives. Heuristic
// list — covers stdlib sync.Mutex / RWMutex, the common pattern of
// `withLock(func() { ... })`, JS `Mutex.acquire`, and Rust's
// `lock()`. False positives are preferable to false negatives here
// because the analyzer is review-grade, not enforcement-grade.
func (s *Server) writerHoldsLock(writerID string) bool {
for _, e := range s.graph.GetOutEdges(writerID) {
if e.Kind != graph.EdgeCalls {
continue
}
if name := callTargetName(e); isLockMethodName(name) {
return true
}
target := s.graph.GetNode(e.To)
if target != nil && isLockMethodName(target.Name) {
return true
}
}
return false
}
// callTargetName extracts the method/function name from an edge's
// `To` — works for both resolved targets (`pkg/m.go::Mutex.Lock`)
// and pre-resolution `unresolved::*.Lock` / `unresolved::Lock`
// shapes so the lock-guard check is robust against partial graphs.
func callTargetName(e *graph.Edge) string {
target := e.To
if i := strings.LastIndex(target, "*."); i >= 0 {
return target[i+2:]
}
if i := strings.LastIndex(target, "."); i >= 0 {
return target[i+1:]
}
if i := strings.LastIndex(target, "::"); i >= 0 {
return target[i+2:]
}
return target
}
// isLockMethodName recognises the names that the standard Go / Rust /
// TS / Python / Java sync libraries use to acquire a lock. Matching
// is case-insensitive because some libraries shout-case their APIs.
func isLockMethodName(name string) bool {
switch strings.ToLower(name) {
case "lock", "rlock", "trylock", "trylockcontext",
"acquire", "tryacquire",
"withlock", "withreadlock", "withwritelock",
"runwithlock",
"do", // sync.Once
"synchronized": // Java-flavoured wrapper helpers
return true
}
return false
}
// ---------------------------------------------------------------------------
// unclosed_channels — channels with at least one EdgeSends but
// no `close()` call site reachable from any sender or receiver.
// ---------------------------------------------------------------------------
// handleAnalyzeUnclosedChannels surfaces channel variables that are
// sent to but never closed. Two failure modes the heuristic catches:
//
// 1. Multiple goroutines write to a channel and rely on a `for v
// := range ch` consumer to terminate when the channel closes —
// if nobody closes the channel, the receiver hangs forever.
// 2. A worker pool publishes results via a channel and the
// coordinator forgets to close after all workers return.
//
// Detection: a channel is "closed somewhere" if any of its senders
// or receivers is in the set of functions that contain a `close()`
// call. Conservative — a channel closed by an unrelated function
// counts as "not flagged" too, because the analyzer cannot prove
// arg identity from the static graph alone.
func (s *Server) handleAnalyzeUnclosedChannels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
limit := 50
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
// Pass 1: build the set of functions that contain a `close()`
// call. Indirect proxy for "this function probably closes a
// channel"; the channel arg isn't tracked so the membership test
// is per-function, not per-channel.
closesIn := map[string]bool{}
for e := range edgesByKinds(s.graph, graph.EdgeCalls) {
if callTargetName(e) != "close" {
continue
}
closesIn[e.From] = true
}
// Pass 2: aggregate channels by sender/receiver sets.
type channelInfo struct {
Channel string
Senders map[string]bool
Receivers map[string]bool
Sends int
Recvs int
FilePath string
Line int
}
byChannel := map[string]*channelInfo{}
for e := range edgesByKinds(s.graph, graph.EdgeSends, graph.EdgeRecvs) {
info := byChannel[e.To]
if info == nil {
info = &channelInfo{
Channel: e.To,
Senders: map[string]bool{},
Receivers: map[string]bool{},
FilePath: e.FilePath,
Line: e.Line,
}
byChannel[e.To] = info
}
if e.Kind == graph.EdgeSends {
info.Sends++
info.Senders[e.From] = true
} else {
info.Recvs++
info.Receivers[e.From] = true
}
}
type unclosedRow struct {
Channel string `json:"channel"`
FilePath string `json:"file_path"`
Line int `json:"line"`
Sends int `json:"sends"`
Recvs int `json:"recvs"`
Senders int `json:"senders"`
Risk string `json:"risk"`
Reason string `json:"reason"`
}
var rows []unclosedRow
for _, info := range byChannel {
if pathPrefix != "" && !strings.HasPrefix(info.FilePath, pathPrefix) {
continue
}
if info.Sends == 0 {
// Pure receive sites — closing is the sender's job and
// there are none. Skip.
continue
}
anyCloser := false
for fn := range info.Senders {
if closesIn[fn] {
anyCloser = true
break
}
}
if !anyCloser {
for fn := range info.Receivers {
if closesIn[fn] {
anyCloser = true
break
}
}
}
if anyCloser {
continue
}
risk, reason := classifyUnclosed(len(info.Senders), info.Recvs)
rows = append(rows, unclosedRow{
Channel: info.Channel,
FilePath: info.FilePath,
Line: info.Line,
Sends: info.Sends,
Recvs: info.Recvs,
Senders: len(info.Senders),
Risk: risk,
Reason: reason,
})
}
// Scope filter: keep only channels whose channel node is visible to
// the current request. Senders/Sends/Recvs are counts (not node IDs)
// so they need no pruning. total/truncated recompute below. No-op for
// an unbound request.
if s.scopeFiltersActive(ctx) {
kept := make([]unclosedRow, 0, len(rows))
for _, r := range rows {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Channel)) {
kept = append(kept, r)
}
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rowRiskRank(rows[i].Risk) != rowRiskRank(rows[j].Risk) {
return rowRiskRank(rows[i].Risk) > rowRiskRank(rows[j].Risk)
}
if rows[i].Sends != rows[j].Sends {
return rows[i].Sends > rows[j].Sends
}
return rows[i].Channel < rows[j].Channel
})
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
// unclosedRow and unclosedChannelItem share the same field
// shape (only tag annotations differ, which Go's struct
// conversion ignores). A direct conversion avoids the
// per-field copy boilerplate.
items := make([]unclosedChannelItem, 0, len(rows))
for _, r := range rows {
items = append(items, unclosedChannelItem(r))
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("unclosed_channels", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "[%s] sends=%d senders=%d recvs=%d %s\n",
r.Risk, r.Sends, r.Senders, r.Recvs, r.Channel)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no unclosed channels with senders\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"unclosed_channels": rows,
"total": len(rows),
"truncated": truncated,
})
}
// classifyUnclosed returns a (risk, reason) pair for an unclosed
// channel. High-risk: multiple senders + receiver(s) — the canonical
// "fan-in / worker pool, nobody closes after the workers drain"
// shape that hangs `for v := range ch`. Medium: a single sender with
// receivers — the receiver may or may not range; without arg flow
// we can't tell. Low: senders without receivers, almost always a
// fire-and-forget signal.
func classifyUnclosed(senders, recvs int) (string, string) {
switch {
case senders >= 2 && recvs >= 1:
return "high", "multiple senders with consumer(s) and no detected close — receivers will hang on range"
case senders == 1 && recvs >= 1:
return "medium", "sender with consumer(s) and no detected close — fine if receiver doesn't range"
default:
return "low", "sender(s) with no detected close and no consumer in this scope"
}
}
func rowRiskRank(r string) int {
switch r {
case "high":
return 3
case "medium":
return 2
case "low":
return 1
}
return 0
}