diff --git a/internal/derive/buckets.go b/internal/derive/buckets.go index c02ecde..b487bd4 100644 --- a/internal/derive/buckets.go +++ b/internal/derive/buckets.go @@ -78,7 +78,7 @@ func PlaceProcesses(procs []model.Proc, sessions []model.Session, sigs *signatur } placement := ProcPlacement{PID: p.PID, Bucket: BucketOther} placement.Memory, placement.Measure = ProcessMemory(p) - agent, likely, appBoundary := false, false, false + agent, likely, appBoundary, hostPending := false, false, false, false var shared int32 bundle := "" for cur := range processChain(byPID, p.PID) { @@ -97,9 +97,13 @@ func PlaceProcesses(procs []model.Proc, sessions []model.Session, sigs *signatur if !appBoundary { // MCP evidence wins over the bundle: editors may run MCP servers // with their own bundled runtime. - if mcp[cur.PID] && shared == 0 { - shared = cur.PPID - likely = true + if hostPending { + // The host is the next process the chain walked, so it is + // a sampled, valid parent and never a reused or dead PID. + shared, likely, hostPending = cur.PID, true, false + } + if mcp[cur.PID] && shared == 0 && !likely { + hostPending = true } else if AppBundle(cur.Exe) != "" && !proven[cur.PID] { appBoundary = true } diff --git a/internal/derive/buckets_test.go b/internal/derive/buckets_test.go index dcc8dea..695f4d7 100644 --- a/internal/derive/buckets_test.go +++ b/internal/derive/buckets_test.go @@ -67,6 +67,33 @@ func TestBucketsEditorExtensionHostMCP(t *testing.T) { t.Fatal(got) } } + +// An MCP server's shared host is the parent its chain walked: never a process +// that reused the PID of its exited parent, and never a parent missing from +// the sample. Without a host the server is not shared with an editor. +func TestBucketsMCPHostIsAWalkedParent(t *testing.T) { + now := time.Now() + for _, tc := range []struct { + name string + mutate func([]model.Proc) []model.Proc + host int32 + bucket Bucket + }{ + {"live parent", func(ps []model.Proc) []model.Proc { return ps }, 1, BucketLikely}, + {"reused parent PID", func(ps []model.Proc) []model.Proc { + ps[0].StartTime, ps[1].StartTime = now, now.Add(-time.Minute) + return ps + }, 0, BucketOther}, + {"parent not sampled", func(ps []model.Proc) []model.Proc { return ps[1:] }, 0, BucketOther}, + } { + t.Run(tc.name, func(t *testing.T) { + got := PlaceProcesses(tc.mutate(editorBucketFixture()), nil, nil)[2] + if got.Bucket != tc.bucket || got.SharedHost != tc.host { + t.Fatal(got) + } + }) + } +} func TestBucketsEditorWorkerIsOther(t *testing.T) { ps := editorBucketFixture() ps[1].Cmdline = "node /x/tsserver.js"