feat(pluginhost): enhance plugin version management and logging for hot reload

This commit is contained in:
hkfires
2026-06-26 12:05:07 +08:00
parent 65f2288a4a
commit 6a59d645b5
13 changed files with 635 additions and 31 deletions

View File

@@ -254,42 +254,70 @@ func deletePluginArtifact(root string, id string, pluginRuntime PluginRuntime) (
if !validPluginFileID(id) {
return "", false, fmt.Errorf("invalid plugin id %q", id)
}
path, errPath := currentPluginFilePath(root, id)
if errPath != nil {
return "", false, errPath
paths, errPaths := pluginFilePaths(root, id)
if errPaths != nil {
return "", false, errPaths
}
if path == "" {
if len(paths) == 0 {
return "", false, nil
}
if pluginRuntime != nil && pluginRuntime.PluginBusy(id) {
if !pluginRuntime.UnloadPlugin(id) && pluginRuntime.PluginBusy(id) {
return path, false, sdkpluginstore.ErrLoadedPluginLocked
return paths[0], false, sdkpluginstore.ErrLoadedPluginLocked
}
}
if errRemove := os.Remove(path); errRemove != nil {
if errors.Is(errRemove, os.ErrNotExist) {
return path, false, nil
deleted := false
for _, path := range paths {
if errRemove := os.Remove(path); errRemove != nil {
if errors.Is(errRemove, os.ErrNotExist) {
continue
}
return paths[0], deleted, errRemove
}
return path, false, errRemove
deleted = true
}
return path, true, nil
return paths[0], deleted, nil
}
func currentPluginFilePath(root string, id string) (string, error) {
paths, errPaths := pluginFilePaths(root, id)
if errPaths != nil {
return "", errPaths
}
if len(paths) == 0 {
return "", nil
}
return paths[0], nil
}
func pluginFilePaths(root string, id string) ([]string, error) {
files, errFiles := pluginFileInfos(root, id)
if errFiles != nil {
return nil, errFiles
}
out := make([]string, 0, len(files))
for _, file := range files {
out = append(out, file.Path)
}
return out, nil
}
func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
id = strings.TrimSpace(id)
platform := CurrentPlatform()
extension := pluginExtension(platform.GOOS)
var selected pluginFileInfo
candidates := make([]pluginFileInfo, 0)
for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH, platform.Variant) {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if errors.Is(errReadDir, os.ErrNotExist) {
continue
}
return "", errReadDir
return nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
@@ -306,12 +334,30 @@ func currentPluginFilePath(root string, id string) (string, error) {
if !okFile || file.ID != id {
continue
}
if pluginFilePreferred(file, selected) {
selected = file
}
candidates = append(candidates, file)
}
}
return selected.Path, nil
if len(candidates) <= 1 {
return candidates, nil
}
bestIndex := 0
for index := 1; index < len(candidates); index++ {
if pluginFilePreferred(candidates[index], candidates[bestIndex]) {
bestIndex = index
}
}
if bestIndex == 0 {
return candidates, nil
}
out := make([]pluginFileInfo, 0, len(candidates))
out = append(out, candidates[bestIndex])
for index, candidate := range candidates {
if index == bestIndex {
continue
}
out = append(out, candidate)
}
return out, nil
}
type pluginFileInfo struct {

View File

@@ -62,7 +62,7 @@ func TestSyncPlatformInstallsManifestArtifact(t *testing.T) {
if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil {
t.Fatalf("SyncPlatform() error = %v", errSync)
}
target := filepath.Join(root, "windows", "amd64", "sample.dll")
target := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0")
got, errRead := os.ReadFile(target)
if errRead != nil {
t.Fatalf("read target: %v", errRead)
@@ -105,7 +105,7 @@ func TestSyncPlatformWithReportRecordsSuccessfulInstall(t *testing.T) {
if plugin.ID != "sample" || plugin.InstallStatus != pluginInstallStatusInstalled || plugin.Version != "0.2.0" {
t.Fatalf("plugin report = %+v, want installed sample 0.2.0", plugin)
}
if wantPath := filepath.Join(root, "windows", "amd64", "sample.dll"); plugin.Path != wantPath {
if wantPath := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0"); plugin.Path != wantPath {
t.Fatalf("plugin path = %q, want %q", plugin.Path, wantPath)
}
}
@@ -116,7 +116,7 @@ func TestSyncPlatformWithReportRecordsSkippedIdenticalArtifact(t *testing.T) {
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
target := filepath.Join(targetDir, "sample.dll")
target := filepath.Join(targetDir, "sample-v0.2.0.dll")
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
@@ -159,7 +159,7 @@ func TestSyncPlatformSkipsIdenticalBusyPlugin(t *testing.T) {
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
target := filepath.Join(targetDir, "sample.dll")
target := filepath.Join(targetDir, "sample-v0.2.0.dll")
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
@@ -329,6 +329,43 @@ func TestDeleteWithReportRemovesCurrentPlatformPlugin(t *testing.T) {
}
}
func TestDeleteWithReportRemovesAllCurrentPlatformPluginVersions(t *testing.T) {
root := t.TempDir()
targetDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
extension := pluginExtension(runtime.GOOS)
olderTarget := filepath.Join(targetDir, "sample-v0.2.0"+extension)
newerTarget := filepath.Join(targetDir, "sample-v0.3.0"+extension)
otherTarget := filepath.Join(targetDir, "other-v0.3.0"+extension)
for _, target := range []string{olderTarget, newerTarget, otherTarget} {
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
t.Fatalf("WriteFile(%s) error = %v", target, errWrite)
}
}
runtimeHost := &fakePluginRuntime{busy: true}
report := DeleteWithReport(context.Background(), syncTestConfig(t, root), runtimeHost, 43, "sample")
if !report.OK {
t.Fatalf("report = %+v, want successful delete task", report)
}
if len(runtimeHost.unloaded) != 1 || runtimeHost.unloaded[0] != "sample" {
t.Fatalf("UnloadPlugin calls = %v, want sample", runtimeHost.unloaded)
}
if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusDeleted || report.Plugins[0].Path != newerTarget {
t.Fatalf("plugin report = %+v, want deleted representative target %s", report.Plugins, newerTarget)
}
for _, target := range []string{olderTarget, newerTarget} {
if _, errStat := os.Stat(target); !os.IsNotExist(errStat) {
t.Fatalf("target %s stat error = %v, want not exist", target, errStat)
}
}
if _, errStat := os.Stat(otherTarget); errStat != nil {
t.Fatalf("other plugin stat error = %v, want retained", errStat)
}
}
func TestDeleteWithReportMissingPluginIsSuccess(t *testing.T) {
report := DeleteWithReport(context.Background(), syncTestConfig(t, t.TempDir()), nil, 7, "missing")
if !report.OK || report.Status != pluginTaskStatusOK {
@@ -363,6 +400,15 @@ store:
}
}
func pluginTestPath(root string, goos string, goarch string, id string, version string) string {
name := strings.TrimSpace(id)
version = strings.TrimSpace(version)
if version != "" {
name += "-v" + version
}
return filepath.Join(root, goos, goarch, name+pluginExtension(goos))
}
func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig {
t.Helper()
var item config.PluginInstanceConfig

View File

@@ -33,10 +33,12 @@ type LogFormatter struct{}
var logFieldOrder = []string{
"provider", "model",
"plugin_id", "plugin_name", "source_id",
"version", "overwritten",
"version", "active_version", "retired_version", "overwritten",
"mode", "budget", "level", "original_mode", "original_value", "min", "max", "clamped_to", "error",
}
var pluginPathFieldOrder = []string{"path", "active_path", "retired_path"}
// Format renders a single log entry with custom formatting.
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
var buffer *bytes.Buffer
@@ -70,8 +72,10 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
}
}
if pluginID, ok := entry.Data["plugin_id"]; ok && strings.TrimSpace(fmt.Sprint(pluginID)) != "" {
if v, ok := entry.Data["path"]; ok {
fields = append(fields, fmt.Sprintf("path=%v", v))
for _, k := range pluginPathFieldOrder {
if v, ok := entry.Data[k]; ok {
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
}
}
}
if len(fields) > 0 {

View File

@@ -34,7 +34,11 @@ func TestLogFormatterPrintsPluginFields(t *testing.T) {
entry.Data["plugin_id"] = "sample-provider"
entry.Data["plugin_name"] = "Sample Provider"
entry.Data["version"] = "0.2.0"
entry.Data["active_version"] = "0.1.0"
entry.Data["retired_version"] = "0.2.0"
entry.Data["path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll"
entry.Data["active_path"] = "plugins/windows/amd64/sample-provider-v0.1.0.dll"
entry.Data["retired_path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll"
formatted, errFormat := (&LogFormatter{}).Format(entry)
if errFormat != nil {
@@ -46,7 +50,11 @@ func TestLogFormatterPrintsPluginFields(t *testing.T) {
"plugin_id=sample-provider",
"plugin_name=Sample Provider",
"version=0.2.0",
"active_version=0.1.0",
"retired_version=0.2.0",
"path=plugins/windows/amd64/sample-provider-v0.2.0.dll",
"active_path=plugins/windows/amd64/sample-provider-v0.1.0.dll",
"retired_path=plugins/windows/amd64/sample-provider-v0.2.0.dll",
} {
if !strings.Contains(line, want) {
t.Fatalf("formatted line %q missing %s", line, want)
@@ -60,6 +68,8 @@ func TestLogFormatterOmitsGenericPathField(t *testing.T) {
entry.Level = log.WarnLevel
entry.Message = "failed to roll back token"
entry.Data["path"] = "auths/private-token.json"
entry.Data["active_path"] = "plugins/windows/amd64/sample-provider-v0.1.0.dll"
entry.Data["retired_path"] = "plugins/windows/amd64/sample-provider-v0.2.0.dll"
formatted, errFormat := (&LogFormatter{}).Format(entry)
if errFormat != nil {
@@ -67,7 +77,9 @@ func TestLogFormatterOmitsGenericPathField(t *testing.T) {
}
line := string(formatted)
if strings.Contains(line, "path=") {
t.Fatalf("formatted line %q contains generic path field", line)
for _, forbidden := range []string{"path=", "active_path=", "retired_path="} {
if strings.Contains(line, forbidden) {
t.Fatalf("formatted line %q contains generic %s field", line, forbidden)
}
}
}

View File

@@ -22,6 +22,7 @@ type runtimeItemConfig struct {
ID string
Enabled bool
Priority int
Version string
ConfigYAML []byte
}
@@ -57,6 +58,7 @@ func runtimeConfigFromConfig(cfg *config.Config) runtimeConfig {
ID: id,
Enabled: enabled,
Priority: item.Priority,
Version: pluginConfigDesiredVersion(item),
ConfigYAML: runtimeConfigYAML(item, enabled),
}
}
@@ -81,6 +83,73 @@ func runtimeConfigYAML(item config.PluginInstanceConfig, enabled bool) []byte {
return append(append([]byte(nil), rawYAML...), '\n')
}
func desiredPluginVersions(items map[string]runtimeItemConfig) map[string]string {
if len(items) == 0 {
return nil
}
out := make(map[string]string, len(items))
for id, item := range items {
id = strings.TrimSpace(id)
version := strings.TrimSpace(item.Version)
if id == "" || version == "" {
continue
}
out[id] = version
}
if len(out) == 0 {
return nil
}
return out
}
func pluginConfigDesiredVersion(item config.PluginInstanceConfig) string {
storeNode := yamlMappingValue(&item.Raw, "store")
if storeNode == nil {
return ""
}
if version := normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "version"))); version != "" {
return version
}
return normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "release-tag")))
}
func normalizePluginDesiredVersion(version string) string {
version = strings.TrimSpace(version)
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
version = version[1:]
}
if !validPluginVersion(version) {
return ""
}
return version
}
func yamlScalarString(node *yaml.Node) string {
if node == nil || node.Kind == 0 {
return ""
}
if node.Kind == yaml.ScalarNode {
return strings.TrimSpace(node.Value)
}
var value string
if errDecode := node.Decode(&value); errDecode != nil {
return ""
}
return strings.TrimSpace(value)
}
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for index := 0; index+1 < len(node.Content); index += 2 {
if node.Content[index] != nil && node.Content[index].Value == key {
return node.Content[index+1]
}
}
return nil
}
func normalizedConfigNode(item config.PluginInstanceConfig, enabled bool) *yaml.Node {
if item.Raw.Kind == 0 {
return defaultRuntimeConfigNode(enabled, item.Priority)

View File

@@ -49,3 +49,51 @@ func TestRuntimeConfigYAMLDefaultsEnabledFalse(t *testing.T) {
}
}
}
func TestRuntimeConfigFromConfigExtractsStoreVersion(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("store:\n version: 1.0.3\n release-tag: v1.0.3\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
enabled := true
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Configs: map[string]config.PluginInstanceConfig{
"alpha": {
Enabled: &enabled,
Raw: *node.Content[0],
},
},
},
}
got := runtimeConfigFromConfig(cfg)
if got.Items["alpha"].Version != "1.0.3" {
t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version)
}
}
func TestRuntimeConfigFromConfigDerivesStoreVersionFromReleaseTag(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("store:\n release-tag: v1.0.3\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
enabled := true
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Configs: map[string]config.PluginInstanceConfig{
"alpha": {
Enabled: &enabled,
Raw: *node.Content[0],
},
},
},
}
got := runtimeConfigFromConfig(cfg)
if got.Items["alpha"].Version != "1.0.3" {
t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -201,7 +202,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
return
}
files, errSelect := selectPluginFiles(rc.Dir)
desiredVersions := desiredPluginVersions(rc.Items)
files, errSelect := selectPluginFiles(rc.Dir, desiredVersions)
if errSelect != nil {
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
h.mu.Lock()
@@ -213,9 +215,11 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.refreshThinkingProviders(nil)
return
}
files = h.withLoadedPluginFallbacks(files, rc.Items, desiredVersions)
records := make([]capabilityRecord, 0, len(files))
loadedFiles := make([]pluginFile, 0, len(files))
hotReloadLogs := make([]log.Fields, 0)
for _, file := range files {
item, ok := rc.Items[file.ID]
if !ok {
@@ -238,6 +242,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
}
loadedNow := false
var hotReloadFields log.Fields
if lp == nil {
h.mu.Lock()
h.loading[file.ID] = struct{}{}
@@ -255,6 +260,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
// so a nil read cannot race into a duplicate load.
lp = loaded
if replaced != nil {
hotReloadFields = pluginHotReloadLogFields(file.ID, file.Version, file.Path, replaced.version, replaced.path)
h.retireLoadedPluginLocked(replaced)
delete(h.fused, file.ID)
h.removePluginRuntimeStateLocked(file.ID)
@@ -281,6 +287,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if loadedNow {
log.WithFields(pluginLogFieldsFromMetadata(file.ID, plugin.Metadata, file.Path)).Info("pluginhost: plugin registered")
}
if hotReloadFields != nil {
hotReloadLogs = append(hotReloadLogs, hotReloadFields)
}
records = append(records, capabilityRecord{
id: file.ID,
path: file.Path,
@@ -302,6 +311,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.snapshot.Store(&Snapshot{enabled: true, records: records})
h.mu.Unlock()
h.refreshThinkingProviders(records)
for _, fields := range hotReloadLogs {
log.WithFields(fields).Info("pluginhost: plugin hot reloaded")
}
if cleanupFiles && len(loadedFiles) > 0 {
if errCleanup := cleanupUnselectedPluginFiles(rc.Dir, loadedFiles); errCleanup != nil {
log.Warnf("pluginhost: failed to clean old plugin files: %v", errCleanup)
@@ -323,6 +335,46 @@ func (h *Host) load(file pluginFile) (*loadedPlugin, error) {
}, nil
}
func (h *Host) withLoadedPluginFallbacks(files []pluginFile, items map[string]runtimeItemConfig, desired map[string]string) []pluginFile {
if h == nil || len(desired) == 0 {
return files
}
selected := make(map[string]struct{}, len(files))
for _, file := range files {
id := strings.TrimSpace(file.ID)
if id != "" {
selected[id] = struct{}{}
}
}
ids := make([]string, 0, len(desired))
for id := range desired {
ids = append(ids, id)
}
sort.Strings(ids)
h.mu.Lock()
defer h.mu.Unlock()
for _, id := range ids {
if _, ok := selected[id]; ok {
continue
}
if item, ok := items[id]; ok && !item.Enabled {
continue
}
lp := h.loaded[id]
if lp == nil || strings.TrimSpace(lp.path) == "" {
continue
}
files = append(files, pluginFile{
ID: id,
Path: lp.path,
Version: strings.TrimSpace(lp.version),
})
selected[id] = struct{}{}
}
return files
}
// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
func (h *Host) UnloadPlugin(id string) bool {
if h == nil {

View File

@@ -607,6 +607,144 @@ func TestHostApplyConfigLogsLoadedAndRegisteredOnlyOnInitialLoad(t *testing.T) {
}
}
func TestHostApplyConfigLogsHotReloadActiveAndRetiredVersions(t *testing.T) {
var out bytes.Buffer
originalOut := log.StandardLogger().Out
originalFormatter := log.StandardLogger().Formatter
originalLevel := log.GetLevel()
log.SetOutput(&out)
log.SetFormatter(&log.TextFormatter{
DisableColors: true,
DisableTimestamp: true,
})
log.SetLevel(log.InfoLevel)
t.Cleanup(func() {
log.SetOutput(originalOut)
log.SetFormatter(originalFormatter)
log.SetLevel(originalLevel)
})
loader := newTestSymbolLoader()
loader.lookups["alpha"] = newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin("alpha"),
})
h := NewForTest(loader)
t.Cleanup(h.ShutdownAll)
pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.4")
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"alpha": enabledPluginConfigWithStoreVersion(t, "1.0.4"),
},
},
})
paths["1.0.3"] = writeVersionedPluginFile(t, pluginsDir, "alpha", "1.0.3")
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"alpha": enabledPluginConfigWithStoreVersion(t, "1.0.3"),
},
},
})
if !h.pluginIdentityCurrent("alpha", paths["1.0.3"], "1.0.3") {
t.Fatalf("active plugin identity did not switch to %s", paths["1.0.3"])
}
if h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") {
t.Fatalf("old plugin identity is still active: %s", paths["1.0.4"])
}
logs := out.String()
if count := strings.Count(logs, `msg="pluginhost: plugin hot reloaded"`); count != 1 {
t.Fatalf("plugin hot reloaded log count = %d, want 1\n%s", count, logs)
}
for _, want := range []string{
"plugin_id=alpha",
"active_version=1.0.3",
"retired_version=1.0.4",
"active_path=",
"retired_path=",
"alpha-v1.0.3",
"alpha-v1.0.4",
} {
if !strings.Contains(logs, want) {
t.Fatalf("plugin hot reload log missing %s:\n%s", want, logs)
}
}
}
func TestHostApplyConfigKeepsLoadedVersionWhenPinnedVersionMissing(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
t.Cleanup(h.ShutdownAll)
pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.4")
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"alpha": enabledPluginConfigWithStoreVersion(t, "1.0.4"),
},
},
})
if !h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") {
t.Fatalf("active plugin identity did not start at %s", paths["1.0.4"])
}
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"alpha": enabledPluginConfigWithStoreVersion(t, "1.0.5"),
},
},
})
if !h.PluginRegistered("alpha") {
t.Fatal("PluginRegistered(alpha) = false, want old version to remain active while pinned version is missing")
}
if !h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") {
t.Fatalf("active plugin identity changed before pinned version was available")
}
if loader.openCalls != 1 {
t.Fatalf("Open calls = %d, want 1 while reusing loaded plugin", loader.openCalls)
}
if plugin.registerCalls != 1 || plugin.reconfigureCalls != 1 {
t.Fatalf("calls = register %d reconfigure %d, want 1/1", plugin.registerCalls, plugin.reconfigureCalls)
}
paths["1.0.5"] = writeVersionedPluginFile(t, pluginsDir, "alpha", "1.0.5")
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"alpha": enabledPluginConfigWithStoreVersion(t, "1.0.5"),
},
},
})
if !h.pluginIdentityCurrent("alpha", paths["1.0.5"], "1.0.5") {
t.Fatalf("active plugin identity did not switch after pinned version was available")
}
if h.pluginIdentityCurrent("alpha", paths["1.0.4"], "1.0.4") {
t.Fatal("old plugin identity is still active after pinned version became available")
}
if loader.openCalls != 2 {
t.Fatalf("Open calls = %d, want 2 after loading pinned version", loader.openCalls)
}
}
func TestHostApplyConfigLogsLoadedWhenRegistrationInvalid(t *testing.T) {
var out bytes.Buffer
originalOut := log.StandardLogger().Out

View File

@@ -26,3 +26,22 @@ func pluginLogFields(id, name, version, path string) log.Fields {
func pluginLogFieldsFromMetadata(id string, meta pluginapi.Metadata, path string) log.Fields {
return pluginLogFields(id, meta.Name, meta.Version, path)
}
func pluginHotReloadLogFields(id, activeVersion, activePath, retiredVersion, retiredPath string) log.Fields {
fields := log.Fields{
"plugin_id": strings.TrimSpace(id),
}
if activeVersion = strings.TrimSpace(activeVersion); activeVersion != "" {
fields["active_version"] = activeVersion
}
if activePath = strings.TrimSpace(activePath); activePath != "" {
fields["active_path"] = activePath
}
if retiredVersion = strings.TrimSpace(retiredVersion); retiredVersion != "" {
fields["retired_version"] = retiredVersion
}
if retiredPath = strings.TrimSpace(retiredPath); retiredPath != "" {
fields["retired_path"] = retiredPath
}
return fields
}

View File

@@ -32,3 +32,25 @@ func TestPluginLogFieldsOmitsEmptyName(t *testing.T) {
t.Fatalf("plugin_name = %v, want omitted", fields["plugin_name"])
}
}
func TestPluginHotReloadLogFieldsIncludesActiveAndRetiredIdentity(t *testing.T) {
fields := pluginHotReloadLogFields(
"sample",
"0.1.0",
"/tmp/plugins/sample-v0.1.0.dll",
"0.2.0",
"/tmp/plugins/sample-v0.2.0.dll",
)
for key, want := range map[string]string{
"plugin_id": "sample",
"active_version": "0.1.0",
"active_path": "/tmp/plugins/sample-v0.1.0.dll",
"retired_version": "0.2.0",
"retired_path": "/tmp/plugins/sample-v0.2.0.dll",
} {
if fields[key] != want {
t.Fatalf("%s = %v, want %s", key, fields[key], want)
}
}
}

View File

@@ -112,16 +112,17 @@ func pluginExtension(goos string) string {
}
}
func selectPluginFiles(root string) ([]pluginFile, error) {
selected, _, errSelect := selectPluginFilesWithCandidates(root)
func selectPluginFiles(root string, desiredVersions ...map[string]string) ([]pluginFile, error) {
selected, _, errSelect := selectPluginFilesWithCandidates(root, desiredVersions...)
return selected, errSelect
}
func selectPluginFilesWithCandidates(root string) ([]pluginFile, []pluginFile, error) {
func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]string) ([]pluginFile, []pluginFile, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
desired := normalizeDesiredPluginVersions(desiredVersions...)
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant())
extension := pluginExtension(runtime.GOOS)
@@ -158,18 +159,49 @@ func selectPluginFilesWithCandidates(root string) ([]pluginFile, []pluginFile, e
order = append(order, file.ID)
continue
}
if pluginFilePreferred(file, current) {
if pluginFilePreferredForDesired(file, current, desired[file.ID]) {
selectedByID[file.ID] = file
}
}
}
selected := make([]pluginFile, 0, len(order))
for _, id := range order {
selected = append(selected, selectedByID[id])
file := selectedByID[id]
if desiredVersion := desired[id]; desiredVersion != "" && file.Version != desiredVersion {
continue
}
selected = append(selected, file)
}
return selected, all, nil
}
func normalizeDesiredPluginVersions(sources ...map[string]string) map[string]string {
out := make(map[string]string)
for _, source := range sources {
for id, version := range source {
id = strings.TrimSpace(id)
version = normalizePluginDesiredVersion(version)
if id == "" || version == "" {
continue
}
out[id] = version
}
}
return out
}
func pluginFilePreferredForDesired(candidate pluginFile, current pluginFile, desiredVersion string) bool {
desiredVersion = normalizePluginDesiredVersion(desiredVersion)
if desiredVersion != "" {
candidateMatches := candidate.Version == desiredVersion
currentMatches := current.Version == desiredVersion
if candidateMatches != currentMatches {
return candidateMatches
}
}
return pluginFilePreferred(candidate, current)
}
func pluginFilePreferred(candidate pluginFile, current pluginFile) bool {
if candidate.Version == "" {
return false

View File

@@ -159,6 +159,84 @@ func TestDiscoverPluginFilesReturnsSelectedPluginFiles(t *testing.T) {
}
}
func TestSelectPluginFilesPrefersConfiguredVersionOverHigherVersion(t *testing.T) {
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
extension := pluginExtension(runtime.GOOS)
olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension)
newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension)
for _, path := range []string{olderPath, newerPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"})
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: olderPath, Version: "1.0.3"}) {
t.Fatalf("selectPluginFiles()[0] = %v, want configured plugin %s", files[0], olderPath)
}
}
func TestSelectPluginFilesFallsBackToHighestVersionWithoutConfiguredVersion(t *testing.T) {
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
extension := pluginExtension(runtime.GOOS)
olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension)
newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension)
for _, path := range []string{olderPath, newerPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: newerPath, Version: "1.0.4"}) {
t.Fatalf("selectPluginFiles()[0] = %v, want highest plugin %s", files[0], newerPath)
}
}
func TestSelectPluginFilesSkipsPluginWhenConfiguredVersionIsMissing(t *testing.T) {
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
extension := pluginExtension(runtime.GOOS)
path := filepath.Join(archDir, "alpha-v1.0.4"+extension)
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"})
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 0 {
t.Fatalf("selectPluginFiles() = %v, want no selected alpha plugin", files)
}
}
func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) {
variant := cpuVariant()
if variant == "" {

View File

@@ -9,8 +9,10 @@ import (
"runtime"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
type testSymbolLoader struct {
@@ -333,3 +335,39 @@ func makePluginDir(t *testing.T, ids ...string) string {
}
return root
}
func makeVersionedPluginDir(t *testing.T, id string, versions ...string) (string, map[string]string) {
t.Helper()
root := t.TempDir()
paths := make(map[string]string, len(versions))
for _, version := range versions {
paths[version] = writeVersionedPluginFile(t, root, id, version)
}
return root, paths
}
func writeVersionedPluginFile(t *testing.T, root, id, version string) string {
t.Helper()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
path := filepath.Join(archDir, fmt.Sprintf("%s-v%s%s", id, version, pluginExtension(runtime.GOOS)))
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
return path
}
func enabledPluginConfigWithStoreVersion(t *testing.T, version string) config.PluginInstanceConfig {
t.Helper()
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte(fmt.Sprintf("store:\n version: %s\n", version)), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
enabled := true
return config.PluginInstanceConfig{
Enabled: &enabled,
Raw: *node.Content[0],
}
}