Files
DeepSeek-Reasonix/internal/plugin/stats.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

287 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"
"slices"
"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 := max(dur.Milliseconds(), 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...)
slices.Sort(sorted)
// 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 := max(int(float64(len(sorted))*0.99+0.9999999)-1, 0)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return time.Duration(sorted[idx]) * time.Millisecond
}