mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户需求:插件需要长期运行环境(独立 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 任务压力。
369 lines
12 KiB
Go
369 lines
12 KiB
Go
// 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))
|
||
}
|