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:
JianFeeeee
2026-10-11 10:26:39 +08:00
parent 5f6939b590
commit 4d1407bbb4
4 changed files with 801 additions and 1 deletions

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