Files
2026-07-13 13:00:08 +08:00

293 lines
11 KiB
Go

// Per-plugin startup latency tracking for MCP servers. Reasonix uses these
// samples to decide whether a chronically slow plugin should be demoted from
// "eager" to background loading for the rest of a session — see Recommend.
//
// Storage is one tiny JSON file per plugin under <cacheDir>/mcp/, written
// atomically (tmpfile + Rename) so a crash mid-write can't corrupt history.
// All errors are best-effort: missing/unreadable files yield "no demote",
// write failures get logged via slog and dropped — startup must not fail
// because telemetry can't persist.
package plugin
import (
"encoding/json"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
"reasonix/internal/config"
fileencoding "reasonix/internal/fileutil/encoding"
)
const (
// statsVersion is the on-disk format version. Bump when StartupStats changes
// shape incompatibly; older files are then ignored on load (treated as
// missing) so a new build doesn't crash on a stale schema.
statsVersion = 1
// maxSamples bounds the rolling window. 20 is enough to absorb a few flukes
// while still letting recent regressions dominate p99 / consecutive-fail
// checks. Older samples drop off the front.
maxSamples = 20
// defaultDemoteAfter is the consecutive-over-budget threshold used when the
// caller passes <= 0. Three in a row is "user has felt it three startups",
// which matches what the plan calls out as the demote signal.
defaultDemoteAfter = 3
)
// StartupStats is the on-disk record of recent startup durations for one
// plugin. SamplesMs is oldest→newest (newest appended at the tail); LastSeen
// is the wall-clock time the most recent sample was recorded so a future
// "stale data" pruning step can act on it without re-parsing each sample.
type StartupStats struct {
Version int `json:"version"`
SamplesMs []int64 `json:"samples_ms"`
LastSeen time.Time `json:"last_seen"`
// Name is the raw (pre-slug) server name that owns these samples. The
// filename slug is lossy — "foo.bar" and "foo-bar" share one file — so
// readers verify ownership before trusting samples, or a slow server's
// history could demote an unrelated healthy one. Empty on files written
// by older versions; those are adopted by the first writer (additive
// field, no version bump, so downgraded builds still read the file).
Name string `json:"name,omitempty"`
}
// Recommendation is the result of inspecting a plugin's recent startup
// history. Demote is the actionable bit boot.go consumes (true → switch this
// plugin to background startup for this session). P99 and Reason are
// descriptive only — Reason is meant to be surfaced to the user as a Notice so a
// sudden demotion isn't silent.
type Recommendation struct {
Demote bool
P99 time.Duration
Reason string
}
// RecordStartup appends one sample (the wall-clock duration of a plugin's
// blocking handshake phase) to that plugin's stats file. The file is created
// on first call; existing samples are kept in a rolling window of maxSamples,
// dropping the oldest when full.
//
// Best-effort: any I/O or marshal failure is logged with slog.Warn and
// returned, but callers are expected to ignore the error — telemetry must
// never block real work. Writes go through a tmpfile + Rename so a partial
// write can't leave the stats file truncated or unparseable.
func RecordStartup(name string, dur time.Duration) error {
path := statsPath(name)
if path == "" {
// No cache dir resolvable on this host — silently skip. This is the
// same fallback every other persistence helper in the project takes
// (ArchiveDir/SessionDir return "" and writers no-op).
return nil
}
stats := loadStatsForOwner(name) // missing/corrupt/foreign → fresh zero value
if stats.Version != statsVersion {
// Version mismatch: start over rather than try to migrate. The window
// is small enough that "lose 20 samples" is cheap, and it keeps the
// migration path trivial as the format evolves.
stats = StartupStats{Version: statsVersion}
}
stats.Name = name
ms := dur.Milliseconds()
if ms < 0 {
ms = 0
}
stats.SamplesMs = append(stats.SamplesMs, ms)
if len(stats.SamplesMs) > maxSamples {
// Trim from the front: oldest samples leave first.
stats.SamplesMs = stats.SamplesMs[len(stats.SamplesMs)-maxSamples:]
}
stats.LastSeen = time.Now()
if err := writeStatsAtomic(path, stats); err != nil {
slog.Warn("plugin: record startup stats failed", "server", name, "err", err)
return err
}
return nil
}
// Recommend inspects the recent samples for name and decides whether the
// plugin should be demoted to "lazy" this session. The rule is simple: demote
// when the last demoteAfter samples all hit or exceed the blocking startup
// budget. Missing/empty stats → no demote (a fresh plugin gets the benefit of
// the doubt and one normal startup attempt).
//
// budget == 0 disables the check (returns no-demote). demoteAfter <= 0 falls
// back to defaultDemoteAfter so callers can pass the config value verbatim
// without sanitising it.
func Recommend(name string, budget time.Duration, demoteAfter int) Recommendation {
if budget <= 0 {
return Recommendation{}
}
if demoteAfter <= 0 {
demoteAfter = defaultDemoteAfter
}
path := statsPath(name)
if path == "" {
return Recommendation{}
}
stats := loadStatsForOwner(name)
if stats.Version != statsVersion || len(stats.SamplesMs) == 0 {
// Either no history yet or a format we can't read — give the plugin a
// chance. The cost of one slow start is small compared to wrongly
// demoting a healthy plugin off stale data.
return Recommendation{}
}
rec := Recommendation{P99: p99(stats.SamplesMs)}
if len(stats.SamplesMs) < demoteAfter {
return rec
}
threshold := budget.Milliseconds()
tail := stats.SamplesMs[len(stats.SamplesMs)-demoteAfter:]
for _, ms := range tail {
if ms < threshold {
return rec
}
}
rec.Demote = true
rec.Reason = fmt.Sprintf(
"plugin %q has been slow %d startups in a row (last %dms, budget %dms); demoting to background startup this session",
name, demoteAfter, tail[len(tail)-1], budget.Milliseconds(),
)
return rec
}
// statsPath returns the canonical path for one plugin's stats file:
// <config.CacheDir()>/mcp/<slug>-<hash>.stats.json. The slug alone is lossy —
// "foo.bar" and "foo-bar" collapse to one slug — and two colliding servers
// sharing a file would alternately reset each other's window, so neither
// could ever accumulate enough consecutive samples to demote. Hashing the raw
// name into the filename gives every server its own history. The slug portion
// is bounded so the whole component stays under the 255-byte filesystem limit
// even for very long server names — the hash carries the uniqueness, so
// truncating the slug is safe. Returns "" when no cache dir is resolvable,
// which all callers treat as "skip telemetry".
func statsPath(name string) string {
base := config.CacheDir()
if base == "" {
return ""
}
h := fnv.New32a()
_, _ = h.Write([]byte(name))
// 255-byte component budget: slug + "-" + 8 hex digits + ".stats.json".
stem := slug(name)
if maxStem := 255 - len("-00000000.stats.json"); len(stem) > maxStem {
stem = stem[:maxStem]
}
return filepath.Join(base, "mcp", fmt.Sprintf("%s-%08x.stats.json", stem, h.Sum32()))
}
// legacyStatsPath is the pre-hash location, shared by every server whose name
// collapses to the same slug. Read-only for migration: a named legacy file is
// adopted by its owner; a nameless one (pre-Name builds) has no provable owner
// and is never trusted for demotion or adopted into a new window.
func legacyStatsPath(name string) string {
base := config.CacheDir()
if base == "" {
return ""
}
return filepath.Join(base, "mcp", slug(name)+".stats.json")
}
// loadStats reads and decodes a stats file. Any failure (missing file,
// permission denied, malformed JSON) returns a zero StartupStats so callers
// can treat absence and corruption identically. The slog.Warn fires only on
// non-NotExist read errors and on JSON errors — those are surprising enough
// to be worth a trace, but they still must not stop the caller.
func loadStats(path string) StartupStats {
var s StartupStats
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("plugin: read startup stats failed", "path", path, "err", err)
}
return s
}
if err := json.Unmarshal(b, &s); err != nil {
slog.Warn("plugin: parse startup stats failed", "path", path, "err", err)
return StartupStats{}
}
return s
}
// loadStatsForOwner reads name's history from its hashed path, falling back to
// the shared legacy (pre-hash) file for a one-time migration. Legacy files are
// trusted only when their recorded Name matches: a nameless legacy file
// (written before ownership tracking) is shared by every server whose name
// collapses to the same slug, so its samples cannot be attributed and must not
// seed anyone's window or demote anyone.
func loadStatsForOwner(name string) StartupStats {
if s := loadStats(statsPath(name)); s.Version != 0 || len(s.SamplesMs) > 0 {
if s.Name == "" || s.Name == name {
return s
}
return StartupStats{}
}
legacy := legacyStatsPath(name)
if legacy == "" {
return StartupStats{}
}
if s := loadStats(legacy); s.Name == name {
return s
}
return StartupStats{}
}
// writeStatsAtomic serialises s and writes it via tmpfile + os.Rename so that
// concurrent readers see either the old content or the new one, never a
// half-written file. Mirrors desktop/sessions.go:40-64.
func writeStatsAtomic(path string, s StartupStats) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
b, err := json.Marshal(s)
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".stats.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return os.Rename(tmpPath, path)
}
// p99 returns the 99th-percentile sample as a Duration. With small windows
// (n ≤ 20) "p99" collapses to "the slowest sample we have"; the value is
// purely informational, surfaced in Recommendation for UI/notice text.
func p99(samples []int64) time.Duration {
if len(samples) == 0 {
return 0
}
sorted := append([]int64(nil), samples...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
// Use ceil(0.99 * n) - 1 so that for n=1..100 we always pick the last
// element; for larger n it's the index at the 99% boundary.
idx := int(float64(len(sorted))*0.99+0.9999999) - 1
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return time.Duration(sorted[idx]) * time.Millisecond
}