Files
ModelRouter/internal/lua/plugin_runtime.go
JianFeeeee 4d1407bbb4 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 任务压力。
2026-10-11 10:26:39 +08:00

369 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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))
}