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
476 lines
15 KiB
Go
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
|
|
}
|