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:
mirivlad 2026-06-07 22:58:26 +08:00
parent d83c8c80e1
commit 45cfe1b0a6
8 changed files with 340 additions and 106 deletions

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 n == name {
return nil // already present
}
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)
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 {

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>

View File

@ -10,7 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.svelte'
import PluginPage from './lib/PluginPage.svelte'
import CalendarPluginPage from './lib/CalendarPluginPage.svelte'
import AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js'
@ -3574,7 +3574,7 @@
</div>
{:else if selectedSection && selectedSection.startsWith('plugin:')}
<PluginPage sectionId={selectedSection} />
<CalendarPluginPage sectionId={selectedSection} />
{:else}
<div class="welcome">

View File

@ -75,7 +75,7 @@
break
default:
console.log('[PluginPage] Unknown iframe action:', msg.action)
console.log('[CalendarPluginPage] Unknown iframe action:', msg.action)
}
}
@ -106,7 +106,7 @@
categories,
})
} catch (e) {
console.error('[PluginPage] loadCalendarData:', e)
console.error('[CalendarPluginPage] loadCalendarData:', e)
}
}
@ -121,7 +121,7 @@
events,
})
} catch (e) {
console.error('[PluginPage] get-events:', e)
console.error('[CalendarPluginPage] get-events:', e)
}
}
@ -137,7 +137,7 @@
})
loadCalendarData()
} catch (e) {
console.error('[PluginPage] create-event:', e)
console.error('[CalendarPluginPage] create-event:', e)
postToIframe({
source: 'verstak',
type: 'error',
@ -157,7 +157,7 @@
})
loadCalendarData()
} catch (e) {
console.error('[PluginPage] update-event:', e)
console.error('[CalendarPluginPage] update-event:', e)
}
}
@ -172,7 +172,7 @@
})
loadCalendarData()
} catch (e) {
console.error('[PluginPage] delete-event:', e)
console.error('[CalendarPluginPage] delete-event:', e)
}
}

View File

@ -1,6 +1,7 @@
package plugins
import (
"fmt"
"log"
"os"
"path/filepath"
@ -10,25 +11,23 @@ import (
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
// Only works if plugin is installed and enabled but not yet active.
func (m *Manager) ActivatePlugin(name string) {
// Returns error if VM creation, script loading, scheduler setup, or on_init hook fails.
func (m *Manager) ActivatePlugin(name string) error {
for i := range m.plugins {
p := &m.plugins[i]
if p.Meta.Name != name || p.Active {
continue
}
if !p.Enabled {
log.Printf("[plugins] %s: cannot activate — not enabled", name)
return
return fmt.Errorf("plugin %q is not enabled", name)
}
if p.HasInstall && !p.Installed {
log.Printf("[plugins] %s: cannot activate — not installed", name)
return
return fmt.Errorf("plugin %q is not installed", name)
}
vm, err := NewLuaVM(p)
if err != nil {
log.Printf("[plugins] %s: activate VM error: %v", name, err)
return
return fmt.Errorf("create VM for %q: %w", name, err)
}
p.vm = vm
if m.Services != nil {
@ -38,28 +37,35 @@ func (m *Manager) ActivatePlugin(name string) {
mainPath := filepath.Join(p.Dir, "main.lua")
if _, err := os.Stat(mainPath); err == nil {
if err := vm.LoadScript("main.lua"); err != nil {
log.Printf("[plugins] %s: load main.lua: %v", name, err)
p.vm.Close()
p.vm = nil
return fmt.Errorf("load main.lua for %q: %w", name, err)
}
}
p.scheduler = NewScheduler(p, vm)
for _, bg := range p.Meta.Background {
if err := p.scheduler.AddTask(bg); err != nil {
log.Printf("[plugins] %s: add task %s: %v", name, bg.ID, err)
p.vm.Close()
p.vm = nil
p.scheduler = nil
return fmt.Errorf("add task %s for %q: %w", bg.ID, name, err)
}
}
p.scheduler.Start()
if hookName, ok := p.Meta.Hooks["on_init"]; ok {
if err := vm.CallHook(hookName); err != nil {
// on_init failure is non-fatal for activation — log but continue
log.Printf("[plugins] %s: on_init error: %v", name, err)
}
}
p.Active = true
log.Printf("[plugins] %s: activated", name)
return
return nil
}
return fmt.Errorf("plugin %q not found", name)
}
// DeactivatePlugin stops a plugin's runtime without removing it from enabled list.

View File

@ -2,6 +2,7 @@ package plugins
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@ -193,11 +194,11 @@ func (vm *LuaVM) DoString(src string) (string, error) {
return ret.String(), nil
}
// CallFunction resolves a dotted function name, pushes args, and calls it under lock with timeout.
// CallFunctionJSON is a thread-safe, timeout-safe wrapper that accepts JSON params.
// It converts JSON→Lua under vm.mu, so the Lua state is never touched outside the lock.
// segments: pre-validated identifier segments (e.g. ["calendar", "create_event"])
// luaArg: pre-converted Lua argument (nil for no-arg calls)
// Returns the first return value as string, or error.
func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, error) {
// paramsJSON: JSON string or "" / "{}" for no-arg calls.
func (vm *LuaVM) CallFunctionJSON(segments []string, paramsJSON string) (string, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
@ -229,6 +230,16 @@ func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, err
return "", fmt.Errorf("not a function")
}
// Convert JSON params to Lua value UNDER the lock
var luaArg lua.LValue
if paramsJSON != "" && paramsJSON != "{}" {
var params interface{}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return "", fmt.Errorf("invalid JSON params: %w", err)
}
luaArg = goToLua(vm.L, params)
}
// Push function and args
vm.L.Push(fn)
if luaArg != nil {
@ -247,7 +258,8 @@ func (vm *LuaVM) CallFunction(segments []string, luaArg lua.LValue) (string, err
return ret.String(), nil
}
// LState returns the underlying lua.LState (for table creation).
// LState returns the underlying lua.LState (for table creation outside CallFunctionJSON).
// WARNING: only use this for read-only operations or when NOT holding vm.mu.
func (vm *LuaVM) LState() *lua.LState {
return vm.L
}
@ -257,6 +269,35 @@ func (p *Plugin) VM() *LuaVM {
return p.vm
}
// goToLua converts a Go interface{} to a lua.LValue.
// Must be called with vm.mu held (uses vm.L).
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))
}
}
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
// nargs is the number of function arguments already on the stack.
// Must be called with vm.mu held.

View File

@ -1191,10 +1191,10 @@ end
// Set short timeout
vm.callTimeout = 1 * time.Second
// CallFunction should timeout, not hang
// CallFunctionJSON should timeout, not hang
done := make(chan struct{})
go func() {
_, err := vm.CallFunction([]string{"infinite_loop"}, nil)
_, err := vm.CallFunctionJSON([]string{"infinite_loop"}, "")
if err == nil {
t.Error("expected timeout error from infinite_loop")
}
@ -1205,7 +1205,7 @@ end
case <-done:
// Success — function returned with timeout error
case <-time.After(5 * time.Second):
t.Fatal("CallFunction hung — timeout didn't work")
t.Fatal("CallFunctionJSON hung — timeout didn't work")
}
}
@ -1311,7 +1311,7 @@ func TestPluginPage_GenericBridge(t *testing.T) {
}
// TestFullLifecycle_EndToEnd tests the complete scenario:
// Install → Enable → Init → CallFunction → Deactivate → Activate → Reload → Uninstall.
// Install → Enable → Init → CallFunctionJSON → Deactivate → Activate → Reload → Uninstall.
func TestFullLifecycle_EndToEnd(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"e2e": {
@ -1367,14 +1367,14 @@ func TestFullLifecycle_EndToEnd(t *testing.T) {
t.Fatalf("expected 1 active, got %d", len(mgr.Active()))
}
// 4. CallFunction — call increment via LuaVM
// 4. CallFunctionJSON — call increment via LuaVM
allPlugins := mgr.Plugins()
if len(allPlugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(allPlugins))
}
result, err := allPlugins[0].vm.CallFunction([]string{"increment"}, nil)
result, err := allPlugins[0].vm.CallFunctionJSON([]string{"increment"}, "")
if err != nil {
t.Fatalf("CallFunction(increment): %v", err)
t.Fatalf("CallFunctionJSON(increment): %v", err)
}
if result != "2" { // counter was 1 after on_init, now 2
t.Errorf("increment() = %q, want %q", result, "2")
@ -1436,3 +1436,217 @@ func TestFullLifecycle_EndToEnd(t *testing.T) {
}
}
}
// TestSetPluginEnabled_ActivateFails_NoConfigSave verifies that when
// ActivatePlugin fails, the plugin is NOT added to EnabledPlugins in config.
// We simulate a failure by using a plugin with an invalid background task interval
// (which causes scheduler.AddTask to fail inside ActivatePlugin).
func TestSetPluginEnabled_ActivateFails_NoConfigSave(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"broken": {
files: map[string][]byte{
"plugin.json": []byte(`{
"name": "broken",
"version": "1.0",
"hooks": {"on_install": "on_install"},
"background_tasks": [{"id": "bad", "interval": "not-a-duration", "script": "dummy.lua"}]
}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
// Install succeeds
if err := mgr.Install("broken"); err != nil {
t.Fatalf("install: %v", err)
}
// Enable sets Enabled=true
if err := mgr.Enable("broken"); err != nil {
t.Fatalf("enable: %v", err)
}
// ActivatePlugin should fail (invalid background task interval)
err := mgr.ActivatePlugin("broken")
if err == nil {
t.Fatal("expected ActivatePlugin to fail with invalid background task interval")
}
// Verify plugin is NOT active
if len(mgr.Active()) != 0 {
t.Errorf("expected 0 active plugins, got %d", len(mgr.Active()))
}
// Verify config does NOT contain "broken" in EnabledPlugins
// (SetPluginEnabled would not have saved it because activation failed)
appCfg, _ := config.LoadAppConfig()
for _, n := range appCfg.EnabledPlugins {
if n == "broken" {
t.Error("broken plugin should NOT be in EnabledPlugins after failed activation")
}
}
}
// TestActivatePlugin_ErrorReturn verifies that ActivatePlugin returns
// descriptive errors for various failure modes.
func TestActivatePlugin_ErrorReturn(t *testing.T) {
// Test: plugin not found
root := setupPluginDir(t, map[string]*fsDir{
"exists": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "exists", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
// Not found
err := mgr.ActivatePlugin("nonexistent")
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' error, got: %v", err)
}
// Not installed
mgr.plugins[0].Enabled = true
err = mgr.ActivatePlugin("exists")
if err == nil || !strings.Contains(err.Error(), "not installed") {
t.Errorf("expected 'not installed' error, got: %v", err)
}
// Not enabled
mgr.plugins[0].Installed = true
mgr.plugins[0].Enabled = false
err = mgr.ActivatePlugin("exists")
if err == nil || !strings.Contains(err.Error(), "not enabled") {
t.Errorf("expected 'not enabled' error, got: %v", err)
}
}
// TestCallFunctionJSON_ThreadSafe verifies that CallFunctionJSON
// properly converts JSON params under the lock.
func TestCallFunctionJSON_ThreadSafe(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "jsonplug")
os.MkdirAll(pluginDir, 0755)
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
function echo_json(data)
return data["key"] .. ":" .. tostring(data["num"])
end
function echo_array(arr)
return arr[1] + arr[2]
end
`), 0644)
p := &Plugin{
Meta: Meta{Name: "jsonplug", Hooks: map[string]string{"on_install": "on_install"}},
Dir: pluginDir,
DataDir: filepath.Join(dir, "data"),
Active: true,
Enabled: true,
HasInstall: true,
}
os.MkdirAll(p.DataDir, 0755)
vm, err := NewLuaVM(p)
if err != nil {
t.Fatalf("NewLuaVM: %v", err)
}
defer vm.Close()
if err := vm.LoadScript("main.lua"); err != nil {
t.Fatalf("LoadScript: %v", err)
}
// Test with JSON object
result, err := vm.CallFunctionJSON([]string{"echo_json"}, `{"key":"hello","num":42}`)
if err != nil {
t.Fatalf("CallFunctionJSON: %v", err)
}
if result != "hello:42" {
t.Errorf("echo_json = %q, want %q", result, "hello:42")
}
// Test with JSON array
result, err = vm.CallFunctionJSON([]string{"echo_array"}, `[10, 20]`)
if err != nil {
t.Fatalf("CallFunctionJSON: %v", err)
}
if result != "30" {
t.Errorf("echo_array = %q, want %q", result, "30")
}
// Test with empty params
result, err = vm.CallFunctionJSON([]string{"echo_json"}, "")
if err == nil {
t.Error("expected error for missing required params")
}
}
// TestDeactivatePlugin_Idempotent verifies that deactivating an already
// inactive plugin is a no-op (no panic, no error).
func TestDeactivatePlugin_Idempotent(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"idle": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "idle", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
mgr.Install("idle")
mgr.Enable("idle")
mgr.InitRuntimes()
// Deactivate twice — second should be no-op
mgr.DeactivatePlugin("idle")
mgr.DeactivatePlugin("idle") // should not panic
if len(mgr.Active()) != 0 {
t.Errorf("expected 0 active, got %d", len(mgr.Active()))
}
}
// TestInitRuntimes_SkipsDisabled verifies that InitRuntimes only activates
// plugins that are Enabled=true.
func TestInitRuntimes_SkipsDisabled(t *testing.T) {
root := setupPluginDir(t, map[string]*fsDir{
"on": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "on", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
"off": {
files: map[string][]byte{
"plugin.json": []byte(`{"name": "off", "hooks": {"on_install": "on_install"}}`),
"main.lua": []byte(`function on_install() end`),
},
},
})
mgr := NewManager(root)
mgr.Discover()
mgr.Install("on")
mgr.Install("off")
mgr.Enable("on")
// "off" is NOT enabled
mgr.InitRuntimes()
if len(mgr.Active()) != 1 {
t.Errorf("expected 1 active (only 'on'), got %d", len(mgr.Active()))
}
if len(mgr.Plugins()) != 2 {
t.Errorf("expected 2 plugins total, got %d", len(mgr.Plugins()))
}
}