293 lines
11 KiB
Go
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
|
|
}
|