mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Fix plugin RPC teardown race in Shutdown and Deactivate Environment.Shutdown() closed each plugin's RPC connection (via supervisor.Shutdown) before removing it from registeredPlugins, leaving a window where IsActive still returned true for a plugin whose transport was already dead. Any concurrent RunMultiPluginHook* call in that window would dispatch to the dead connection and log a spurious "connection is shut down" error. The fix: set PluginStateNotRunning immediately before supervisor.Shutdown on every teardown path, so IsActive returns false before any dispatcher can reach the closed transport. The state remains Running during OnDeactivate so a plugin can still call back into the server (e.g. CreatePost) and dispatch hooks to itself while its connection is live. * fixup! Fix plugin RPC teardown race in Shutdown and Deactivate * fixup! Fix plugin RPC teardown race in Shutdown and Deactivate * Skip OnDeactivate teardown for already-inactive plugins deactivateAndTeardown ran OnDeactivate and closed the RPC connection guarding only on a nil supervisor. Shutdown calls it for every registered plugin, so a plugin already deactivated (state NotRunning, but still registered with a shut-down supervisor) had OnDeactivate dispatched over its dead transport, logging a spurious 'connection is shut down' error. Fold the IsActive check into deactivateAndTeardown so both Shutdown and Deactivate skip OnDeactivate and supervisor.Shutdown when the plugin isn't running, only reconciling state to NotRunning. * document and test intentional transition to PluginStatusNotRunning
279 lines
8.8 KiB
Go
279 lines
8.8 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin/utils"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
)
|
|
|
|
// TestPluginMarksNotRunningAfterOnDeactivate verifies the state transitions during plugin
|
|
// teardown, for both Shutdown and Deactivate. While a plugin's OnDeactivate is still running,
|
|
// IsActive must return true so that hook dispatches the plugin makes to itself (e.g. CreatePost)
|
|
// are not rejected. Once OnDeactivate completes, IsActive must return false before the RPC
|
|
// connection is torn down.
|
|
func TestPluginMarksNotRunningAfterOnDeactivate(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
teardown func(env *Environment, pluginID string)
|
|
}{
|
|
{
|
|
name: "Shutdown",
|
|
teardown: func(env *Environment, _ string) {
|
|
env.Shutdown()
|
|
},
|
|
},
|
|
{
|
|
name: "Deactivate",
|
|
teardown: func(env *Environment, pluginID string) {
|
|
env.Deactivate(pluginID)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pluginDir, err := os.MkdirTemp("", "mm-shutdown-state-plugin")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(pluginDir) })
|
|
webappPluginDir, err := os.MkdirTemp("", "mm-shutdown-state-webapp")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
|
|
|
|
pluginID := "test-shutdown-state-plugin"
|
|
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
|
|
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
|
|
|
|
// OnDeactivate blocks until the test signals it via MessageWillBePosted.
|
|
utils.CompileGo(t, `
|
|
package main
|
|
|
|
import (
|
|
"sync"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin"
|
|
)
|
|
|
|
type MyPlugin struct {
|
|
plugin.MattermostPlugin
|
|
once sync.Once
|
|
proceed chan struct{}
|
|
}
|
|
|
|
func (p *MyPlugin) OnActivate() error {
|
|
p.proceed = make(chan struct{})
|
|
return nil
|
|
}
|
|
|
|
func (p *MyPlugin) OnDeactivate() error {
|
|
<-p.proceed
|
|
return nil
|
|
}
|
|
|
|
func (p *MyPlugin) MessageWillBePosted(_ *plugin.Context, _ *model.Post) (*model.Post, string) {
|
|
p.once.Do(func() { close(p.proceed) })
|
|
return nil, ""
|
|
}
|
|
|
|
func main() {
|
|
plugin.ClientMain(&MyPlugin{})
|
|
}
|
|
`, backend)
|
|
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(pluginDir, pluginID, "plugin.json"),
|
|
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
|
|
0600,
|
|
))
|
|
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
apiImpl := func(*model.Manifest) API { return nil }
|
|
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = env.Activate(pluginID)
|
|
require.NoError(t, err)
|
|
require.True(t, env.IsActive(pluginID))
|
|
|
|
teardownDone := make(chan struct{})
|
|
go func() {
|
|
defer close(teardownDone)
|
|
tc.teardown(env, pluginID)
|
|
}()
|
|
|
|
// Plugin is blocked in OnDeactivate — state must still be Running so a plugin
|
|
// dispatching hooks back to itself from OnDeactivate isn't rejected.
|
|
require.True(t, env.IsActive(pluginID), "IsActive should be true while OnDeactivate is blocking")
|
|
|
|
// Signal the plugin to complete OnDeactivate.
|
|
env.RunMultiPluginHook(func(hooks Hooks, _ *model.Manifest) bool {
|
|
hooks.MessageWillBePosted(&Context{}, &model.Post{})
|
|
return true
|
|
}, MessageWillBePostedID)
|
|
|
|
select {
|
|
case <-teardownDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("%s did not return", tc.name)
|
|
}
|
|
|
|
require.False(t, env.IsActive(pluginID))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestShutdownAfterDeactivateNoOnDeactivateRPC verifies that shutting down the environment after a
|
|
// plugin has already been deactivated does not dispatch a second OnDeactivate over the plugin's
|
|
// already-closed RPC connection. Previously Shutdown ran deactivateAndTeardown unconditionally for
|
|
// every registered plugin, dispatching OnDeactivate to inactive plugins and logging a spurious
|
|
// "connection is shut down" error.
|
|
func TestShutdownAfterDeactivateNoOnDeactivateRPC(t *testing.T) {
|
|
pluginDir, err := os.MkdirTemp("", "mm-shutdown-after-deactivate-plugin")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(pluginDir) })
|
|
webappPluginDir, err := os.MkdirTemp("", "mm-shutdown-after-deactivate-webapp")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
|
|
|
|
pluginID := "test-shutdown-after-deactivate-plugin"
|
|
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
|
|
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
|
|
|
|
utils.CompileGo(t, `
|
|
package main
|
|
|
|
import (
|
|
"github.com/mattermost/mattermost/server/public/plugin"
|
|
)
|
|
|
|
type MyPlugin struct {
|
|
plugin.MattermostPlugin
|
|
}
|
|
|
|
func (p *MyPlugin) OnDeactivate() error {
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
plugin.ClientMain(&MyPlugin{})
|
|
}
|
|
`, backend)
|
|
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(pluginDir, pluginID, "plugin.json"),
|
|
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
|
|
0600,
|
|
))
|
|
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
var buf mlog.Buffer
|
|
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
|
|
|
|
apiImpl := func(*model.Manifest) API { return nil }
|
|
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = env.Activate(pluginID)
|
|
require.NoError(t, err)
|
|
require.True(t, env.IsActive(pluginID))
|
|
|
|
// Deactivate tears down the RPC connection but leaves the plugin registered.
|
|
require.True(t, env.Deactivate(pluginID))
|
|
require.False(t, env.IsActive(pluginID))
|
|
|
|
// Shutdown must not dispatch OnDeactivate again to the now-inactive plugin.
|
|
env.Shutdown()
|
|
|
|
require.NoError(t, logger.Flush())
|
|
assert.NotContains(t, buf.String(), "OnDeactivate",
|
|
"Shutdown dispatched OnDeactivate to an already-deactivated plugin")
|
|
}
|
|
|
|
// TestDeactivateReconcilesPluginState verifies that deactivation always reconciles the plugin to
|
|
// PluginStateNotRunning, clearing any prior error state, and that the health check job's ordering
|
|
// (deactivate, then set state) still records PluginStateFailedToStayRunning.
|
|
func TestDeactivateReconcilesPluginState(t *testing.T) {
|
|
pluginDir, err := os.MkdirTemp("", "mm-deactivate-state-plugin")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(pluginDir) })
|
|
webappPluginDir, err := os.MkdirTemp("", "mm-deactivate-state-webapp")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
|
|
|
|
pluginID := "test-deactivate-state-plugin"
|
|
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
|
|
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
|
|
|
|
utils.CompileGo(t, `
|
|
package main
|
|
|
|
import (
|
|
"github.com/mattermost/mattermost/server/public/plugin"
|
|
)
|
|
|
|
type MyPlugin struct {
|
|
plugin.MattermostPlugin
|
|
}
|
|
|
|
func (p *MyPlugin) OnDeactivate() error {
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
plugin.ClientMain(&MyPlugin{})
|
|
}
|
|
`, backend)
|
|
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(pluginDir, pluginID, "plugin.json"),
|
|
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
|
|
0600,
|
|
))
|
|
|
|
logger := mlog.CreateConsoleTestLogger(t)
|
|
apiImpl := func(*model.Manifest) API { return nil }
|
|
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
|
|
require.NoError(t, err)
|
|
t.Cleanup(env.Shutdown)
|
|
|
|
t.Run("deactivating an inactive, failed plugin clears the error state", func(t *testing.T) {
|
|
_, _, err = env.Activate(pluginID)
|
|
require.NoError(t, err)
|
|
require.True(t, env.IsActive(pluginID))
|
|
|
|
// Tear the plugin down, then simulate the health check marking it as failed while it
|
|
// remains registered but inactive.
|
|
require.True(t, env.Deactivate(pluginID))
|
|
require.False(t, env.IsActive(pluginID))
|
|
env.setPluginState(pluginID, model.PluginStateFailedToStayRunning)
|
|
|
|
// Deactivating an already-inactive plugin (e.g. on Shutdown or an explicit disable) must
|
|
// reconcile the error state back to not running rather than leaving it as failed.
|
|
require.False(t, env.Deactivate(pluginID))
|
|
assert.Equal(t, model.PluginStateNotRunning, env.GetPluginState(pluginID))
|
|
})
|
|
|
|
t.Run("health check ordering records FailedToStayRunning", func(t *testing.T) {
|
|
_, _, err = env.Activate(pluginID)
|
|
require.NoError(t, err)
|
|
require.True(t, env.IsActive(pluginID))
|
|
|
|
// Mirror PluginHealthCheckJob.CheckPlugin: deactivate first, then record the failed state.
|
|
require.True(t, env.Deactivate(pluginID))
|
|
env.setPluginState(pluginID, model.PluginStateFailedToStayRunning)
|
|
|
|
assert.Equal(t, model.PluginStateFailedToStayRunning, env.GetPluginState(pluginID))
|
|
})
|
|
}
|