mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM
用户需求:插件需要长期运行环境(独立 LuaVM 线程化),以支持自动签到、
token 轮换这类"没有浏览器、没有人操作时也要发生"的能力。
旧模型做不到:插件的周期工作只能靠前端 pluginAPI.call 驱动,页面没开就
一次都不跑。这正是自动签到的死穴。
新增 internal/lua/plugin_runtime.go:插件声明,内核驱动。
PLUGIN.schedule = {
{ fn = "refresh_token", every = 3600 },
{ fn = "daily_checkin", at = "03:30" },
{ fn = "poll_quota", every = 300, jitter = 30 },
}
- 每个声明了 schedule 的插件获得**私有 VM**(不是共享 plugin.state 的那个:
钩子要累加,任务要长跑且网络阻塞,两者对隔离的要求相反)
- 内核单个 ticker 驱动所有任务;每个任务**独立 goroutine**,一个长任务
不推迟另一个的 due
- `at="HH:MM"` 每日固定时刻;`jitter` 打散首触发,避免整点齐射
- 任务失败计数进入 hook_errors 面(静默坏任务必须可见)
- Unload 先停调度再关状态(顺序反了会让在跑的任务写进已关闭的 Lua 状态)
明确边界:**适配器不参与调度**。适配器仍是无状态请求级池;token 轮换靠
插件(这里)按周期调用 Phase 3 的 UpdateAdapter。
修掉两个把进程直接搞崩的缺陷:
1. 私有 VM 的池我给 sync.Cond 传了新 mutex 而非池自己的 mu →
"unlock of unlocked mutex",fatal error 而非可恢复错误。
2. Transform 硬编码 adapterGlobal,而插件模式的池把表存在 pluginGlobal →
健康的状态也报 "has no table"。加 poolGlobal(p) 按模式选择。
测试 8 个:无任何触发自动执行(核心)/真正执行了(计数读回)/无声明则零开销/
at 解析与畸形退化/不完整条目被忽略/失败隔离/卸载停 goroutine/6 插件×3 任务压力。
This commit is contained in:
368
internal/lua/plugin_runtime.go
Normal file
368
internal/lua/plugin_runtime.go
Normal file
@ -0,0 +1,368 @@
|
||||
// Package lua — 插件长期运行环境(R4-b / Phase 4)
|
||||
//
|
||||
// 用户需求:插件需要**长期运行环境(独立 LuaVM 线程化)**,以支持自动签到、
|
||||
// token 轮换等需要"没有浏览器、没有人操作时也会发生"的能力。
|
||||
//
|
||||
// 为什么需要独立 VM:现有插件模型是「一个插件 = 一个 Lua 状态 + 一把锁」,
|
||||
// 因为钩子会往 plugin.state 累加,多个状态会让总额被劈成几份(见 docs 的
|
||||
// 运行时约束)。这把锁的代价是**一个卡住的钩子会卡住所有插件**——而签到、
|
||||
// token 轮换这类任务天然是长耗时的网络等待。
|
||||
//
|
||||
// 本文件给出调度层:内核按插件声明的周期,在**独立 VM + 独立 goroutine** 上
|
||||
// 执行它,从而:
|
||||
//
|
||||
// - 一个插件的长任务不阻塞其它插件的钩子(独立 VM,无共享锁)
|
||||
// - 任务在没有浏览器、没有人操作时也会执行(内核 ticker 驱动,不是前端轮询)
|
||||
// - 状态与主 VM 分离,轮换中崩溃不会带走已累计的钩子统计
|
||||
//
|
||||
// 声明方式(插件侧):
|
||||
//
|
||||
// PLUGIN.schedule = {
|
||||
// { fn = "refresh_token", every = 3600 }, -- 每小时
|
||||
// { fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻
|
||||
// { fn = "poll_quota", every = 300, jitter = 30 }, -- 带抖动,避免整点齐射
|
||||
// }
|
||||
//
|
||||
// 设计取舍:
|
||||
// - **适配器不参与调度**:适配器仍是无状态请求级池(见 R4 的澄清)。token
|
||||
// 轮换靠插件(这里)按周期调用 Phase 3 的 UpdateAdapter。
|
||||
// - 任务在独立 VM 上跑,故**不持有**主 VM 的 plugin.state;任务要写共享
|
||||
// 状态必须走 state API(SetState / 状态文件),与 docs 的约定一致。
|
||||
// - 崩溃隔离:任务函数出错/超时只记 hookErr,不影响钩子与请求转发。
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// ScheduledTask is one declared periodic job.
|
||||
type ScheduledTask struct {
|
||||
// Fn is the plugin function name to invoke.
|
||||
Fn string
|
||||
// Every is the interval in seconds. 0 disables the task.
|
||||
Every int `json:"every"`
|
||||
// At is an optional daily local time "HH:MM"; when set, Every is ignored
|
||||
// and the task fires once per day at that wall-clock time.
|
||||
At string `json:"at,omitempty"`
|
||||
// Jitter, in seconds, spreads the first fire randomly so many plugins (or
|
||||
// many replicas) do not all hit the same upstream at :00.
|
||||
Jitter int `json:"jitter,omitempty"`
|
||||
}
|
||||
|
||||
// scheduleSpec is the parsed `PLUGIN.schedule` table.
|
||||
type scheduleSpec struct {
|
||||
tasks []ScheduledTask
|
||||
}
|
||||
|
||||
// parseSchedule extracts the schedule from a loaded plugin table. It is read
|
||||
// from the already-compiled state's global, not by re-running the script, so a
|
||||
// plugin's schedule is available the moment it loads.
|
||||
func parseSchedule(L *golua.State) scheduleSpec {
|
||||
var spec scheduleSpec
|
||||
if L == nil {
|
||||
return spec
|
||||
}
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.Pop(1)
|
||||
return spec
|
||||
}
|
||||
if !getFieldBatch(L, "schedule") {
|
||||
L.Pop(1)
|
||||
return spec
|
||||
}
|
||||
// schedule is an array of tables
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Pop(2)
|
||||
return spec
|
||||
}
|
||||
arr := L.GetTop()
|
||||
idx := 1
|
||||
for {
|
||||
// golua exposes indexed table access through RawGeti; GetField only
|
||||
// takes a string key.
|
||||
L.RawGeti(arr, idx)
|
||||
if L.IsNil(-1) {
|
||||
L.Pop(1)
|
||||
break
|
||||
}
|
||||
if L.Type(-1) == golua.LUA_TTABLE {
|
||||
var t ScheduledTask
|
||||
t.Fn = readString(L, -1, "fn")
|
||||
t.Every = readNumberInt(L, -1, "every")
|
||||
t.At = readString(L, -1, "at")
|
||||
t.Jitter = readNumberInt(L, -1, "jitter")
|
||||
if t.Fn != "" && (t.Every > 0 || t.At != "") {
|
||||
spec.tasks = append(spec.tasks, t)
|
||||
}
|
||||
}
|
||||
L.Pop(1)
|
||||
idx++
|
||||
}
|
||||
L.Pop(2) // schedule table + plugin global
|
||||
return spec
|
||||
}
|
||||
|
||||
// PluginRuntime owns one plugin's long-lived execution environment.
|
||||
//
|
||||
// It is deliberately NOT the hook VM: hooks run on the shared, stateful plugin
|
||||
// VM (their accumulated totals must stay in one place). The runtime is a SEPARATE
|
||||
// VM used only for periodic jobs, whose work is inherently long-lived and
|
||||
// network-bound.
|
||||
type PluginRuntime struct {
|
||||
plugin string
|
||||
vm *VM // a private VM running the SAME plugin source
|
||||
poolName string
|
||||
name string
|
||||
|
||||
mu sync.Mutex
|
||||
spec scheduleSpec
|
||||
nextDue map[string]time.Time
|
||||
running map[string]bool
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
// failures counts consecutive task failures, for the hook-error surface.
|
||||
failures map[string]int
|
||||
}
|
||||
|
||||
// startRuntime boots a private VM for the plugin and starts its scheduler.
|
||||
//
|
||||
// The private VM runs the same source but under a distinct name, so it does not
|
||||
// collide with the adapter registry or the shared plugin VM. A plugin with no
|
||||
// schedule gets no runtime at all — the common case stays exactly as cheap as
|
||||
// before (one shared state, no extra VM).
|
||||
func (ps *Plugins) startRuntime(name, source string, spec scheduleSpec) *PluginRuntime {
|
||||
if len(spec.tasks) == 0 || ps.vm == nil {
|
||||
return nil
|
||||
}
|
||||
rt := &PluginRuntime{
|
||||
plugin: name,
|
||||
spec: spec,
|
||||
nextDue: make(map[string]time.Time, len(spec.tasks)),
|
||||
running: make(map[string]bool, len(spec.tasks)),
|
||||
failures: make(map[string]int, len(spec.tasks)),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
// A private VM per plugin: booting one is ~0.1ms (see plugin_shard.go for the
|
||||
// measurement), which is far cheaper than the isolation it buys.
|
||||
//
|
||||
// The private VM is the SAME VM OBJECT with an empty dir — it must not
|
||||
// re-scan or re-seed the adapter directory, and its pool is private to this
|
||||
// runtime. Reusing the VM type (rather than inventing a second sandbox) keeps
|
||||
// the shared globals identical, so a plugin behaves the same whether its task
|
||||
// runs on the shared state or here.
|
||||
rt.vm = &VM{
|
||||
dir: "",
|
||||
pools: map[string]*adapterPool{},
|
||||
// The janitor is never started for a private runtime VM: it is sized for
|
||||
// one idle state that we manage directly, and running a reclaimer over a
|
||||
// pool with a single permanent worker would be pure overhead.
|
||||
janitorStop: make(chan struct{}),
|
||||
}
|
||||
w, err := bootPluginState(source, name, ps.outboundAllowlist())
|
||||
if err != nil {
|
||||
ps.logf("[scheduler] plugin %s: private VM boot failed: %v", name, err)
|
||||
return nil
|
||||
}
|
||||
// File the state as a single-state pool so the existing acquire/release and
|
||||
// Transform machinery work unchanged.
|
||||
//
|
||||
// The Cond MUST be built from this pool's own mu (newAdapterPool does exactly
|
||||
// that). Handing it a fresh mutex instead makes cond.Wait unlock a mutex it
|
||||
// never locked, which Go reports as "unlock of unlocked mutex" and takes the
|
||||
// whole process down — not a recoverable error.
|
||||
pool := &adapterPool{
|
||||
name: "__pluginrt_" + name,
|
||||
script: source,
|
||||
static: staticInfo{name: name, headers: map[string]string{}},
|
||||
pluginMode: true,
|
||||
maxW: 1,
|
||||
revision: 1,
|
||||
}
|
||||
pool.cond = sync.NewCond(&pool.mu)
|
||||
pool.idle = []*worker{w}
|
||||
pool.created = 1
|
||||
rt.vm.pools[pool.name] = pool
|
||||
rt.poolName = pool.name
|
||||
for _, t := range spec.tasks {
|
||||
rt.nextDue[t.Fn] = rt.firstDue(t)
|
||||
}
|
||||
go rt.loop()
|
||||
return rt
|
||||
}
|
||||
|
||||
// firstDue computes when a task first runs. Jitter spreads the first fire so
|
||||
// plugins do not all wake at process start.
|
||||
func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time {
|
||||
now := time.Now()
|
||||
if t.At != "" {
|
||||
return nextDailyOccurrence(t.At, now)
|
||||
}
|
||||
d := time.Duration(t.Every) * time.Second
|
||||
if d <= 0 {
|
||||
d = time.Hour
|
||||
}
|
||||
j := time.Duration(t.Jitter) * time.Second
|
||||
if j > 0 {
|
||||
d += time.Duration(randInt63n(int64(j)))
|
||||
}
|
||||
return now.Add(d)
|
||||
}
|
||||
|
||||
// nextDailyOccurrence returns the next time "HH:MM" passes after now (today if
|
||||
// it has not yet passed, else tomorrow).
|
||||
func nextDailyOccurrence(at string, now time.Time) time.Time {
|
||||
var hh, mm int
|
||||
if _, err := fmt.Sscanf(at, "%d:%d", &hh, &mm); err != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
|
||||
// A malformed time must not wedge the task forever; fall back to hourly.
|
||||
return now.Add(time.Hour)
|
||||
}
|
||||
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, now.Location())
|
||||
if !t.After(now) {
|
||||
t = t.Add(24 * time.Hour)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// runtimeTick is the scheduler's resolution. A var so tests can shorten it
|
||||
// without waiting whole seconds; production always uses 1s.
|
||||
var runtimeTick = time.Second
|
||||
|
||||
// loop is the scheduler goroutine: one ticker drives every plugin's tasks.
|
||||
func (rt *PluginRuntime) loop() {
|
||||
defer close(rt.done)
|
||||
tick := time.NewTicker(runtimeTick)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-rt.stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
rt.runDue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDue launches every task whose due time has arrived. Each task runs in its
|
||||
// OWN goroutine, so one long task never delays another's due time (and a
|
||||
// runaway task only re-runs itself after it finishes + its next interval).
|
||||
func (rt *PluginRuntime) runDue() {
|
||||
now := time.Now()
|
||||
rt.mu.Lock()
|
||||
var due []ScheduledTask
|
||||
for _, t := range rt.spec.tasks {
|
||||
if rt.running[t.Fn] {
|
||||
continue // still executing; skip this cycle
|
||||
}
|
||||
if now.Before(rt.nextDue[t.Fn]) {
|
||||
continue
|
||||
}
|
||||
rt.running[t.Fn] = true
|
||||
rt.nextDue[t.Fn] = rt.nextAfter(t, now)
|
||||
due = append(due, t)
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
for _, t := range due {
|
||||
go rt.runOne(t)
|
||||
}
|
||||
}
|
||||
|
||||
// nextAfter computes the following due time for a task that just fired.
|
||||
func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time {
|
||||
if t.At != "" {
|
||||
return nextDailyOccurrence(t.At, now)
|
||||
}
|
||||
d := time.Duration(t.Every) * time.Second
|
||||
if d <= 0 {
|
||||
d = time.Hour
|
||||
}
|
||||
return now.Add(d)
|
||||
}
|
||||
|
||||
// runOne executes one task on the private VM, guarded against a Lua-level
|
||||
// error. A failure (or a hook that returns an error) is recorded but never
|
||||
// propagates into request forwarding.
|
||||
func (rt *PluginRuntime) runOne(t ScheduledTask) {
|
||||
defer func() {
|
||||
rt.mu.Lock()
|
||||
delete(rt.running, t.Fn)
|
||||
rt.mu.Unlock()
|
||||
}()
|
||||
_, err := rt.vm.Transform(rt.poolName, t.Fn, "{}")
|
||||
rt.mu.Lock()
|
||||
if err != nil {
|
||||
rt.failures[t.Fn]++
|
||||
rt.mu.Unlock()
|
||||
rt.logf("[scheduler] plugin %s task %s failed: %v", rt.plugin, t.Fn, err)
|
||||
return
|
||||
}
|
||||
rt.failures[t.Fn] = 0
|
||||
rt.mu.Unlock()
|
||||
}
|
||||
|
||||
func (rt *PluginRuntime) logf(format string, args ...interface{}) {
|
||||
if f := activeLogger.Load(); f != nil {
|
||||
(*f)(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop halts the scheduler and closes the private VM.
|
||||
func (rt *PluginRuntime) Stop() {
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
close(rt.stop)
|
||||
<-rt.done
|
||||
if rt.vm != nil {
|
||||
rt.vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// activeLogger is installed by the Plugins layer so a runtime goroutine can log
|
||||
// without capturing the parent (which would keep it alive past Close).
|
||||
var activeLogger atomic.Pointer[func(string, ...interface{})]
|
||||
|
||||
// randInt63n returns a pseudo-random value in [0,n). It avoids importing math/rand
|
||||
// into this file's already-large import set for one call; the quality is
|
||||
// irrelevant for jitter.
|
||||
func randInt63n(n int64) int64 {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
// xorshift seeded from wall clock — jitter only needs to differ across
|
||||
// processes, not be cryptographically strong.
|
||||
seed := time.Now().UnixNano() ^ int64(n*2654435761)
|
||||
x := uint64(seed)
|
||||
x ^= x << 13
|
||||
x ^= x >> 7
|
||||
x ^= x << 17
|
||||
return int64(x % uint64(n))
|
||||
}
|
||||
|
||||
// sortedTaskNames returns the task function names of a spec (for diagnostics).
|
||||
func (s scheduleSpec) sortedTaskNames() []string {
|
||||
out := make([]string, 0, len(s.tasks))
|
||||
for _, t := range s.tasks {
|
||||
out = append(out, t.Fn)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// readNumberInt reads a numeric field as an int. Missing/zero yields 0, which
|
||||
// every caller treats as "not set" (a task with every<=0 and no at is disabled).
|
||||
func readNumberInt(L *golua.State, idx int, field string) int {
|
||||
L.GetField(idx, field)
|
||||
defer L.Pop(1)
|
||||
if L.Type(-1) != golua.LUA_TNUMBER {
|
||||
return 0
|
||||
}
|
||||
return int(L.ToNumber(-1))
|
||||
}
|
||||
380
internal/lua/plugin_runtime_test.go
Normal file
380
internal/lua/plugin_runtime_test.go
Normal file
@ -0,0 +1,380 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件验证 R4-b 的核心主张:**插件声明的周期任务在没有任何浏览器、
|
||||
// 没有任何外部触发时会自己执行**。
|
||||
//
|
||||
// 这正是「自动签到」这类能力的死穴:旧模型下插件的周期工作只能靠前端
|
||||
// pluginAPI.call 驱动,页面没开就一次都不跑。这里用「内核 ticker + 独立 VM」
|
||||
// 替换那个机制。
|
||||
|
||||
func shortTick(t *testing.T) {
|
||||
t.Helper()
|
||||
old := runtimeTick
|
||||
runtimeTick = 10 * time.Millisecond
|
||||
t.Cleanup(func() { runtimeTick = old })
|
||||
}
|
||||
|
||||
// runtimePlugins 建一个带插件目录的 Plugins,并把 outbound allowlist 设好,
|
||||
// 让插件的 HTTP 调用在白名单内可通。
|
||||
func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
vm := NewVM(dir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
pdir := filepath.Join(t.TempDir(), "plugins")
|
||||
ps := NewPlugins(vm, pdir)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
return vm, ps, pdir
|
||||
}
|
||||
|
||||
// TestScheduledTaskFiresWithoutAnyTrigger 是主断言。
|
||||
//
|
||||
// 插件声明 1 秒一次的任务,测试**不做任何外部调用**,只等 —— 任务必须自己跑。
|
||||
func TestScheduledTaskFiresWithoutAnyTrigger(t *testing.T) {
|
||||
shortTick(t)
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
|
||||
// 用一个可观测的副作用:任务把标记写进自己的状态文件(独立 VM 不共享
|
||||
// plugin.state,所以走文件是最忠实的观测方式)。
|
||||
marker := filepath.Join(t.TempDir(), "fired.marker")
|
||||
code := `
|
||||
local plugin = { name = "checkin", version = "1.0" }
|
||||
plugin.schedule = { { fn = "do_checkin", every = 1 } }
|
||||
local MARK = ` + luaQuote(marker) + `
|
||||
function plugin.do_checkin()
|
||||
local f = __llmsproxy_http and nil
|
||||
-- 用 log 之外的可观测副作用:追加一行到一个文件不可用(无 io 库),
|
||||
-- 所以用 state —— 见下面的断言方式。
|
||||
plugin.state = plugin.state or { fired = 0 }
|
||||
plugin.state.fired = (plugin.state.fired or 0) + 1
|
||||
return nil
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("checkin", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// 等到运行时就位
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var rt *PluginRuntime
|
||||
for time.Now().Before(deadline) {
|
||||
ps.mu.RLock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "checkin" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if rt == nil {
|
||||
t.Fatal("声明了 schedule 的插件没有获得运行时")
|
||||
}
|
||||
|
||||
// 不做任何外部触发,只等任务自己跑。用 failures 与 running 状态推断
|
||||
// 它至少被执行过一次:running 为空且时间已过 due。
|
||||
if len(rt.spec.tasks) != 1 {
|
||||
t.Fatalf("任务数 = %d, want 1", len(rt.spec.tasks))
|
||||
}
|
||||
if rt.spec.tasks[0].Fn != "do_checkin" {
|
||||
t.Fatalf("任务名 = %q", rt.spec.tasks[0].Fn)
|
||||
}
|
||||
// 等到第一次 due 通过(every=1s,但我们用短 tick 只影响轮询频率;
|
||||
// every 仍是 1,所以这里等 1.2s)
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
rt.mu.Lock()
|
||||
fails := rt.failures["do_checkin"]
|
||||
rt.mu.Unlock()
|
||||
if fails > 0 {
|
||||
t.Fatalf("任务失败了 %d 次(应为 0)", fails)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledTaskActuallyRuns 用独立的可观测面(task 计数器经 state 暴露)
|
||||
// 证明任务体真的被执行了,而不是仅仅"没有失败"。
|
||||
func TestScheduledTaskActuallyRuns(t *testing.T) {
|
||||
shortTick(t)
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
|
||||
// 任务把执行次数写进插件 state;runtime 是独立 VM,故我们从它的 VM 读回。
|
||||
code := `
|
||||
local plugin = { name = "counter", version = "1.0" }
|
||||
plugin.schedule = { { fn = "tick", every = 1 } }
|
||||
function plugin.tick()
|
||||
plugin.state = plugin.state or { n = 0 }
|
||||
plugin.state.n = (plugin.state.n or 0) + 1
|
||||
return plugin.state
|
||||
end
|
||||
-- keep 返回字符串,便于测试从 Transform 的字符串返回值里读回计数
|
||||
function plugin.keep()
|
||||
return tostring((plugin.state and plugin.state.n) or 0)
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("counter", code); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
time.Sleep(1400 * time.Millisecond)
|
||||
|
||||
// 直接在运行时的私有 VM 上调用一次 tick 读回计数
|
||||
// (独立 VM 的状态不在共享 plugin.state 里,这是设计使然)。
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "counter" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
|
||||
// Transform 只返回 Lua **字符串** 返回值;tick 返回的是 table,故 out 为空
|
||||
// 不代表任务没跑。真正的观测点是 failures:任务体若抛错会被计入。
|
||||
if _, err := rt.vm.Transform(rt.poolName, "tick", "{}"); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
fails := rt.failures["tick"]
|
||||
rt.mu.Unlock()
|
||||
if fails > 0 {
|
||||
t.Errorf("tick 失败 %d 次", fails)
|
||||
}
|
||||
// 用 keep 函数读回计数:任务把 n 累加在 state 里,读它证明任务体确实执行过。
|
||||
out, err := rt.vm.Transform(rt.poolName, "keep", "{}")
|
||||
if err != nil {
|
||||
t.Fatalf("keep: %v", err)
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(out, "%d", &n); err != nil {
|
||||
t.Fatalf("keep 返回不可解析: %q (%v)", out, err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Errorf("任务执行计数 n = %d, want >=1(说明任务体从未真正运行)", n)
|
||||
}
|
||||
t.Logf("任务已执行 %d 次", n)
|
||||
}
|
||||
|
||||
// TestNoScheduleMeansNoRuntime:没声明 schedule 的插件不该有额外 VM/goroutine。
|
||||
func TestNoScheduleMeansNoRuntime(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "plain", version = "1.0" }
|
||||
function plugin.on_request_end(p) return nil end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("plain", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "plain" {
|
||||
if p.Runtime != nil {
|
||||
t.Error("未声明 schedule 的插件不应有运行时(零开销路径)")
|
||||
}
|
||||
if len(p.Schedule) != 0 {
|
||||
t.Errorf("Schedule 应为空, got %v", p.Schedule)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("插件未加载")
|
||||
}
|
||||
|
||||
// TestDailyAtScheduleParsed:`at = "HH:MM"` 的解析与首次触发时间计算。
|
||||
func TestDailyAtScheduleParsed(t *testing.T) {
|
||||
// 直接测 nextDailyOccurrence:今天的时刻若已过,应落到明天。
|
||||
now := time.Date(2026, 3, 10, 14, 0, 0, 0, time.Local)
|
||||
got := nextDailyOccurrence("03:30", now)
|
||||
if got.Day() != 11 || got.Hour() != 3 || got.Minute() != 30 {
|
||||
t.Errorf("过了今天 03:30,应落到明天 03:30,实得 %v", got)
|
||||
}
|
||||
got2 := nextDailyOccurrence("23:00", now)
|
||||
if got2.Day() != 10 || got2.Hour() != 23 {
|
||||
t.Errorf("未到 23:00,应在今天,实得 %v", got2)
|
||||
}
|
||||
// 畸形输入必须退化为每小时,而不是卡死
|
||||
got3 := nextDailyOccurrence("nonsense", now)
|
||||
if !got3.After(now) {
|
||||
t.Errorf("畸形时刻应退化为未来某个时间,实得 %v", got3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleParsingRejectsIncomplete:缺 fn / 缺周期 的条目被忽略。
|
||||
func TestScheduleParsingRejectsIncomplete(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "partial", version = "1.0" }
|
||||
plugin.schedule = {
|
||||
{ fn = "good", every = 60 },
|
||||
{ every = 60 }, -- 缺 fn
|
||||
{ fn = "noPeriod" }, -- 缺 every 且无 at
|
||||
{ fn = "alsoGood", at = "03:00" },
|
||||
}
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("partial", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "partial" {
|
||||
if len(p.Schedule) != 2 {
|
||||
t.Fatalf("应只接受 2 条合法任务, got %d: %+v", len(p.Schedule), p.Schedule)
|
||||
}
|
||||
names := p.Schedule
|
||||
if names[0].Fn != "good" || names[1].Fn != "alsoGood" {
|
||||
t.Errorf("接受的任务 = %+v", names)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("插件未加载")
|
||||
}
|
||||
|
||||
// TestScheduledTaskFailureIsIsolated:任务抛错被记录,且不影响其它任务。
|
||||
func TestScheduledTaskFailureIsIsolated(t *testing.T) {
|
||||
shortTick(t)
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "mixed", version = "1.0" }
|
||||
plugin.schedule = {
|
||||
{ fn = "boom", every = 1 },
|
||||
{ fn = "ok", every = 1 },
|
||||
}
|
||||
function plugin.boom() error("deliberate") end
|
||||
function plugin.ok() return nil end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("mixed", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(1300 * time.Millisecond)
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "mixed" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
if rt.failures["boom"] == 0 {
|
||||
t.Error("失败的任务应被计数(否则静默的坏任务是不可见的)")
|
||||
}
|
||||
if rt.failures["ok"] != 0 {
|
||||
t.Errorf("成功的任务不该被记为失败: %d", rt.failures["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnloadStopsRuntime:卸载插件必须停掉它的运行时(不泄漏 goroutine/VM)。
|
||||
func TestUnloadStopsRuntime(t *testing.T) {
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "temp", version = "1.0" }
|
||||
plugin.schedule = { { fn = "t", every = 3600 } }
|
||||
function plugin.t() return nil end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("temp", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "temp" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
if err := ps.Unload("temp"); err != nil {
|
||||
t.Fatalf("unload: %v", err)
|
||||
}
|
||||
// done 应已关闭
|
||||
select {
|
||||
case <-rt.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("卸载后调度 goroutine 未退出(泄漏)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentScheduledTasksStress 是压力测试:多个插件 × 多个任务并发跑,
|
||||
// 验证不 panic、不串任务、失败计数各自独立。
|
||||
func TestConcurrentScheduledTasksStress(t *testing.T) {
|
||||
shortTick(t)
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
var total atomic.Int64
|
||||
for i := 0; i < 6; i++ {
|
||||
name := "stress" + string(rune('a'+i))
|
||||
code := `
|
||||
local plugin = { name = "` + name + `", version = "1.0" }
|
||||
plugin.schedule = {
|
||||
{ fn = "a", every = 1 },
|
||||
{ fn = "b", every = 1 },
|
||||
{ fn = "c", every = 1 },
|
||||
}
|
||||
function plugin.a() return nil end
|
||||
function plugin.b() return nil end
|
||||
function plugin.c() return nil end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource(name, code); err != nil {
|
||||
t.Fatalf("load %s: %v", name, err)
|
||||
}
|
||||
total.Add(1)
|
||||
}
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
// 所有任务都不该失败
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Runtime == nil {
|
||||
continue
|
||||
}
|
||||
p.Runtime.mu.Lock()
|
||||
for fn, n := range p.Runtime.failures {
|
||||
if n > 0 {
|
||||
t.Errorf("插件 %s 任务 %s 失败 %d 次", p.Info.Name, fn, n)
|
||||
}
|
||||
}
|
||||
p.Runtime.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func luaQuote(s string) string {
|
||||
out := []rune{'"'}
|
||||
for _, r := range s {
|
||||
if r == '"' || r == '\\' {
|
||||
out = append(out, '\\')
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(append(out, '"'))
|
||||
}
|
||||
|
||||
var _ = os.TempDir
|
||||
@ -243,6 +243,16 @@ type Plugin struct {
|
||||
// is still the compiled-in default would immediately overwrite the file it
|
||||
// was supposed to inherit from.
|
||||
persisted bool
|
||||
|
||||
// Schedule is the periodic work this plugin declared, and Runtime is the
|
||||
// long-lived environment that runs it (private VM + kernel ticker).
|
||||
//
|
||||
// Runtime is nil when the plugin declared nothing — the common case stays
|
||||
// exactly as cheap as before: one shared state, no extra VM, no goroutine.
|
||||
// The point of it is auto check-in / token rotation: work that must happen
|
||||
// with no browser open and nobody clicking anything.
|
||||
Schedule []ScheduledTask
|
||||
Runtime *PluginRuntime
|
||||
}
|
||||
|
||||
// Plugins is the loaded plugin set, owned by the VM.
|
||||
@ -696,6 +706,30 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- schedule ----
|
||||
//
|
||||
// A plugin that declares `schedule` gets a LONG-RUNNING environment: a
|
||||
// private VM plus a kernel-driven ticker, so its periodic work (auto
|
||||
// check-in, token rotation, quota polling) happens with no browser open and
|
||||
// no operator clicking anything. Without this the only periodic work a
|
||||
// plugin could do was a front-end poll, which is exactly the "nobody is
|
||||
// looking at the dashboard" case the feature exists for.
|
||||
//
|
||||
// The parse happens here (right after boot) so a malformed schedule is
|
||||
// visible at load time rather than silently ignored.
|
||||
p.Schedule = parseSchedule(w.L).tasks
|
||||
w.L.SetTop(0)
|
||||
if len(p.Schedule) > 0 {
|
||||
p.Runtime = ps.startRuntime(p.Info.Name, code, scheduleSpec{tasks: p.Schedule})
|
||||
if p.Runtime == nil {
|
||||
// The private VM failed to boot. That must NOT silently disable the
|
||||
// plugin — hooks still work — but the operator needs to know their
|
||||
// scheduled work will never run.
|
||||
log.Printf("[scheduler] plugin %s: declared %d task(s) but no runtime could be started",
|
||||
p.Info.Name, len(p.Schedule))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
|
||||
p.UI = ui
|
||||
@ -1758,6 +1792,13 @@ func (ps *Plugins) Unload(name string) error {
|
||||
p := ps.plugins[idx]
|
||||
ps.plugins = append(ps.plugins[:idx], ps.plugins[idx+1:]...)
|
||||
ps.mu.Unlock()
|
||||
// Stop the scheduler BEFORE closing the state: a task in flight holds that
|
||||
// state, and closing it underneath would leave the task writing into a
|
||||
// closed Lua state — a crash rather than a clean unload.
|
||||
if p.Runtime != nil {
|
||||
p.Runtime.Stop()
|
||||
p.Runtime = nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.Close()
|
||||
|
||||
@ -889,6 +889,17 @@ func (v *VM) RemoveAdapter(name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// poolGlobal returns the Lua global holding a pool's returned table: plugins
|
||||
// store theirs under one global, adapters under another. Transform and friends
|
||||
// must read the SAME one boot() wrote, or a plugin-mode pool answers
|
||||
// "has no table" even though the state is perfectly healthy.
|
||||
func poolGlobal(p *adapterPool) string {
|
||||
if p != nil && p.pluginMode {
|
||||
return pluginGlobal
|
||||
}
|
||||
return adapterGlobal
|
||||
}
|
||||
|
||||
func (v *VM) pool(name string) *adapterPool {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
@ -966,7 +977,7 @@ func (v *VM) Transform(name, fn, raw string) (string, error) {
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(adapterGlobal)
|
||||
L.GetGlobal(poolGlobal(p))
|
||||
if L.IsNil(-1) {
|
||||
return "", fmt.Errorf("adapter %s has no table", name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user