mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。
## 为什么必须这样做(三次实测)
本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
1. SetExecutionLimit(200000) 对紧密循环 10s 未返回 —— 无效
2. 计数 hook(2000) + RaiseError 对紧密循环 fired=0 —— hook 未触发
3. 计数 hook(1000) 对「函数调用+表写」循环 fired=20971 触发,
但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径
原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。
## 模型:generation 可抛弃
PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:
- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
熔断也就永远不触发)
## 熔断(必须有)
每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。
## 开发中修掉的四个缺陷
1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。
测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
661 lines
21 KiB
Go
661 lines
21 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"`
|
||
// Timeout, in seconds, bounds one execution. 0 uses defaultTaskTimeout.
|
||
// A task that exceeds it is interrupted at the Lua instruction level — the
|
||
// only way to stop a runaway script on the C stack.
|
||
Timeout int `json:"timeout,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")
|
||
t.Timeout = readNumberInt(L, -1, "timeout")
|
||
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
|
||
source string // kept so a discarded generation can be re-booted from scratch
|
||
|
||
mu sync.Mutex
|
||
spec scheduleSpec
|
||
// gen is the CURRENT generation: one private VM plus the goroutine it runs
|
||
// on. It is replaced wholesale when a task is judged wedged.
|
||
gen *runtimeGen
|
||
// running marks per-task in-flight state for the ACTIVE generation.
|
||
running map[string]bool
|
||
nextDue map[string]time.Time
|
||
// failures counts consecutive task failures per task.
|
||
failures map[string]int
|
||
// abandons counts how many generations have been discarded for a wedged
|
||
// task. A plugin that keeps wedging is disarmed rather than respawned
|
||
// forever (see the circuit-breaker note).
|
||
abandons map[string]int
|
||
// startedAt records when each running task began, so checkWedged can tell a
|
||
// slow-but-healthy task from a wedged one.
|
||
startedAt map[string]time.Time
|
||
// disarmed is set once a plugin has wedged maxAbandons times; it stops the
|
||
// respawn loop and requires a manual reload.
|
||
disarmed bool
|
||
// pendingRespawn lists tasks that were in flight on a dropped generation, so
|
||
// the replacement can re-run them (and the abandon counter can advance).
|
||
pendingRespawn []string
|
||
// ps is the owning plugin set, needed to boot replacement generations.
|
||
ps *Plugins
|
||
|
||
stop chan struct{}
|
||
done chan struct{}
|
||
}
|
||
|
||
// runtimeGen is one disposable execution generation: a private VM plus the
|
||
// single-state pool the tasks run on.
|
||
//
|
||
// Disposability is the whole point. Because this LuaJIT build cannot interrupt a
|
||
// running Lua call (measured three ways — see the capability note at the top of
|
||
// this file), the only safe response to a task that will not return is to STOP
|
||
// WAITING for it and give the plugin a fresh VM. A generation is therefore
|
||
// treated as disposable: on a wedge the runtime drops the reference, lets the
|
||
// stuck goroutine and its state be garbage-collected once it eventually
|
||
// returns (or leaks if it never does), and boots a new one for future work.
|
||
type runtimeGen struct {
|
||
vm *VM
|
||
poolName string
|
||
// busy marks the pool's worker as checked out by a task that has not
|
||
// returned. A generation with any busy task is wedged under the drop model.
|
||
busy map[string]bool
|
||
}
|
||
|
||
// wedgedAfter is how long a task may run before the runtime declares its
|
||
// generation wedged and abandons it.
|
||
//
|
||
// It is generous because the normal case IS a slow network call: check-in
|
||
// endpoints and token refreshes routinely take seconds to tens of seconds.
|
||
// Declaring a wedge too eagerly would abandon healthy generations and, worse,
|
||
// could start a second copy of a task that is still legitimately running.
|
||
var wedgedAfter = 10 * time.Minute
|
||
|
||
// maxAbandons disarms a plugin whose tasks keep wedging.
|
||
//
|
||
// Without a ceiling, a plugin with a task that always hangs would have the
|
||
// runtime boot a fresh VM every wedgedAfter interval forever, and each abandoned
|
||
// goroutine stays alive holding a Lua state — an unbounded leak driven by the
|
||
// plugin's own bug. After this many abandons the runtime stops respawning and
|
||
// requires a manual reload, which is the same posture as a systemd unit that
|
||
// repeatedly fails to start.
|
||
const maxAbandons = 5
|
||
|
||
// startRuntime boots the first generation and starts the scheduler.
|
||
//
|
||
// 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, no goroutine.
|
||
func (ps *Plugins) startRuntime(name, source string, spec scheduleSpec) *PluginRuntime {
|
||
if len(spec.tasks) == 0 || ps.vm == nil {
|
||
return nil
|
||
}
|
||
rt := &PluginRuntime{
|
||
plugin: name,
|
||
source: source,
|
||
ps: ps,
|
||
spec: spec,
|
||
running: make(map[string]bool, len(spec.tasks)),
|
||
nextDue: make(map[string]time.Time, len(spec.tasks)),
|
||
failures: make(map[string]int, len(spec.tasks)),
|
||
abandons: make(map[string]int, len(spec.tasks)),
|
||
stop: make(chan struct{}),
|
||
done: make(chan struct{}),
|
||
}
|
||
gen := rt.bootGeneration(ps)
|
||
if gen == nil {
|
||
ps.logf("[scheduler] plugin %s: no generation could be started", name)
|
||
return nil
|
||
}
|
||
// Store it, or every later reader (checkWedged, runDue, dropGeneration) sees
|
||
// a nil generation and silently does nothing — the runtime would look alive
|
||
// while never running a single task.
|
||
rt.gen = gen
|
||
for _, t := range spec.tasks {
|
||
rt.nextDue[t.Fn] = rt.firstDue(t)
|
||
}
|
||
go rt.loop()
|
||
return rt
|
||
}
|
||
|
||
// bootGeneration creates a fresh disposable generation: a private VM with the
|
||
// plugin compiled into a single-state pool.
|
||
//
|
||
// Splitting this out is what makes the drop-and-respawn model work: a wedged
|
||
// generation is simply replaced by calling this again.
|
||
func (rt *PluginRuntime) bootGeneration(ps *Plugins) *runtimeGen {
|
||
vm := &VM{
|
||
dir: "",
|
||
pools: map[string]*adapterPool{},
|
||
// No janitor: this pool holds one permanent worker that we manage
|
||
// directly, so a reclaimer would be pure overhead.
|
||
janitorStop: make(chan struct{}),
|
||
}
|
||
w, err := bootPluginState(rt.source, rt.plugin, ps.outboundAllowlist())
|
||
if err != nil {
|
||
ps.logf("[scheduler] plugin %s: private VM boot failed: %v", rt.plugin, err)
|
||
return nil
|
||
}
|
||
// The Cond MUST be built from this pool's own mu. Handing it a fresh mutex
|
||
// makes cond.Wait unlock a mutex it never locked, which Go reports as
|
||
// "unlock of unlocked mutex" — a fatal error, not a recoverable one.
|
||
pool := &adapterPool{
|
||
name: "__pluginrt_" + rt.plugin,
|
||
script: rt.source,
|
||
static: staticInfo{name: rt.plugin, headers: map[string]string{}},
|
||
pluginMode: true,
|
||
maxW: 1,
|
||
revision: 1,
|
||
}
|
||
pool.cond = sync.NewCond(&pool.mu)
|
||
pool.idle = []*worker{w}
|
||
pool.created = 1
|
||
vm.pools[pool.name] = pool
|
||
return &runtimeGen{vm: vm, poolName: pool.name, busy: map[string]bool{}}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if j := time.Duration(t.Jitter) * time.Second; 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 task of the plugin.
|
||
func (rt *PluginRuntime) loop() {
|
||
defer close(rt.done)
|
||
tick := time.NewTicker(runtimeTick)
|
||
defer tick.Stop()
|
||
for {
|
||
select {
|
||
case <-rt.stop:
|
||
// Abandon any in-flight generation rather than waiting for it: a
|
||
// wedged task will never return, and Stop must not block forever.
|
||
rt.dropGeneration()
|
||
return
|
||
case <-tick.C:
|
||
rt.checkWedged()
|
||
rt.ensureGeneration(rt.ps)
|
||
rt.runDue()
|
||
}
|
||
}
|
||
}
|
||
|
||
// checkWedged abandons a generation whose task has overrun wedgedAfter.
|
||
//
|
||
// This is the heart of the model: instead of trying (and failing) to interrupt
|
||
// the Lua call, the runtime stops trusting that generation and boots a clean
|
||
// one. The abandoned goroutine keeps its own VM and will never touch the new
|
||
// generation's state.
|
||
func (rt *PluginRuntime) checkWedged() {
|
||
rt.mu.Lock()
|
||
gen := rt.gen
|
||
if gen == nil {
|
||
rt.mu.Unlock()
|
||
return
|
||
}
|
||
// Collect the tasks that are both in flight AND past the wedge threshold.
|
||
// A task that is merely slow (in flight but within wedgedAfter) must NOT
|
||
// count: treating it as wedged would discard a healthy generation and could
|
||
// start a second copy of work that is still legitimately running.
|
||
var wedged []string
|
||
for fn, busy := range gen.busy {
|
||
if !busy {
|
||
continue
|
||
}
|
||
started, ok := rt.startedAt[fn]
|
||
if !ok || time.Since(started) < wedgedAfter {
|
||
continue
|
||
}
|
||
wedged = append(wedged, fn)
|
||
}
|
||
if len(wedged) == 0 {
|
||
rt.mu.Unlock()
|
||
return
|
||
}
|
||
var disarmedNow bool
|
||
for _, fn := range wedged {
|
||
rt.abandons[fn]++
|
||
rt.failures[fn]++
|
||
n := rt.abandons[fn]
|
||
if n >= maxAbandons {
|
||
rt.disarmed = true
|
||
disarmedNow = true
|
||
}
|
||
rt.logf("[scheduler] plugin %s task %s exceeded %s; abandoning its VM (abandon #%d/%d)",
|
||
rt.plugin, fn, wedgedAfter, n, maxAbandons)
|
||
}
|
||
rt.mu.Unlock()
|
||
if disarmedNow {
|
||
rt.logf("[scheduler] plugin %s disarmed after %d wedges; reload the plugin to retry",
|
||
rt.plugin, maxAbandons)
|
||
}
|
||
// Drop the generation: the stuck goroutine keeps its own VM, and a clean one
|
||
// is booted by ensureGeneration on the next tick. The in-flight tasks are
|
||
// carried over so the counter can advance on the replacement.
|
||
rt.dropGeneration()
|
||
}
|
||
|
||
// dropGeneration replaces the current generation with a fresh one, leaving the
|
||
// old (possibly wedged) goroutine holding its own VM.
|
||
//
|
||
// The wedged tasks are carried over and marked due NOW: the point of a fresh
|
||
// generation is to re-run that work against clean state, and if we simply forgot
|
||
// them the abandon counter would never advance and the circuit breaker could
|
||
// never trip. The old generation's goroutine is deliberately NOT closed — the
|
||
// stuck Lua call still owns its state, and closing it underneath would be a
|
||
// use-after-free rather than a clean teardown.
|
||
func (rt *PluginRuntime) dropGeneration() {
|
||
rt.mu.Lock()
|
||
old := rt.gen
|
||
if old == nil {
|
||
rt.mu.Unlock()
|
||
return
|
||
}
|
||
// Collect the tasks that were in flight on that generation.
|
||
var carry []string
|
||
for fn, busy := range old.busy {
|
||
if busy {
|
||
carry = append(carry, fn)
|
||
}
|
||
}
|
||
rt.gen = nil
|
||
rt.running = make(map[string]bool, len(rt.spec.tasks))
|
||
rt.startedAt = map[string]time.Time{}
|
||
// Re-arm the carried tasks for immediate dispatch on the new generation.
|
||
for _, fn := range carry {
|
||
rt.nextDue[fn] = time.Now()
|
||
}
|
||
rt.pendingRespawn = carry
|
||
rt.mu.Unlock()
|
||
_ = old // kept alive by the stuck goroutine; the GC reclaims it if it returns
|
||
}
|
||
|
||
// ensureGeneration boots a replacement when the previous one was dropped.
|
||
func (rt *PluginRuntime) ensureGeneration(ps *Plugins) {
|
||
rt.mu.Lock()
|
||
need := rt.gen == nil && !rt.disarmed
|
||
rt.mu.Unlock()
|
||
if !need {
|
||
return
|
||
}
|
||
gen := rt.bootGeneration(ps)
|
||
if gen == nil {
|
||
return
|
||
}
|
||
rt.mu.Lock()
|
||
if rt.gen == nil {
|
||
rt.gen = gen
|
||
}
|
||
rt.mu.Unlock()
|
||
}
|
||
|
||
// 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.
|
||
func (rt *PluginRuntime) runDue() {
|
||
rt.mu.Lock()
|
||
if rt.disarmed || rt.gen == nil {
|
||
rt.mu.Unlock()
|
||
return
|
||
}
|
||
gen := rt.gen
|
||
now := time.Now()
|
||
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
|
||
if rt.startedAt == nil {
|
||
rt.startedAt = map[string]time.Time{}
|
||
}
|
||
rt.startedAt[t.Fn] = now
|
||
rt.nextDue[t.Fn] = rt.nextAfter(t, now)
|
||
due = append(due, t)
|
||
}
|
||
rt.mu.Unlock()
|
||
for _, t := range due {
|
||
go rt.runOne(gen, 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 given generation.
|
||
//
|
||
// A task that never returns leaves this goroutine (and the generation it holds)
|
||
// behind; that is the accepted cost of the drop model, bounded by the abandon
|
||
// counter.
|
||
func (rt *PluginRuntime) runOne(gen *runtimeGen, t ScheduledTask) {
|
||
if gen == nil {
|
||
// The generation was already abandoned between selection and dispatch.
|
||
// Nothing to run on — clear the in-flight flag so the next cycle can
|
||
// schedule this task against the replacement generation.
|
||
rt.mu.Lock()
|
||
delete(rt.running, t.Fn)
|
||
delete(rt.startedAt, t.Fn)
|
||
rt.mu.Unlock()
|
||
return
|
||
}
|
||
rt.mu.Lock()
|
||
gen.busy[t.Fn] = true
|
||
rt.running[t.Fn] = true
|
||
if rt.startedAt == nil {
|
||
rt.startedAt = map[string]time.Time{}
|
||
}
|
||
// Recorded here, not in runDue: any path that dispatches a task must have it
|
||
// tracked, or checkWedged cannot tell "still running" from "never started".
|
||
rt.startedAt[t.Fn] = time.Now()
|
||
rt.mu.Unlock()
|
||
defer func() {
|
||
rt.mu.Lock()
|
||
delete(gen.busy, t.Fn)
|
||
delete(rt.running, t.Fn)
|
||
delete(rt.startedAt, t.Fn)
|
||
rt.mu.Unlock()
|
||
}()
|
||
|
||
p := gen.vm.pool(gen.poolName)
|
||
if p == nil {
|
||
rt.recordFailure(t.Fn, "no pool")
|
||
return
|
||
}
|
||
w, err := p.acquire()
|
||
if err != nil {
|
||
rt.recordFailure(t.Fn, err.Error())
|
||
return
|
||
}
|
||
defer p.release(w)
|
||
|
||
// No interrupt is attempted: see the capability note. The deadline is
|
||
// enforced by checkWedged at the generation level instead.
|
||
if err := callFnOn(w.L, t.Fn); err != nil {
|
||
rt.recordFailure(t.Fn, err.Error())
|
||
return
|
||
}
|
||
rt.mu.Lock()
|
||
rt.failures[t.Fn] = 0
|
||
rt.abandons[t.Fn] = 0
|
||
rt.mu.Unlock()
|
||
}
|
||
|
||
// recordFailure notes a task error. A failure is visible in the hook-error
|
||
// surface so a silently broken task cannot hide.
|
||
func (rt *PluginRuntime) recordFailure(fn, msg string) {
|
||
rt.mu.Lock()
|
||
rt.failures[fn]++
|
||
rt.mu.Unlock()
|
||
rt.logf("[scheduler] plugin %s task %s failed: %s", rt.plugin, fn, msg)
|
||
}
|
||
|
||
func (rt *PluginRuntime) logf(format string, args ...interface{}) {
|
||
if f := activeLogger.Load(); f != nil {
|
||
(*f)(format, args...)
|
||
}
|
||
}
|
||
|
||
// Stop halts the scheduler and abandons the current generation.
|
||
func (rt *PluginRuntime) Stop() {
|
||
if rt == nil {
|
||
return
|
||
}
|
||
close(rt.stop)
|
||
<-rt.done
|
||
}
|
||
|
||
// 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{})]
|
||
|
||
// defaultTaskTimeout documents the intended bound for one scheduled task. It is
|
||
// NOT enforced by the Lua runtime (see the capability note at the top of this
|
||
// file); it exists so the declaration and the plan agree on the number a future
|
||
// implementation should honour.
|
||
const defaultTaskTimeout = 5 * time.Minute
|
||
|
||
// callFnOn invokes a plugin function on an ALREADY-CHECKED-OUT state.
|
||
//
|
||
// It is the runtime's counterpart to VM.Transform: Transform takes its own
|
||
// checkout, which would deadlock here because the runtime holds the only worker
|
||
// of a single-state pool. The stack discipline mirrors Transform exactly:
|
||
// [global, guard, fn] then call with one argument.
|
||
//
|
||
// A return value other than a string is deliberately discarded: a scheduled
|
||
// task's output is its side effect (a token written, an adapter updated), not a
|
||
// value the scheduler forwards anywhere.
|
||
func callFnOn(L *golua.State, fn string) error {
|
||
L.SetTop(0)
|
||
defer L.SetTop(0)
|
||
L.GetGlobal(pluginGlobal)
|
||
if L.IsNil(-1) {
|
||
return fmt.Errorf("plugin table missing")
|
||
}
|
||
plug := L.GetTop()
|
||
L.GetGlobal(hookGuardName)
|
||
if L.IsNil(-1) {
|
||
L.Pop(1)
|
||
}
|
||
L.GetField(plug, fn)
|
||
if !L.IsFunction(-1) {
|
||
return fmt.Errorf("task %s missing", fn)
|
||
}
|
||
L.PushString("{}")
|
||
if err := L.Call(1, 2); err != nil {
|
||
return err
|
||
}
|
||
if L.GetTop() >= 2 && !L.IsNil(-1) {
|
||
msg := L.ToString(-1)
|
||
L.Pop(1)
|
||
if msg != "" {
|
||
return fmt.Errorf("%s", msg)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// runInterruptible runs fn. It currently performs NO interruption — see the
|
||
// capability note at the top of this file for the three measurements showing
|
||
// that neither SetExecutionLimit nor a count-hook RaiseError stops a LuaJIT
|
||
// loop in this build. The name and signature are kept so that the call site
|
||
// stays correct once a real interrupt mechanism exists: only this function
|
||
// would change.
|
||
func runInterruptible(L *golua.State, timeout time.Duration, fn func() error) error {
|
||
return fn()
|
||
}
|
||
|
||
// randInt63n returns a pseudo-random value in [0,n) for schedule jitter. Quality
|
||
// is irrelevant here — it only needs to differ across processes.
|
||
func randInt63n(n int64) int64 {
|
||
if n <= 0 {
|
||
return 0
|
||
}
|
||
seed := time.Now().UnixNano() ^ int64(n*2654435761)
|
||
x := uint64(seed)
|
||
x ^= x << 13
|
||
x ^= x >> 7
|
||
x ^= x << 17
|
||
return int64(x % uint64(n))
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// PluginTasks lists the scheduled task names a plugin declared (for diagnostics
|
||
// and for the WebUI to show what periodic work exists).
|
||
func (p *Plugin) PluginTasks() []string {
|
||
out := make([]string, 0, len(p.Schedule))
|
||
for _, t := range p.Schedule {
|
||
out = append(out, t.Fn)
|
||
}
|
||
return out
|
||
}
|