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