Files
ModelRouter/internal/lua/plugin_runtime.go
JianFeeeee 9016eda905 feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
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/压力/抛弃换代/熔断/健康任务不受影响)。
2026-10-11 10:40:58 +08:00

661 lines
21 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"`
// 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
}