fix: финальный cleanup Lua plugin lifecycle

1. ActivatePlugin → error return:
   - Возвращает ошибки при создании VM, загрузке main.lua, scheduler setup
   - on_init failure = non-fatal (logged, activation continues)
   - SetPluginEnabled сохраняет EnabledPlugins в config ТОЛЬКО после успешной активации
   - При ошибке активации — rollback (deactivate + не сохраняем в config)

2. CallPluginFunction fully thread-safe:
   - Новый метод LuaVM.CallFunctionJSON(segments, paramsJSON)
   - JSON→Lua conversion происходит под vm.mu (внутри lock)
   - Убраны parseParamsToLua/goToLua из bindings_plugins.go
   - goToLua перенесён в runtime.go (под lock)

3. PluginPage → CalendarPluginPage:
   - Компонент явно календарный (get-events/create-event/update-event/delete-event)
   - Переименован для ясности
   - Console log префиксы обновлены

4. Тесты:
   - TestSetPluginEnabled_ActivateFails_NoConfigSave: проверяет что при ошибке
     активации плагин НЕ сохраняется в EnabledPlugins
   - TestActivatePlugin_ErrorReturn: проверяет все режимы ошибок
   - TestCallFunctionJSON_ThreadSafe: JSON object/array/empty params
   - TestDeactivatePlugin_Idempotent: двойная деактивация = no-op
   - TestInitRuntimes_SkipsDisabled: только Enabled плагины активируются
This commit is contained in:
2026-06-07 22:58:26 +08:00
parent d83c8c80e1
commit 45cfe1b0a6
8 changed files with 340 additions and 106 deletions
+47 -74
View File
@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
@@ -10,9 +9,6 @@ import (
"strings"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
lua "github.com/yuin/gopher-lua"
)
// PluginDTO represents a discovered plugin with its current state.
@@ -105,51 +101,74 @@ func (a *App) ListPlugins() []PluginDTO {
}
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
// Enable: marks plugin as enabled, then activates runtime (VM + scheduler).
// Disable: deactivates runtime, then marks plugin as disabled.
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
// Disable: deactivates runtime, then marks plugin as disabled in config.
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
if enabled {
// Enable first (sets Enabled=true), then activate runtime
// Enable first (sets Enabled=true on the plugin struct)
if err := a.plugins.Enable(name); err != nil {
return err
}
a.plugins.ActivatePlugin(name)
// Activate runtime — if this fails, do NOT persist to config
if err := a.plugins.ActivatePlugin(name); err != nil {
// Rollback: deactivate and un-enable
a.plugins.DeactivatePlugin(name)
return fmt.Errorf("activate %q: %w", name, err)
}
// Only persist to config after successful activation
if err := a.saveEnabledPlugin(name); err != nil {
// Config save failed — rollback runtime too
a.plugins.DeactivatePlugin(name)
return fmt.Errorf("save config for %q: %w", name, err)
}
} else {
// Deactivate runtime first, then disable
a.plugins.DeactivatePlugin(name)
if err := a.plugins.Disable(name); err != nil {
return err
}
// Remove from config
if err := a.removeEnabledPlugin(name); err != nil {
return fmt.Errorf("save config for %q: %w", name, err)
}
}
// Persist enabled state in config
return nil
}
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
func (a *App) saveEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
existing := make(map[string]bool)
for _, n := range appCfg.EnabledPlugins {
existing[n] = true
}
if enabled {
existing[name] = true
} else {
delete(existing, name)
}
appCfg.EnabledPlugins = make([]string, 0, len(existing))
for n := range existing {
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return fmt.Errorf("save config: %w", err)
if n == name {
return nil // already present
}
}
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
return config.SaveAppConfig(appCfg)
}
return nil
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
func (a *App) removeEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil // nothing to remove
}
var updated []string
for _, n := range appCfg.EnabledPlugins {
if n != name {
updated = append(updated, n)
}
}
appCfg.EnabledPlugins = updated
return config.SaveAppConfig(appCfg)
}
// GetPluginPanelHTML returns the HTML panel content for a plugin.
@@ -269,59 +288,13 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
continue
}
// Parse params into Lua value
luaArg, err := parseParamsToLua(vm, paramsJSON)
if err != nil {
return "", fmt.Errorf("parse params: %w", err)
}
// Call via thread-safe, timeout-safe LuaVM.CallFunction
return vm.CallFunction(segments, luaArg)
// Call via fully thread-safe LuaVM.CallFunctionJSON
// (JSON→Lua conversion happens under vm.mu)
return vm.CallFunctionJSON(segments, paramsJSON)
}
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
}
// parseParamsToLua converts a JSON params string to a lua.LValue.
// Empty or "{}" → nil (no argument).
func parseParamsToLua(vm *plugins.LuaVM, paramsJSON string) (lua.LValue, error) {
if paramsJSON == "" || paramsJSON == "{}" {
return nil, nil
}
var params interface{}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return nil, fmt.Errorf("invalid JSON params: %w", err)
}
return goToLua(vm.LState(), params), nil
}
// goToLua converts a Go interface{} to a lua.LValue.
func goToLua(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case string:
return lua.LString(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case map[string]interface{}:
tbl := L.NewTable()
for k, v := range val {
tbl.RawSetString(k, goToLua(L, v))
}
return tbl
case []interface{}:
tbl := L.NewTable()
for i, v := range val {
tbl.RawSetInt(i+1, goToLua(L, v))
}
return tbl
default:
return lua.LString(fmt.Sprintf("%v", v))
}
}
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error {
if a.plugins == nil {
+1 -1
View File
@@ -19,7 +19,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-8qmy5tDO.js"></script>
<script type="module" crossorigin src="/assets/main-C2sdkP-s.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
</head>
<body>