feat(pluginstore, pluginhost): add plugin unload handling and preserve config during plugin updates

- Introduced logic to handle plugin unloading during updates to prevent conflicts with loaded plugins.
- Preserved existing plugin configurations during updates, ensuring seamless transitions and maintaining custom fields.
- Added support for reloading the configuration after management saves changes.
- Enhanced unit tests to validate unloading, configuration preservation, and reloading behaviors.
This commit is contained in:
Luis Pater
2026-06-13 00:33:21 +08:00
parent 049ced5c3f
commit 60f6a54282
10 changed files with 396 additions and 14 deletions
@@ -3,6 +3,7 @@
package management
import (
"context"
"crypto/subtle"
"fmt"
"net/http"
@@ -50,6 +51,7 @@ type Handler struct {
postAuthHook coreauth.PostAuthHook
postAuthPersistHook coreauth.PostAuthHook
pluginHost *pluginhost.Host
configReloadHook func(context.Context, *config.Config)
pluginStoreRegistryURL string
pluginStoreHTTPClient pluginstore.HTTPDoer
}
@@ -137,6 +139,33 @@ func (h *Handler) SetPluginHost(host *pluginhost.Host) {
h.mu.Unlock()
}
// SetConfigReloadHook updates the callback used after management saves config changes.
func (h *Handler) SetConfigReloadHook(hook func(context.Context, *config.Config)) {
if h == nil {
return
}
h.mu.Lock()
h.configReloadHook = hook
h.mu.Unlock()
}
func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfg *config.Config) {
if h == nil || cfg == nil {
return
}
h.mu.Lock()
hook := h.configReloadHook
host := h.pluginHost
h.mu.Unlock()
if hook != nil {
hook(ctx, cfg)
return
}
if host != nil {
host.ApplyConfig(ctx, cfg)
}
}
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
@@ -13,6 +13,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
log "github.com/sirupsen/logrus"
)
type pluginStoreListResponse struct {
@@ -131,17 +132,41 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
}
pluginIsLoaded := func() bool { return pluginLoaded(host, id) }
unloadedBeforeWrite := false
result, errInstall := client.Install(c.Request.Context(), plugin, pluginstore.InstallOptions{
PluginsDir: pluginsDir,
GOOS: goos,
GOARCH: goarch,
PluginLoaded: pluginIsLoaded,
BeforeWrite: func() error {
if !pluginIsLoaded() {
return nil
}
if host == nil {
return pluginstore.ErrLoadedPluginLocked
}
log.WithFields(log.Fields{
"plugin_id": id,
"version": plugin.Version,
}).Info("pluginstore: unloading loaded plugin before install")
if !host.UnloadPlugin(id) && pluginIsLoaded() {
return pluginstore.ErrLoadedPluginLocked
}
unloadedBeforeWrite = true
return nil
},
})
if errInstall != nil {
if unloadedBeforeWrite {
h.mu.Lock()
reloadCfg := h.cfg
h.mu.Unlock()
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
}
if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_update_requires_restart",
"message": "loaded Windows plugins cannot be overwritten while the server is running",
"message": "loaded plugin cannot be overwritten while the server is running",
"restart_required": true,
})
return
@@ -149,13 +174,11 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()})
return
}
// Sample after the install so the response reflects the library state at
// the time the new file landed on disk.
restartRequired := pluginIsLoaded()
restartRequired := false
h.mu.Lock()
defer h.mu.Unlock()
if h.cfg == nil {
h.mu.Unlock()
c.JSON(http.StatusInternalServerError, gin.H{
"error": "config_unavailable",
"message": fmt.Sprintf("plugin file installed at %s but config is unavailable to enable it", result.Path),
@@ -164,6 +187,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
return
}
if errEnable := h.enablePluginConfigLocked(id); errEnable != nil {
h.mu.Unlock()
c.JSON(http.StatusInternalServerError, gin.H{
"error": "config_update_failed",
"message": fmt.Sprintf("plugin file installed at %s but enabling it in config failed: %s", result.Path, errEnable.Error()),
@@ -172,6 +196,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
return
}
if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil {
h.mu.Unlock()
c.JSON(http.StatusInternalServerError, gin.H{
"error": "config_save_failed",
"message": fmt.Sprintf("plugin file installed at %s but saving config failed: %s", result.Path, errSave.Error()),
@@ -179,6 +204,16 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
})
return
}
reloadCfg := h.cfg
h.mu.Unlock()
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
log.WithFields(log.Fields{
"plugin_id": result.ID,
"version": result.Version,
"path": result.Path,
"overwritten": result.Overwritten,
}).Info("pluginstore: plugin installed")
c.JSON(http.StatusOK, pluginInstallResponse{
Status: "installed",
@@ -3,6 +3,7 @@ package management
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -156,6 +157,84 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
}
}
func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
pluginsDir := t.TempDir()
existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS))
if errWrite := os.WriteFile(existingPath, []byte("old-library-data"), 0o644); errWrite != nil {
t.Fatalf("WriteFile(%s) error = %v", existingPath, errWrite)
}
archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "new-library-data")
archiveName := "sample-provider_0.1.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip"
checksum := sha256.Sum256(archiveData)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigFromYAML(t, "enabled: false\npriority: 5\nmode: fast\nextra: keep\n"),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreRegistryURL: "https://registry.example/registry.json",
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
"https://registry.example/registry.json": registryJSON(t),
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{
"tag_name": "v0.1.0",
"assets": [
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
]
}`),
"https://downloads.example/" + archiveName: archiveData,
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
},
}
reloads := 0
h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) {
reloads++
if cfg != h.cfg {
t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg)
}
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
h.InstallPluginFromStore(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if reloads != 1 {
t.Fatalf("reloads = %d, want 1", reloads)
}
data, errRead := os.ReadFile(existingPath)
if errRead != nil {
t.Fatalf("ReadFile(%s) error = %v", existingPath, errRead)
}
if string(data) != "new-library-data" {
t.Fatalf("installed file = %q, want new-library-data", data)
}
item := h.cfg.Plugins.Configs["sample-provider"]
if item.Enabled == nil || !*item.Enabled {
t.Fatalf("plugin enabled = %#v, want true", item.Enabled)
}
if item.Priority != 5 {
t.Fatalf("plugin priority = %d, want 5", item.Priority)
}
raw := marshalPluginRaw(t, item)
if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "extra: keep") {
t.Fatalf("plugin raw config lost custom fields:\n%s", raw)
}
}
func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) {
t.Parallel()
+9
View File
@@ -62,6 +62,7 @@ type serverOptionConfig struct {
postAuthHook auth.PostAuthHook
postAuthPersistHook auth.PostAuthHook
pluginHost *pluginhost.Host
configReloadHook func(context.Context, *config.Config)
}
// ServerOption customises HTTP server construction.
@@ -154,6 +155,13 @@ func WithPluginHost(host *pluginhost.Host) ServerOption {
}
}
// WithConfigReloadHook registers a callback used after management saves config changes.
func WithConfigReloadHook(hook func(context.Context, *config.Config)) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.configReloadHook = hook
}
}
// Server represents the main API server.
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
type Server struct {
@@ -316,6 +324,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
s.mgmt.SetPluginHost(optionState.pluginHost)
s.mgmt.SetConfigReloadHook(optionState.configReloadHook)
if optionState.localPassword != "" {
s.mgmt.SetLocalPassword(optionState.localPassword)
}
+99 -4
View File
@@ -28,6 +28,12 @@ type modelExecutor interface {
ExecuteModelStream(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage)
}
type pluginUnloadTarget struct {
id string
path string
client pluginClient
}
type Host struct {
mu sync.Mutex
loader pluginLoader
@@ -180,6 +186,10 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
}
lp = loaded
h.loaded[file.ID] = lp
log.WithFields(log.Fields{
"plugin_id": file.ID,
"path": file.Path,
}).Info("pluginhost: plugin loaded")
}
plugin, okCall := h.callRegisterLocked(ctx, lp, item)
@@ -213,19 +223,60 @@ func (h *Host) loadLocked(file pluginFile) (*loadedPlugin, error) {
}, nil
}
// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
func (h *Host) UnloadPlugin(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
var target pluginUnloadTarget
h.mu.Lock()
lp := h.loaded[id]
if lp == nil {
h.mu.Unlock()
return false
}
target = pluginUnloadTarget{id: lp.id, path: lp.path, client: lp.client}
delete(h.loaded, id)
delete(h.fused, id)
records, enabled := h.snapshotWithoutPluginLocked(id)
h.removePluginRuntimeStateLocked(id)
h.snapshot.Store(&Snapshot{enabled: enabled, records: records})
h.mu.Unlock()
h.refreshThinkingProviders(records)
h.RegisterFrontendAuthProviders()
if target.client != nil {
target.client.Shutdown()
}
log.WithFields(log.Fields{
"plugin_id": target.id,
"path": target.path,
}).Info("pluginhost: plugin unloaded")
return true
}
// ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries.
func (h *Host) ShutdownAll() {
if h == nil {
return
}
clients := make([]pluginClient, 0)
targets := make([]pluginUnloadTarget, 0)
h.mu.Lock()
for _, lp := range h.loaded {
if lp == nil || lp.client == nil {
continue
}
clients = append(clients, lp.client)
targets = append(targets, pluginUnloadTarget{
id: lp.id,
path: lp.path,
client: lp.client,
})
}
h.loaded = make(map[string]*loadedPlugin)
h.modelClientIDs = make(map[string]struct{})
@@ -243,11 +294,55 @@ func (h *Host) ShutdownAll() {
h.refreshThinkingProviders(nil)
h.RegisterFrontendAuthProviders()
for _, client := range clients {
client.Shutdown()
for _, target := range targets {
target.client.Shutdown()
log.WithFields(log.Fields{
"plugin_id": target.id,
"path": target.path,
}).Info("pluginhost: plugin unloaded")
}
}
func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) {
raw := h.snapshot.Load()
snap, _ := raw.(*Snapshot)
if snap == nil || len(snap.records) == 0 {
return nil, snap != nil && snap.enabled
}
records := make([]capabilityRecord, 0, len(snap.records))
for _, record := range snap.records {
if record.id == id {
continue
}
records = append(records, record)
}
return records, snap.enabled
}
func (h *Host) removePluginRuntimeStateLocked(id string) {
for key, record := range h.managementRoutes {
if record.pluginID == id {
delete(h.managementRoutes, key)
}
}
for key, record := range h.resourceRoutes {
if record.pluginID == id {
delete(h.resourceRoutes, key)
}
}
for name, record := range h.commandLineFlags {
if record.pluginID == id {
delete(h.commandLineFlags, name)
delete(h.commandLineHits, name)
}
}
if registration, ok := h.modelRegistrations[id]; ok {
delete(h.providerModels, registration.provider)
}
delete(h.modelProviders, id)
delete(h.modelRegistrations, id)
}
func (h *Host) callRegisterLocked(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) {
if lp == nil {
return pluginapi.Plugin{}, false
+61
View File
@@ -113,6 +113,67 @@ func TestPluginLoadedTracksLoadedPluginAfterDisabled(t *testing.T) {
}
}
func TestHostUnloadPluginTargetsOnlyRequestedPlugin(t *testing.T) {
loader := newTestSymbolLoader()
alpha := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
bravo := &testPlugin{
registerResult: validTestPlugin("bravo"),
reconfigureResult: validTestPlugin("bravo"),
}
alphaLookup := newTestSymbolLookup(alpha)
bravoLookup := newTestSymbolLookup(bravo)
loader.lookups["alpha"] = alphaLookup
loader.lookups["bravo"] = bravoLookup
h := NewForTest(loader)
t.Cleanup(h.ShutdownAll)
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha", "bravo"),
},
}
h.ApplyConfig(context.Background(), cfg)
if !h.UnloadPlugin("alpha") {
t.Fatal("UnloadPlugin(alpha) = false, want true")
}
if h.PluginLoaded("alpha") {
t.Fatal("PluginLoaded(alpha) = true, want false after targeted unload")
}
if !h.PluginLoaded("bravo") {
t.Fatal("PluginLoaded(bravo) = false, want true after alpha unload")
}
if alphaLookup.shutdownCalls != 1 {
t.Fatalf("alpha shutdown calls = %d, want 1", alphaLookup.shutdownCalls)
}
if bravoLookup.shutdownCalls != 0 {
t.Fatalf("bravo shutdown calls = %d, want 0", bravoLookup.shutdownCalls)
}
plugins := h.RegisteredPlugins()
if len(plugins) != 1 || plugins[0].ID != "bravo" {
t.Fatalf("RegisteredPlugins() = %#v, want only bravo", plugins)
}
h.ApplyConfig(context.Background(), cfg)
if loader.openCalls != 3 {
t.Fatalf("Open calls = %d, want 3", loader.openCalls)
}
if alpha.registerCalls != 2 {
t.Fatalf("alpha register calls = %d, want 2", alpha.registerCalls)
}
if bravo.registerCalls != 1 {
t.Fatalf("bravo register calls = %d, want 1", bravo.registerCalls)
}
if bravo.reconfigureCalls != 1 {
t.Fatalf("bravo reconfigure calls = %d, want 1", bravo.reconfigureCalls)
}
}
func TestHostApplyConfigRegistersPluginThinkingApplier(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
+4 -1
View File
@@ -34,6 +34,7 @@ func (l *testSymbolLoader) Open(file pluginFile, host *Host) (pluginClient, erro
type testSymbolLookup struct {
plugin *testPlugin
active pluginapi.Plugin
shutdownCalls int
registerOverride func([]byte) pluginapi.Plugin
reconfigureOverride func([]byte) pluginapi.Plugin
}
@@ -148,7 +149,9 @@ func (l *testSymbolLookup) Call(ctx context.Context, method string, request []by
}
}
func (l *testSymbolLookup) Shutdown() {}
func (l *testSymbolLookup) Shutdown() {
l.shutdownCalls++
}
func (l *testSymbolLookup) callLifecycle(request []byte, reload bool) ([]byte, error) {
var req rpcLifecycleRequest
+22 -3
View File
@@ -22,9 +22,12 @@ type InstallOptions struct {
GOOS string
GOARCH string
// PluginLoaded reports whether the plugin's dynamic library is currently
// loaded by the running host. Loaded libraries cannot be overwritten on
// Windows, so installs targeting Windows are rejected while it returns true.
// loaded by the running host. Windows installs are rejected while it returns
// true unless BeforeWrite can unload the plugin before replacement.
PluginLoaded func() bool
// BeforeWrite runs after the archive has been downloaded and verified, but
// before the target plugin file is replaced.
BeforeWrite func() error
}
// ErrLoadedPluginLocked is returned when an install would overwrite a plugin
@@ -43,7 +46,7 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
return InstallResult{}, errValidate
}
options = normalizeInstallOptions(options)
if loadedPluginInstallBlocked(options) {
if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil {
return InstallResult{}, ErrLoadedPluginLocked
}
release, errRelease := c.FetchRelease(ctx, plugin)
@@ -100,6 +103,11 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (
}
// Re-check immediately before writing: the plugin may have been loaded
// while the archive was being downloaded and verified.
if options.BeforeWrite != nil {
if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil {
return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite)
}
}
if loadedPluginInstallBlocked(options) {
return InstallResult{}, ErrLoadedPluginLocked
}
@@ -250,6 +258,17 @@ func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error {
}
closed = true
if errRename := os.Rename(tempPath, targetPath); errRename != nil {
if runtime.GOOS == "windows" {
if errRemove := os.Remove(targetPath); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
return fmt.Errorf("remove old plugin file: %w", errRemove)
}
if errRenameRetry := os.Rename(tempPath, targetPath); errRenameRetry == nil {
removeTemp = false
return nil
} else {
return fmt.Errorf("install plugin file: %w", errRenameRetry)
}
}
return fmt.Errorf("install plugin file: %w", errRename)
}
removeTemp = false
+46
View File
@@ -65,6 +65,52 @@ func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) {
}
}
func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, "windows", "amd64")
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
targetPath := filepath.Join(targetDir, "sample-provider.dll")
if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
loaded := true
prepared := false
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "new",
}), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return loaded },
BeforeWrite: func() error {
prepared = true
loaded = false
return nil
},
})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if !prepared {
t.Fatal("BeforeWrite was not called")
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
data, errRead := os.ReadFile(targetPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "new" {
t.Fatalf("installed data = %q, want new", data)
}
}
func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
t.Parallel()
+7 -1
View File
@@ -286,7 +286,13 @@ func (b *Builder) Build() (*Service, error) {
if b.postAuthHook != nil {
service.serverOptions = append(service.serverOptions, api.WithPostAuthHook(b.postAuthHook))
}
service.serverOptions = append(service.serverOptions, api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()), api.WithPluginHost(pluginHost))
service.serverOptions = append(service.serverOptions,
api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()),
api.WithPluginHost(pluginHost),
api.WithConfigReloadHook(func(ctx context.Context, cfg *config.Config) {
service.applyConfigUpdate(cfg)
}),
)
return service, nil
}