Skip to content

Commit 45cf66c

Browse files
authored
fix(plugin): keep active plugin on reload failure (#680)
* fix(plugin): keep active plugin on reload failure * test(plugin): cover safe reload branches Reject nil reload adapters and expand manager reload/load tests for PR #680 patch coverage.
1 parent 522748f commit 45cf66c

4 files changed

Lines changed: 287 additions & 10 deletions

File tree

docs/PLUGIN_DEVELOPMENT_GUIDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,13 @@ Gracefully stops the plugin subprocess and removes its types from the engine. An
654654
POST /api/v1/plugins/external/{name}/reload
655655
```
656656

657-
Equivalent to unload followed by load. Useful after updating the plugin binary on disk.
657+
Starts the replacement plugin subprocess, performs the handshake and contract
658+
validation, then swaps it into the active slot. If the candidate fails to load,
659+
the previously active plugin process stays registered and running.
660+
661+
This API is a local activation primitive only: it consumes the plugin binary and
662+
manifest already staged on disk. Package trust, artifact download, signature
663+
verification, and fleet rollout policy belong to the caller or update manager.
658664

659665
**Response (success):**
660666
```json

plugin/external/handler.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ func (h *PluginHandler) handleUnload(w http.ResponseWriter, r *http.Request) {
117117
writeOK(w, map[string]string{"name": name, "action": "unloaded"})
118118
}
119119

120-
// handleReload reloads an external plugin by name (unload + load).
120+
// handleReload reloads an external plugin by name without unloading the active
121+
// process until the replacement has loaded successfully.
121122
func (h *PluginHandler) handleReload(w http.ResponseWriter, r *http.Request) {
122123
name := r.PathValue("name")
123124
if name == "" {

plugin/external/manager.go

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ type ExternalPluginManager struct {
2323
clients map[string]*goplugin.Client
2424

2525
callbackServer *CallbackServer
26+
27+
startPlugin func(name string) (*pluginLaunch, error)
28+
}
29+
30+
type pluginLaunch struct {
31+
client *goplugin.Client
32+
adapter *ExternalPluginAdapter
2633
}
2734

2835
// NewExternalPluginManager creates a new manager that scans the given directory for plugins.
@@ -91,6 +98,25 @@ func (m *ExternalPluginManager) LoadPlugin(name string) (*ExternalPluginAdapter,
9198
return nil, fmt.Errorf("plugin %q is already loaded", name)
9299
}
93100

101+
launch, err := m.startPluginLocked(name)
102+
if err != nil {
103+
return nil, err
104+
}
105+
if err := validatePluginLaunch(name, launch); err != nil {
106+
return nil, err
107+
}
108+
109+
m.clients[name] = launch.client
110+
m.logger.Printf("plugin %q loaded successfully", name)
111+
112+
return launch.adapter, nil
113+
}
114+
115+
func (m *ExternalPluginManager) startPluginLocked(name string) (*pluginLaunch, error) {
116+
if m.startPlugin != nil {
117+
return m.startPlugin(name)
118+
}
119+
94120
pluginDir := filepath.Join(m.pluginsDir, name)
95121
manifestPath := filepath.Join(pluginDir, "plugin.json")
96122
// Resolve the binary path to absolute. os/exec.Cmd.Start evaluates a
@@ -172,10 +198,7 @@ func (m *ExternalPluginManager) LoadPlugin(name string) (*ExternalPluginAdapter,
172198
return nil, fmt.Errorf("create adapter for plugin %q: %w", name, err)
173199
}
174200

175-
m.clients[name] = client
176-
m.logger.Printf("plugin %q loaded successfully", name)
177-
178-
return adapter, nil
201+
return &pluginLaunch{client: client, adapter: adapter}, nil
179202
}
180203

181204
// UnloadPlugin stops the named plugin subprocess and removes it from the internal map.
@@ -196,12 +219,53 @@ func (m *ExternalPluginManager) UnloadPlugin(name string) error {
196219
return nil
197220
}
198221

199-
// ReloadPlugin unloads and then loads the named plugin.
222+
// ReloadPlugin starts and validates the replacement before stopping the
223+
// currently loaded plugin. Candidate failure leaves the old process registered.
200224
func (m *ExternalPluginManager) ReloadPlugin(name string) (*ExternalPluginAdapter, error) {
201-
// Unload first (ignore error if not loaded)
202-
_ = m.UnloadPlugin(name)
225+
m.mu.Lock()
226+
defer m.mu.Unlock()
227+
228+
oldClient, wasLoaded := m.clients[name]
229+
if !wasLoaded {
230+
launch, err := m.startPluginLocked(name)
231+
if err != nil {
232+
return nil, err
233+
}
234+
if err := validatePluginLaunch(name, launch); err != nil {
235+
return nil, err
236+
}
237+
m.clients[name] = launch.client
238+
m.logger.Printf("plugin %q loaded successfully", name)
239+
return launch.adapter, nil
240+
}
203241

204-
return m.LoadPlugin(name)
242+
launch, err := m.startPluginLocked(name)
243+
if err != nil {
244+
m.logger.Printf("plugin %q reload failed; keeping existing plugin active: %v", name, err)
245+
return nil, fmt.Errorf("reload plugin %q: %w", name, err)
246+
}
247+
if err := validatePluginLaunch(name, launch); err != nil {
248+
m.logger.Printf("plugin %q reload failed; keeping existing plugin active: %v", name, err)
249+
return nil, fmt.Errorf("reload plugin %q: %w", name, err)
250+
}
251+
252+
m.clients[name] = launch.client
253+
oldClient.Kill()
254+
m.logger.Printf("plugin %q reloaded successfully", name)
255+
return launch.adapter, nil
256+
}
257+
258+
func validatePluginLaunch(name string, launch *pluginLaunch) error {
259+
if launch == nil {
260+
return fmt.Errorf("plugin %q launch returned nil result", name)
261+
}
262+
if launch.client == nil {
263+
return fmt.Errorf("plugin %q launch returned nil client", name)
264+
}
265+
if launch.adapter == nil {
266+
return fmt.Errorf("plugin %q launch returned nil adapter", name)
267+
}
268+
return nil
205269
}
206270

207271
// LoadedPlugins returns the names of all currently loaded plugins.

plugin/external/manager_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package external
22

33
import (
4+
"errors"
45
"log"
6+
"os"
7+
"path/filepath"
8+
"strings"
59
"testing"
10+
11+
goplugin "github.com/GoCodeAlone/go-plugin"
612
)
713

814
func TestExternalPluginManagerStoresCallbackServer(t *testing.T) {
@@ -15,3 +21,203 @@ func TestExternalPluginManagerStoresCallbackServer(t *testing.T) {
1521
t.Fatal("expected manager to retain callback server for plugin load")
1622
}
1723
}
24+
25+
func TestExternalPluginManagerReloadFailureKeepsExistingPluginLoaded(t *testing.T) {
26+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
27+
oldClient := &goplugin.Client{}
28+
manager.clients["safe-plugin"] = oldClient
29+
manager.startPlugin = func(string) (*pluginLaunch, error) {
30+
return nil, errors.New("candidate handshake failed")
31+
}
32+
33+
if _, err := manager.ReloadPlugin("safe-plugin"); err == nil {
34+
t.Fatal("expected reload failure")
35+
}
36+
37+
got := manager.clients["safe-plugin"]
38+
if got != oldClient {
39+
t.Fatal("reload failure replaced or removed the active plugin client")
40+
}
41+
if !manager.IsLoaded("safe-plugin") {
42+
t.Fatal("reload failure should leave active plugin loaded")
43+
}
44+
}
45+
46+
func TestExternalPluginManagerLoadPluginStoresCandidateAfterValidation(t *testing.T) {
47+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
48+
candidate := &goplugin.Client{}
49+
adapter := &ExternalPluginAdapter{}
50+
manager.startPlugin = func(name string) (*pluginLaunch, error) {
51+
if name != "safe-plugin" {
52+
t.Fatalf("unexpected plugin name %q", name)
53+
}
54+
return &pluginLaunch{client: candidate, adapter: adapter}, nil
55+
}
56+
57+
got, err := manager.LoadPlugin("safe-plugin")
58+
if err != nil {
59+
t.Fatalf("load: %v", err)
60+
}
61+
62+
if got != adapter {
63+
t.Fatal("load did not return candidate adapter")
64+
}
65+
if manager.clients["safe-plugin"] != candidate {
66+
t.Fatal("load did not register candidate client")
67+
}
68+
if _, err := manager.LoadPlugin("safe-plugin"); err == nil {
69+
t.Fatal("duplicate load should fail")
70+
}
71+
}
72+
73+
func TestExternalPluginManagerLoadPluginRejectsInvalidCandidate(t *testing.T) {
74+
for name, launch := range map[string]*pluginLaunch{
75+
"nil-launch": nil,
76+
"nil-client": {adapter: &ExternalPluginAdapter{}},
77+
"nil-adapter": {client: &goplugin.Client{}},
78+
} {
79+
t.Run(name, func(t *testing.T) {
80+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
81+
manager.startPlugin = func(string) (*pluginLaunch, error) {
82+
return launch, nil
83+
}
84+
85+
if _, err := manager.LoadPlugin("safe-plugin"); err == nil {
86+
t.Fatal("expected invalid candidate error")
87+
}
88+
if manager.IsLoaded("safe-plugin") {
89+
t.Fatal("invalid candidate should not be registered")
90+
}
91+
})
92+
}
93+
}
94+
95+
func TestExternalPluginManagerLoadPluginReturnsStartError(t *testing.T) {
96+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
97+
manager.startPlugin = func(string) (*pluginLaunch, error) {
98+
return nil, errors.New("candidate start failed")
99+
}
100+
101+
if _, err := manager.LoadPlugin("safe-plugin"); err == nil || !strings.Contains(err.Error(), "candidate start failed") {
102+
t.Fatalf("expected start error, got %v", err)
103+
}
104+
if manager.IsLoaded("safe-plugin") {
105+
t.Fatal("start failure should not register plugin")
106+
}
107+
}
108+
109+
func TestExternalPluginManagerLoadPluginValidatesDiskCandidateBeforeStart(t *testing.T) {
110+
pluginsDir := t.TempDir()
111+
pluginDir := filepath.Join(pluginsDir, "safe-plugin")
112+
if err := os.MkdirAll(filepath.Join(pluginDir, "safe-plugin"), 0o755); err != nil {
113+
t.Fatalf("create fake binary directory: %v", err)
114+
}
115+
manifest := []byte(`{
116+
"name": "safe-plugin",
117+
"version": "1.0.0",
118+
"author": "test",
119+
"description": "test plugin"
120+
}`)
121+
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o644); err != nil {
122+
t.Fatalf("write manifest: %v", err)
123+
}
124+
manager := NewExternalPluginManager(pluginsDir, log.Default())
125+
126+
if _, err := manager.LoadPlugin("safe-plugin"); err == nil || !strings.Contains(err.Error(), "binary path is a directory") {
127+
t.Fatalf("expected directory binary validation error, got %v", err)
128+
}
129+
if manager.IsLoaded("safe-plugin") {
130+
t.Fatal("disk validation failure should not register plugin")
131+
}
132+
}
133+
134+
func TestExternalPluginManagerReloadWithoutActivePluginLoadsCandidate(t *testing.T) {
135+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
136+
candidate := &goplugin.Client{}
137+
adapter := &ExternalPluginAdapter{}
138+
manager.startPlugin = func(string) (*pluginLaunch, error) {
139+
return &pluginLaunch{client: candidate, adapter: adapter}, nil
140+
}
141+
142+
got, err := manager.ReloadPlugin("safe-plugin")
143+
if err != nil {
144+
t.Fatalf("reload: %v", err)
145+
}
146+
147+
if got != adapter {
148+
t.Fatal("reload did not return candidate adapter")
149+
}
150+
if manager.clients["safe-plugin"] != candidate {
151+
t.Fatal("reload without active plugin did not register candidate client")
152+
}
153+
}
154+
155+
func TestExternalPluginManagerReloadWithoutActivePluginRejectsInvalidCandidate(t *testing.T) {
156+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
157+
manager.startPlugin = func(string) (*pluginLaunch, error) {
158+
return &pluginLaunch{client: &goplugin.Client{}}, nil
159+
}
160+
161+
if _, err := manager.ReloadPlugin("safe-plugin"); err == nil {
162+
t.Fatal("expected invalid candidate error")
163+
}
164+
if manager.IsLoaded("safe-plugin") {
165+
t.Fatal("invalid reload candidate should not be registered")
166+
}
167+
}
168+
169+
func TestExternalPluginManagerReloadWithoutActivePluginReturnsStartError(t *testing.T) {
170+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
171+
manager.startPlugin = func(string) (*pluginLaunch, error) {
172+
return nil, errors.New("candidate start failed")
173+
}
174+
175+
if _, err := manager.ReloadPlugin("safe-plugin"); err == nil || !strings.Contains(err.Error(), "candidate start failed") {
176+
t.Fatalf("expected start error, got %v", err)
177+
}
178+
if manager.IsLoaded("safe-plugin") {
179+
t.Fatal("start failure should not register plugin")
180+
}
181+
}
182+
183+
func TestExternalPluginManagerReloadSuccessSwapsAfterCandidateStarts(t *testing.T) {
184+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
185+
oldClient := &goplugin.Client{}
186+
newClient := &goplugin.Client{}
187+
adapter := &ExternalPluginAdapter{}
188+
manager.clients["safe-plugin"] = oldClient
189+
manager.startPlugin = func(string) (*pluginLaunch, error) {
190+
if manager.clients["safe-plugin"] != oldClient {
191+
t.Fatal("candidate started after old plugin was removed")
192+
}
193+
return &pluginLaunch{client: newClient, adapter: adapter}, nil
194+
}
195+
196+
got, err := manager.ReloadPlugin("safe-plugin")
197+
if err != nil {
198+
t.Fatalf("reload: %v", err)
199+
}
200+
201+
if got != adapter {
202+
t.Fatal("reload did not return candidate adapter")
203+
}
204+
if got := manager.clients["safe-plugin"]; got != newClient {
205+
t.Fatal("reload success did not register candidate plugin client")
206+
}
207+
}
208+
209+
func TestExternalPluginManagerReloadLoadedRejectsInvalidCandidate(t *testing.T) {
210+
manager := NewExternalPluginManager(t.TempDir(), log.Default())
211+
oldClient := &goplugin.Client{}
212+
manager.clients["safe-plugin"] = oldClient
213+
manager.startPlugin = func(string) (*pluginLaunch, error) {
214+
return &pluginLaunch{client: &goplugin.Client{}}, nil
215+
}
216+
217+
if _, err := manager.ReloadPlugin("safe-plugin"); err == nil {
218+
t.Fatal("expected invalid candidate error")
219+
}
220+
if manager.clients["safe-plugin"] != oldClient {
221+
t.Fatal("invalid reload candidate replaced active plugin")
222+
}
223+
}

0 commit comments

Comments
 (0)