fix(plugins): prune superseded plugin versions after a confirmed start (#2921)

Bounds plugin disk use: installing never removed the version it replaced, so
~/.kandev/plugins/<id>/ accumulated a full copy per install forever (4.2GB on
one machine, 3.74GB of it superseded versions). A plugin now keeps its active
version plus one rollback target, pruned only once the new version is
confirmed running.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-22 15:35:58 +01:00
committed by GitHub
parent f284985d10
commit dc8403e2e8
9 changed files with 908 additions and 2 deletions
+12
View File
@@ -75,6 +75,18 @@ type Service struct {
// insertion one atomic catalog mutation across different plugin IDs.
agentToolInstallMu sync.Mutex
// extractingMu guards extractingPaths and, crucially, is held across the
// pkgtar extraction that registers into it: a version directory must never
// be observable on disk before it is marked in flight, or a concurrent
// prune could delete it in that gap. See extractPackage.
extractingMu sync.Mutex
// extractingPaths counts, per version directory, the installs that have
// extracted it but have not yet finished. Install extracts before it can
// know the plugin id (and therefore before it can take that id's lifecycle
// lock), so this is what tells a prune running under the lock that a
// directory belongs to an install still waiting for it.
extractingPaths map[string]int
pluginsDir string
store store.Store
registry *Registry
@@ -45,10 +45,11 @@ const downloadTimeout = 60 * time.Second
// survive) and restarts the previous version's process, so a failed upgrade
// attempt never destroys a previously working install.
func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, error) {
result, err := pkgtar.Install(r, s.pluginsDir)
result, err := s.extractPackage(r)
if err != nil {
return nil, err
}
defer s.releaseExtraction(result.InstallPath)
if err := s.checkMinKandevVersion(result.Manifest.MinKandevVersion); err != nil {
_ = os.RemoveAll(result.InstallPath)
return nil, err
@@ -123,6 +124,14 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
catalogLocked = false
activateErr := s.activate(rec)
if activateErr == nil {
// Only now can the new version be confirmed running, which is the
// point at which the versions it superseded stop being rollback
// targets. Pruning any earlier would delete what a failed upgrade
// falls back to; pruneSupersededVersions re-checks that the process is
// actually up before deleting anything.
s.pruneSupersededVersions(rec.ID, rec.Version, previousVersion(oldRec, hadOldRec))
}
s.notifyDeliverer()
s.notifyAgentToolCatalogChanged()
@@ -133,6 +142,65 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
return installed, activateErr
}
// extractPackage runs pkgtar.Install and registers the extracted version
// directory as an in-flight install, both under extractingMu so the directory
// is never visible on disk without being marked. Install cannot take the
// per-plugin lifecycle lock any earlier than this — the id is only known once
// the package's manifest has been parsed — so without the mark, two
// overlapping installs of the same id would both extract, and whichever
// acquired the lock first would prune the other's fresh directory and leave it
// activating an InstallPath that no longer exists.
//
// The mutex serializes extraction across every plugin, not just one id. That
// is deliberate and cheap: an install is a rare operator- or poller-driven
// action, and the alternative (marking after pkgtar returns) leaves exactly
// the gap this exists to close.
func (s *Service) extractPackage(r io.Reader) (*pkgtar.InstallResult, error) {
s.extractingMu.Lock()
defer s.extractingMu.Unlock()
result, err := pkgtar.Install(r, s.pluginsDir)
if err != nil {
return nil, err
}
if s.extractingPaths == nil {
s.extractingPaths = make(map[string]int)
}
s.extractingPaths[result.InstallPath]++
return result, nil
}
// releaseExtraction drops one in-flight mark for path, deferred by Install so
// it runs however that install ends (rejected package, failed persist, or a
// completed activation).
func (s *Service) releaseExtraction(path string) {
s.extractingMu.Lock()
defer s.extractingMu.Unlock()
if s.extractingPaths[path] > 1 {
s.extractingPaths[path]--
return
}
delete(s.extractingPaths, path)
}
// extractionInFlight reports whether some install has extracted path and not
// yet finished with it.
func (s *Service) extractionInFlight(path string) bool {
s.extractingMu.Lock()
defer s.extractingMu.Unlock()
return s.extractingPaths[path] > 0
}
// previousVersion returns the version an in-place upgrade replaced, or "" when
// this install had no existing record to replace (a first install, or one over
// a plugin whose record was lost) and therefore knows of no rollback target.
func previousVersion(oldRec *store.Record, hadOldRec bool) string {
if !hadOldRec || oldRec == nil {
return ""
}
return oldRec.Version
}
// DevKandevVersion is the version string an un-stamped local build carries
// (cmd/kandev's `Version` default, mirrored by internal/system/updates'
// devVersion). It sorts meaninglessly against real semver, and a developer
@@ -305,7 +305,16 @@ func (s *Service) StartActivePlugins(ctx context.Context) {
s.notifyDeliverer()
s.notifyAgentToolCatalogChanged()
}
continue
}
// Boot is the only trigger that reaches an instance which already
// accumulated superseded versions but installs nothing new (a plugin
// already at its latest version, or one whose auto-update is off), so
// it prunes too — under the same confirmed-start precondition as
// Install, which is what keeps disabled plugins, sideloads registered
// disabled, and plugins that fail to spawn untouched. Unlike Install,
// this loop holds no lifecycle lock, so the prune takes one itself.
s.pruneUnderLifecycleLock(rec.ID, rec.Version, "")
}
}
@@ -0,0 +1,185 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/plugins/manifest"
)
// pluginDataDirName is the plugin-owned writable directory the runtime
// manager creates beside a plugin's version directories
// (<pluginsDir>/<id>/data — see internal/plugins/runtime's pluginCommand). It
// is durable state that survives every upgrade and is removed only by
// Uninstall, so the prune below excludes it by name as well as by the
// manifest check every retained candidate has to pass.
const pluginDataDirName = "data"
// pruneSupersededVersions removes every superseded version directory under
// <pluginsDir>/<id>/, keeping activeVersion plus one rollback target.
// Installing never used to remove the version it replaced, so a plugin's
// directory grew by a full copy (~20MB, ~95MB for a package shipping all five
// platform binaries) on every install — and the auto-update poller installs
// with no user action at all, which is how a single plugin reached 46
// superseded copies and one machine reached 3.74GB of them.
//
// Retention is deliberately fixed at "current + 1 previous" rather than
// configurable: the second directory exists so a failed upgrade still has a
// working process to fall back on (see rollbackFailedInstall), which is a
// safety invariant rather than an operator preference, and any larger number
// re-creates the unbounded growth this exists to stop.
//
// Nothing is deleted unless id's process is confirmed running, which is the
// precondition that makes this safe: pruning before the new version starts
// would delete the very directory a failed upgrade needs. The check lives here
// rather than at each call site so a future caller cannot forget it — note
// that activate returns nil without starting anything when no runtime is
// wired, so "activate succeeded" alone is not the same guarantee.
//
// Every failure is logged and swallowed: a plugin that installed and started
// correctly must never be reported as failed because cleanup could not delete
// a directory. Callers hold id's lifecycle lock, so no concurrent
// install/enable/uninstall for the same id can race the scan.
//
// rollbackVersion names the version the caller knows was previously in use;
// when it is empty (or no longer on disk) the semver-greatest remaining
// version is retained instead.
func (s *Service) pruneSupersededVersions(id, activeVersion, rollbackVersion string) {
if s.pluginsDir == "" || id == "" || activeVersion == "" {
return
}
if s.runtime == nil || !s.runtime.Running(id) {
return
}
// Every read and delete below goes through one os.Root handle opened on
// the plugin directory, so a path validated by isInstalledVersionDir is
// removed within the same confined tree: a symlink or rename planted
// mid-prune cannot redirect the removal outside <pluginsDir>/<id>/.
root, err := os.OpenRoot(filepath.Join(s.pluginsDir, id))
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("plugins: could not open plugin directory for pruning",
zap.String("plugin_id", id), zap.Error(err))
}
return
}
defer func() { _ = root.Close() }()
versions, err := installedVersionDirs(root, id)
if err != nil {
s.log.Warn("plugins: could not scan installed versions for pruning",
zap.String("plugin_id", id), zap.Error(err))
return
}
keep := retainedVersions(versions, activeVersion, rollbackVersion)
for _, version := range versions {
if keep[version] {
continue
}
// A concurrent Install extracts before it can take id's lifecycle
// lock, so a version directory that exists here may belong to an
// install still waiting on that lock. Deleting it would strand that
// caller with an InstallPath pointing at nothing.
if s.extractionInFlight(filepath.Join(s.pluginsDir, id, version)) {
continue
}
if err := root.RemoveAll(version); err != nil {
s.log.Warn("plugins: could not remove superseded plugin version",
zap.String("plugin_id", id), zap.String("version", version), zap.Error(err))
continue
}
s.log.Info("plugins: removed superseded plugin version",
zap.String("plugin_id", id), zap.String("version", version))
}
}
// pruneUnderLifecycleLock is pruneSupersededVersions for a caller that does
// not already hold id's lifecycle lock (the boot activation loop). Install
// holds it across activate and the prune that follows, and the prune relies on
// that exclusion, so the boot path takes the same lock rather than depending
// on nothing else being able to run yet.
func (s *Service) pruneUnderLifecycleLock(id, activeVersion, rollbackVersion string) {
lock := s.lifecycleLocks.lockFor(id)
lock.Lock()
defer lock.Unlock()
s.pruneSupersededVersions(id, activeVersion, rollbackVersion)
}
// retainedVersions returns the set of version directory names to keep:
// active, plus one rollback target — rollback when the caller named a version
// that is still on disk, otherwise the semver-greatest of the rest.
func retainedVersions(versions []string, active, rollback string) map[string]bool {
keep := map[string]bool{active: true}
fallback := ""
for _, version := range versions {
if version == active {
continue
}
if version == rollback {
keep[version] = true
return keep
}
if fallback == "" || manifest.CompareVersions(version, fallback) > 0 {
fallback = version
}
}
if fallback != "" {
keep[fallback] = true
}
return keep
}
// installedVersionDirs lists the entries directly under root (a handle on
// <pluginsDir>/<id>/) that are installed versions of id. The bar is
// deliberately high, because everything it returns is a deletion candidate:
// the entry must be a real directory (a symlink reports as a link here and is
// skipped), must not be the plugin's data directory or any dot-prefixed name
// (which covers pkgtar's in-flight ".tmp-*" staging directories and any other
// hidden entry), and must hold a manifest.yaml naming exactly this id and this
// directory as its version — which is what pkgtar.Install writes and what
// nothing else on that path produces. The plugin's store record ("<id>.yml")
// and config ("<id>.config.yml") live one level up, beside the plugin
// directory rather than inside it, so the root handle cannot reach them.
func installedVersionDirs(root *os.Root, id string) ([]string, error) {
dir, err := root.Open(".")
if err != nil {
return nil, err
}
defer func() { _ = dir.Close() }()
entries, err := dir.ReadDir(-1)
if err != nil {
return nil, err
}
var versions []string
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() || name == pluginDataDirName || strings.HasPrefix(name, ".") {
continue
}
if !isInstalledVersionDir(root, id, name) {
continue
}
versions = append(versions, name)
}
return versions, nil
}
// isInstalledVersionDir reports whether <version>/manifest.yaml under root
// parses and declares exactly this id and version.
func isInstalledVersionDir(root *os.Root, id, version string) bool {
data, err := root.ReadFile(filepath.Join(version, manifestFileName))
if err != nil {
return false
}
m, err := manifest.Parse(data)
if err != nil {
return false
}
return m.ID == id && m.Version == version
}
@@ -0,0 +1,517 @@
package plugins
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"sort"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/store"
)
const prunePluginID = "kandev-plugin-slack"
// seedVersionDir writes a version directory shaped exactly like one
// pkgtar.Install extracts (a manifest.yaml whose id/version match the
// directory name, plus a payload file), so the prune scan sees a real
// superseded install rather than an empty directory.
func seedVersionDir(t *testing.T, pluginsDir, id, version string) string {
t.Helper()
dir := filepath.Join(pluginsDir, id, version)
if err := os.MkdirAll(filepath.Join(dir, "server"), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", dir, err)
}
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: %s
display_name: Test Plugin
runtime:
type: binary
executables:
%s: server/plugin
`, id, version, platformKey)
if err := os.WriteFile(filepath.Join(dir, manifestFileName), []byte(manifestYAML), 0o644); err != nil {
t.Fatalf("WriteFile(manifest.yaml): %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "server", "plugin"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(server/plugin): %v", err)
}
return dir
}
// versionDirsOnDisk lists every directory entry under <pluginsDir>/<id>/,
// sorted, so a test can assert the exact surviving set (including entries a
// prune must never touch, like "data").
func versionDirsOnDisk(t *testing.T, pluginsDir, id string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(pluginsDir, id))
if err != nil {
t.Fatalf("ReadDir(%q): %v", filepath.Join(pluginsDir, id), err)
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name())
}
}
sort.Strings(names)
return names
}
func assertVersionDirs(t *testing.T, pluginsDir, id string, want ...string) {
t.Helper()
got := versionDirsOnDisk(t, pluginsDir, id)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("version dirs on disk = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("version dirs on disk = %v, want %v", got, want)
}
}
}
// TestServiceInstallPrunesSupersededVersions pins the retention rule: after a
// successful install whose new version started cleanly, only the newly active
// version and the immediately-previous one (the rollback target) survive.
// Every install used to leave its predecessor behind forever — 46 copies of a
// single plugin, 3.74GB of superseded versions on a real machine.
func TestServiceInstallPrunesSupersededVersions(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err != nil {
t.Fatalf("Install(1.1.0): %v", err)
}
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.2.0", false)); err != nil {
t.Fatalf("Install(1.2.0): %v", err)
}
assertVersionDirs(t, dir, prunePluginID, "1.1.0", "1.2.0")
}
// TestServiceInstallPrunesAccumulatedVersions covers the already-broken
// instance: many superseded versions predate the fix, and one successful
// install must collapse them to the retained pair.
func TestServiceInstallPrunesAccumulatedVersions(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0, active
for _, v := range []string{"0.1.0", "0.2.0", "0.9.9"} {
seedVersionDir(t, dir, prunePluginID, v)
}
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err != nil {
t.Fatalf("Install(1.1.0): %v", err)
}
assertVersionDirs(t, dir, prunePluginID, "1.0.0", "1.1.0")
}
// TestServiceInstallPruneKeepsRollbackTarget pins that the first upgrade
// prunes nothing: the previous version is the rollback target a failed
// restart falls back to.
func TestServiceInstallPruneKeepsRollbackTarget(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err != nil {
t.Fatalf("Install(1.1.0): %v", err)
}
assertVersionDirs(t, dir, prunePluginID, "1.0.0", "1.1.0")
}
// TestServiceInstallPruneKeepsDataDirectory pins the hard constraint: the
// plugin's writable data directory is durable state that survives every
// upgrade and is removed only on uninstall.
func TestServiceInstallPruneKeepsDataDirectory(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
marker := filepath.Join(dir, prunePluginID, "data", "marker.txt")
if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
t.Fatalf("MkdirAll(data): %v", err)
}
if err := os.WriteFile(marker, []byte("keep me"), 0o644); err != nil {
t.Fatalf("WriteFile(marker): %v", err)
}
for _, v := range []string{"1.1.0", "1.2.0"} {
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, v, false)); err != nil {
t.Fatalf("Install(%s): %v", v, err)
}
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("plugin data dir marker was removed by the prune: %v", err)
}
assertVersionDirs(t, dir, prunePluginID, "1.1.0", "1.2.0", "data")
}
// TestServiceInstallPruneNeverRemovesDataDirWithManifest pins the data
// directory's exclusion by name, independently of the manifest check that
// also happens to cover it. The directory is plugin-writable, so its contents
// are not something the host gets to reason about: a plugin that writes its
// own manifest.yaml there must not be able to talk the prune into deleting
// its durable state.
func TestServiceInstallPruneNeverRemovesDataDirWithManifest(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
seedVersionDir(t, dir, prunePluginID, pluginDataDirName)
marker := filepath.Join(dir, prunePluginID, pluginDataDirName, "marker.txt")
if err := os.WriteFile(marker, []byte("keep me"), 0o644); err != nil {
t.Fatalf("WriteFile(marker): %v", err)
}
for _, v := range []string{"1.1.0", "1.2.0"} {
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, v, false)); err != nil {
t.Fatalf("Install(%s): %v", v, err)
}
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("plugin data dir was removed by the prune: %v", err)
}
}
// TestServiceInstallPruneSkipsNonVersionDirectories pins that the prune only
// removes directories that actually parse as an installed version: an
// operator's stray directory, a directory whose manifest names a different
// version, and pkgtar's in-flight ".tmp-*" staging directory are all left
// alone.
func TestServiceInstallPruneSkipsNonVersionDirectories(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
seedVersionDir(t, dir, prunePluginID, "0.9.0")
pluginDir := filepath.Join(dir, prunePluginID)
if err := os.MkdirAll(filepath.Join(pluginDir, "notes"), 0o755); err != nil {
t.Fatalf("MkdirAll(notes): %v", err)
}
// pkgtar extracts into a ".tmp-*" dir under the plugin directory before
// renaming it into place, so a concurrent install has a fully populated
// staging dir here that the prune must not delete out from under it.
staging := seedVersionDir(t, dir, prunePluginID, "1.3.0")
if err := os.Rename(staging, filepath.Join(pluginDir, ".tmp-inflight")); err != nil {
t.Fatalf("Rename(.tmp-inflight): %v", err)
}
// A directory whose manifest declares a different version than its own
// name is not something the installer produced; leave it to the operator.
mismatched := seedVersionDir(t, dir, prunePluginID, "0.8.0")
if err := os.Rename(mismatched, filepath.Join(pluginDir, "misnamed")); err != nil {
t.Fatalf("Rename(misnamed): %v", err)
}
for _, v := range []string{"1.1.0", "1.2.0"} {
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, v, false)); err != nil {
t.Fatalf("Install(%s): %v", v, err)
}
}
assertVersionDirs(t, dir, prunePluginID, "1.1.0", "1.2.0", "notes", ".tmp-inflight", "misnamed")
}
// TestServiceInstallSpawnFailurePrunesNothing pins that pruning happens only
// after the new version is confirmed started. A failed spawn must leave every
// other version on disk, because the operator's way out is the previous
// version.
func TestServiceInstallSpawnFailurePrunesNothing(t *testing.T) {
svc, dir, _, rt := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
seedVersionDir(t, dir, prunePluginID, "0.9.0")
rt.setStartErr(prunePluginID, errors.New("spawn failed"))
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err == nil {
t.Fatal("Install() expected an error when the new version fails to spawn")
}
assertVersionDirs(t, dir, prunePluginID, "0.9.0", "1.0.0", "1.1.0")
}
// TestServiceInstallSaveFailurePrunesNothingAndRestartsPrevious pins the
// other failure mode: rollbackFailedInstall removes only the just-extracted
// version and restarts the previous one, and the prune never runs.
func TestServiceInstallSaveFailurePrunesNothingAndRestartsPrevious(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
failing := &failingSaveStore{Store: fsStore}
svc := NewService(failing, NewRegistry(), nil, testLogger(t))
svc.SetPluginsDir(dir)
rt := newFakeRuntime()
svc.SetRuntime(rt)
installTestPlugin(t, svc, prunePluginID) // 1.0.0, active + running
seedVersionDir(t, dir, prunePluginID, "0.9.0")
failing.armFailNextSave()
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err == nil {
t.Fatal("Install() expected an error from the simulated Save failure")
}
assertVersionDirs(t, dir, prunePluginID, "0.9.0", "1.0.0")
if !rt.Running(prunePluginID) {
t.Fatal("the previous version's process was not restarted after the failed upgrade")
}
}
// TestServiceInstallPruneFailureDoesNotFailInstall pins that cleanup is
// best-effort: a plugin that installed and started correctly is never
// reported as failed because a superseded directory could not be removed.
func TestServiceInstallPruneFailureDoesNotFailInstall(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
seedVersionDir(t, dir, prunePluginID, "0.9.0")
requireUnwritableVersionDir(t, filepath.Join(dir, prunePluginID), "0.9.0")
rec, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false))
if err != nil {
t.Fatalf("Install() failed because the prune could not delete a directory: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
}
// requireUnwritableVersionDir makes pluginDir/version reject removal of its
// contents, skipping the test when the current executor does not enforce the
// permission bit (root, or a filesystem/OS that ignores it). The version is a
// parameter rather than a constant so the coupling to what the caller seeded
// is visible at the call site: a caller that seeded a different version would
// otherwise get a silent no-op chmod.
func requireUnwritableVersionDir(t *testing.T, pluginDir, version string) {
t.Helper()
probeParent := filepath.Join(t.TempDir(), "probe")
if err := os.MkdirAll(filepath.Join(probeParent, "child"), 0o755); err != nil {
t.Fatalf("MkdirAll(probe): %v", err)
}
if err := os.Chmod(probeParent, 0o555); err != nil {
t.Skipf("chmod unsupported on this platform: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(probeParent, 0o755) })
if err := os.RemoveAll(filepath.Join(probeParent, "child")); err == nil {
t.Skip("this executor ignores the directory write bit (running as root?)")
}
// The install itself must still be able to create the new version
// directory, so make only the superseded version's own contents
// undeletable rather than the plugin directory.
stale := filepath.Join(pluginDir, version)
if err := os.Chmod(stale, 0o555); err != nil {
t.Skipf("chmod unsupported on this platform: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(stale, 0o755) })
}
// TestServiceInstallWithoutRuntimePrunesNothing pins the precondition against
// the one path where activate reports success without starting anything: with
// no runtime wired, activate only persists StatusActive, so there is no
// confirmed start and no version may be discarded.
func TestServiceInstallWithoutRuntimePrunesNothing(t *testing.T) {
dir := t.TempDir()
svc := NewService(store.NewFSStore(dir), NewRegistry(), nil, testLogger(t))
svc.SetPluginsDir(dir)
for _, v := range []string{"1.0.0", "1.1.0", "1.2.0"} {
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, v, false)); err != nil {
t.Fatalf("Install(%s): %v", v, err)
}
}
assertVersionDirs(t, dir, prunePluginID, "1.0.0", "1.1.0", "1.2.0")
}
// TestPruneSkipsInFlightExtraction pins the guard against a concurrent
// install's freshly extracted directory. Install extracts before it can know
// the plugin id, so it cannot hold that id's lifecycle lock yet; a prune
// running under the lock must still leave that directory alone, and must
// reclaim it once the install that extracted it is done.
func TestPruneSkipsInFlightExtraction(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0
if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false)); err != nil {
t.Fatalf("Install(1.1.0): %v", err)
}
// Exactly what a competing install has done by the time it blocks on the
// lifecycle lock: extracted and marked in flight, nothing else.
inFlight, err := svc.extractPackage(testPackage(t, prunePluginID, "1.2.0", false))
if err != nil {
t.Fatalf("extractPackage(1.2.0): %v", err)
}
svc.pruneSupersededVersions(prunePluginID, "1.1.0", "1.0.0")
assertVersionDirs(t, dir, prunePluginID, "1.0.0", "1.1.0", "1.2.0")
svc.releaseExtraction(inFlight.InstallPath)
svc.pruneSupersededVersions(prunePluginID, "1.1.0", "1.0.0")
assertVersionDirs(t, dir, prunePluginID, "1.0.0", "1.1.0")
}
// TestConcurrentInstallsKeepBothExtractions drives the interleaving end to
// end: the first install is held inside its store write, holding the lifecycle
// lock, while a second install for the same id extracts a different version
// behind it. The first must not prune the second's package out from under it.
func TestConcurrentInstallsKeepBothExtractions(t *testing.T) {
dir := t.TempDir()
barrier := &armedBarrierStore{Store: store.NewFSStore(dir)}
svc := NewService(barrier, NewRegistry(), nil, testLogger(t))
svc.SetPluginsDir(dir)
svc.SetRuntime(newFakeRuntime())
// 1.0.0 must be a real installed record, so the first racing install
// treats it as its rollback target and 1.2.0 becomes a prune candidate.
installTestPlugin(t, svc, prunePluginID)
entered, release := barrier.arm()
errs := make(chan error, 2)
go func() {
_, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.1.0", false))
errs <- err
}()
<-entered // 1.1.0 extracted, lifecycle lock held, prune still ahead
go func() {
_, err := svc.Install(context.Background(), testPackage(t, prunePluginID, "1.2.0", false))
errs <- err
}()
waitForVersionDir(t, dir, prunePluginID, "1.2.0") // extracted, now blocked on the lock
close(release)
if err := <-errs; err != nil {
t.Fatalf("concurrent install error: %v", err)
}
if err := <-errs; err != nil {
t.Fatalf("concurrent install error: %v", err)
}
for _, version := range []string{"1.1.0", "1.2.0"} {
if _, statErr := os.Stat(filepath.Join(dir, prunePluginID, version)); statErr != nil {
t.Fatalf("version %s was pruned out from under a concurrent install: %v", version, statErr)
}
}
rec, err := svc.Get(prunePluginID)
if err != nil {
t.Fatalf("Get(): %v", err)
}
if _, statErr := os.Stat(rec.InstallPath); statErr != nil {
t.Fatalf("the winning record's InstallPath %q does not exist: %v", rec.InstallPath, statErr)
}
}
// armedBarrierStore pauses one chosen Save, so a test can pin an install
// inside the lifecycle-locked region of Install (its record write) while a
// competing install runs behind it. Unarmed saves pass straight through, which
// keeps ordinary setup installs out of the barrier.
type armedBarrierStore struct {
store.Store
mu sync.Mutex
armed bool
entered chan struct{}
release chan struct{}
}
// arm makes the next Save block; entered closes once that Save is reached and
// the returned release channel unblocks it when closed.
func (s *armedBarrierStore) arm() (entered <-chan struct{}, release chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.entered = make(chan struct{})
s.release = make(chan struct{})
s.armed = true
return s.entered, s.release
}
func (s *armedBarrierStore) Save(rec *store.Record) error {
s.mu.Lock()
armed := s.armed
s.armed = false
entered, release := s.entered, s.release
s.mu.Unlock()
if armed {
close(entered)
<-release
}
return s.Store.Save(rec)
}
// waitForVersionDir blocks until a version directory appears, so a test can
// synchronize on a competing install's extraction (a real side effect) rather
// than on a sleep.
func waitForVersionDir(t *testing.T, pluginsDir, id, version string) {
t.Helper()
dir := filepath.Join(pluginsDir, id, version)
deadline := time.NewTimer(10 * time.Second)
defer deadline.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
if _, err := os.Stat(dir); err == nil {
return
}
select {
case <-tick.C:
case <-deadline.C:
t.Fatalf("version dir %q never appeared", dir)
}
}
}
// TestStartActivePluginsPrunesAfterHealthyStart pins boot-time recovery: an
// instance that already accumulated superseded versions collapses them on the
// next restart, without waiting for an install that may never come.
func TestStartActivePluginsPrunesAfterHealthyStart(t *testing.T) {
svc, dir, _, rt := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0, active
for _, v := range []string{"0.7.0", "0.8.0", "0.9.0"} {
seedVersionDir(t, dir, prunePluginID, v)
}
rt.Stop(prunePluginID) // simulate a fresh process: registered active, not running
svc.StartActivePlugins(context.Background())
if !rt.Running(prunePluginID) {
t.Fatal("StartActivePlugins() did not start the active plugin")
}
assertVersionDirs(t, dir, prunePluginID, "0.9.0", "1.0.0")
}
// TestStartActivePluginsDoesNotPruneWhenStartFails pins the same
// confirmed-start precondition at boot.
func TestStartActivePluginsDoesNotPruneWhenStartFails(t *testing.T) {
svc, dir, _, rt := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0, active
seedVersionDir(t, dir, prunePluginID, "0.8.0")
seedVersionDir(t, dir, prunePluginID, "0.9.0")
rt.Stop(prunePluginID)
rt.setStartErr(prunePluginID, errors.New("spawn failed"))
svc.StartActivePlugins(context.Background())
assertVersionDirs(t, dir, prunePluginID, "0.8.0", "0.9.0", "1.0.0")
}
// TestStartActivePluginsDoesNotPruneDisabledPlugin pins that a plugin nobody
// started keeps every version an operator staged for it.
func TestStartActivePluginsDoesNotPruneDisabledPlugin(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, prunePluginID) // 1.0.0, active
seedVersionDir(t, dir, prunePluginID, "0.8.0")
seedVersionDir(t, dir, prunePluginID, "0.9.0")
if err := svc.Disable(prunePluginID); err != nil {
t.Fatalf("Disable(): %v", err)
}
svc.StartActivePlugins(context.Background())
assertVersionDirs(t, dir, prunePluginID, "0.8.0", "0.9.0", "1.0.0")
}
@@ -0,0 +1,82 @@
# ADR-2026-08-22-plugin-version-retention: Keep Exactly One Superseded Plugin Version
**Status:** accepted
**Date:** 2026-08-22
**Area:** backend
## Context
Every plugin install extracts a full package into `~/.kandev/plugins/<id>/<version>/`, and no
install path ever removed the version it replaced. Keeping the predecessor during the upgrade is
deliberate: `Service.Install` stops the running plugin before swapping, and the old directory is
what `rollbackFailedInstall` restarts when the new version fails. Nothing, however, removed it
afterwards.
All four install routes (marketplace/URL, auto-update poller, sideload upload, dropped tarball)
converge on that same code, and the auto-update poller installs with no user action at all, so the
growth is unattended. A version directory is roughly 20MB, or 95MB for a package shipping all five
platform binaries. On one developer machine the plugins directory had reached 4.2GB, of which
3.74GB was 58 superseded version directories, 46 of them belonging to a single auto-updating
plugin.
## Decision
A plugin converges on two extracted versions: the active one and the version it replaced. The
retained count is fixed in code, not configurable. It is a convergent state rather than an enforced
cap, because every precondition below can legitimately leave more versions on disk.
Deletion is gated on a **confirmed running process**, never on the install alone. `Install` prunes
after `activate` returns successfully and boot (`StartActivePlugins`) prunes after `runtime.Start`
succeeds, but in both cases `pruneSupersededVersions` itself re-checks `runtime.Running(id)` before
deleting anything — `activate` returns nil without starting a process when no runtime is wired, so
"activate succeeded" is not the same guarantee. A failed install, a failed spawn, a disabled plugin,
a sideload registered disabled, and a plugin in `error` therefore all keep every version they have,
as does a plugin whose deletion failed. `rollbackFailedInstall` is unchanged, and remains the one
path that deletes a version directory without a prune: it removes the newly extracted version, and
only that one, when the record cannot be persisted.
Extraction is serialized and tracked. `Install` cannot take a plugin's lifecycle lock until it has
parsed the manifest, so two overlapping installs of the same id both extract before either is
serialized; the extraction and its in-flight registration happen under one mutex, and a prune skips
any directory still marked in flight. Without that, the first install to acquire the lock would
delete the second's fresh package and strand it activating a path that no longer exists.
A directory is a deletion candidate only if it is a real directory (never a symlink), is not
`data/` or any dot-prefixed name (which covers pkgtar's `.tmp-*` staging directories), and contains
a `manifest.yaml` declaring exactly that plugin id and that directory name as its version. Listing,
manifest reads, and removal all go through a single `os.Root` handle on the plugin directory, so a
path validated by the scan is removed inside the same confined tree and the `<id>.yml` /
`<id>.config.yml` records one level up are unreachable.
Pruning is best-effort: every failure is logged and swallowed, because a plugin that installed and
started correctly must not be reported as failed because cleanup could not delete a directory.
## Consequences
- Disk use per plugin becomes bounded by two versions instead of growing with every install.
- Rollback keeps working: the version a failed upgrade restarts is always still on disk.
- Boot-time pruning lets an instance that already accumulated versions recover on its next restart,
including plugins that are already at their latest version and would never trigger an install.
- The prune deletes durable on-disk state, so its preconditions are the safety contract and any
change to them needs regression coverage (`service_prune_test.go`).
- Rolling back further than one version now requires reinstalling the wanted package.
## Alternatives Considered
- **Make the retained count configurable.** Rejected as speculative. The second directory exists to
serve a specific mechanism (rollback of a failed upgrade), which is a safety invariant rather than
an operator preference, and any larger configured value re-creates the unbounded growth this
exists to stop. A knob would also need UI, docs, and validation for no demonstrated need.
- **Keep only the active version.** Rejected because it deletes the rollback target
`rollbackFailedInstall` depends on, converting a failed upgrade from recoverable into an outage.
- **Prune immediately after extraction, before the new version starts.** Rejected for the same
reason: it removes the fallback exactly when it is most likely to be needed.
- **Age-based retention (delete versions older than N days).** Rejected because it neither bounds
disk use for a frequently-updating plugin nor guarantees a rollback target exists for a rarely
updated one.
- **Install-time pruning only.** Rejected because the instances that motivated this fix are already
full of superseded versions, and a plugin at its latest version, or one with auto-update off,
would never install again and never recover.
- **A separate operator-triggered cleanup action.** Rejected as a worse default: the growth is
unattended, so the recovery should be too, and an operator who has not noticed 3.74GB of stale
packages will not press a button about them.
+1
View File
@@ -187,3 +187,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-19-repository-qualified-comparison-targets | [Qualify Git Comparison Targets by Repository](2026-08-19-repository-qualified-comparison-targets.md) | accepted | backend, agentctl, frontend, protocol, GitHub, GitLab | 2026-08-19 |
| 2026-08-20-startup-configuration-source-parity | [Startup Configuration Uses One Typed Source Model](2026-08-20-startup-configuration-source-parity.md) | accepted | backend, agentctl, frontend, cli, security, operations | 2026-08-20 |
| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 |
| 2026-08-22-plugin-version-retention | [Keep Exactly One Superseded Plugin Version](2026-08-22-plugin-version-retention.md) | accepted | backend | 2026-08-22 |
+6 -1
View File
@@ -44,7 +44,12 @@ flowchart LR
Installation validates the manifest, archive paths, checksums, managed runtime,
and the current host executable before extraction. Kandev then supervises the
declared subprocess and injects a Host connection. The UI bundle is static
declared subprocess and injects a Host connection. Installing a new version
does not remove the one it replaces: that version stays on disk as the rollback
target, and only the versions before it are deleted, once the new one is
confirmed running. Two extracted versions is therefore the steady state for a
plugin that runs; a plugin that is disabled or failed to start keeps every
version it has, and `data/` is never part of that cleanup. The UI bundle is static
package content loaded by the browser; it does not run inside the backend
subprocess. On disable or uninstall, the host calls destroy when present and
bulk-revokes registrations, styles, routes, handlers, and navigation. Reloads and
+27
View File
@@ -127,6 +127,14 @@ Either path runs the same pipeline:
Before persistence, credential-like values such as PATs, bearer tokens,
labeled secrets/API keys, and the host home path are redacted; plugin stdout
is not stored verbatim.
6. Once the new version is confirmed running, delete the plugin's older
extracted versions, keeping the version now running plus the one it
replaced as a rollback target (see "Version retention on disk"). This step
is skipped entirely when the install or the spawn fails, and a deletion that
fails is logged without failing the install. Skipping it is not the same as
deleting nothing: an install that extracted successfully but could not be
recorded is rolled back, which removes that one new version directory and
restarts the previous one.
A successful install that failed to spawn returns HTTP 201 with a
`warning` field rather than failing outright. The package is installed,
@@ -247,6 +255,25 @@ host-managed runtime fields, including `signed`, `last_error`, and
`last_error_at`. The diagnostic fields are empty until a runtime failure is
recorded and are cleared after successful recovery.
### Version retention on disk
Two extracted versions is the steady state for a plugin that runs: the one
currently running and the one it replaced, which is what a failed upgrade falls
back to. Older versions are deleted once a newer one is confirmed running,
either right after the install that superseded them or at the next backend
start. The retained count is fixed and not configurable.
It is a steady state rather than a hard cap, because the cleanup is
conservative and never insists. Kandev deletes a version directory only after
confirming that the plugin's process is up, so a plugin that is disabled,
errored, or was never started keeps every version it has, and a deletion that
fails leaves that version in place until the next attempt. It never
touches `data/`, a directory whose name does not match the version declared by
the `manifest.yaml` inside it, or anything outside `~/.kandev/plugins/<id>/`.
Because an upgrade previously left its predecessor behind forever, an instance
that has been auto-updating for a long time can reclaim a substantial amount of
disk on its first restart after upgrading to this version.
## Security posture
- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess