fix(lua): 定时任务改跑在插件自己的 VM 上 —— 纠正 Phase 4 的双 VM 错误

用户指出的设计错误:**一个插件 = 一个 Lua VM = 一个调度器**。而 Phase 4
(4d1407b)给定时任务开了一个**私有 VM**,于是:

    定时任务 refresh_token 写 plugin.state.token  →  写进私有 VM 的副本
    请求钩子           读 plugin.state.token      →  读主 VM 的值

轮换"成功"了,对实际流量完全无效;而且私有 VM 绕过状态持久化,token 连
磁盘都到不了。这直接违背这个功能存在的理由。

## 修正

PluginRuntime 不再持有 Lua 状态。任务通过 p.state + p.mu 运行 —— 与请求
钩子**同一个 VM、同一把锁**:

- 任务写入立即可见于钩子(TestTaskWriteIsVisibleToHooks 锁死这一点)
- 任务写入经 markDirtyLocked 走同一套持久化
- 跨插件仍并行(每插件各自 VM、各自锁)

## 由此产生的行为(有意为之,非缺陷)

- 某插件的长任务会排在**它自己**的钩子前面(同一 Lua 栈,一次一个调用者)
- 卡死处置随之改变:不再"抛弃 VM 重建"(那会丢掉累计状态——计费总额、
  签到历史,正是插件存在的意义),而是**停用该插件**:
  - checkWedged 超过 wedgedAfter(10min) 置 disarmed
  - Fire 对 disarmed 插件快速失败并记 hook_errors(否则请求会排在永不释放
    的锁后面永久挂起——比停用更糟)
  - Unload 检测 InFlight:有任务在跑时不取 p.mu(会永久挂起)、不 Close
    状态(Lua C 层 use-after-free 直接 abort)。泄漏这一个状态是有界的,
    比崩溃好。
  - maxAbandons/抛弃计数因此删除:没有"重生"了,熔断由 disarmed 承担

## 测试(10 个)

主断言从"任务跑过"改为"**任务的写入对请求路径可见**"—— 旧实现会在这里
失败。另含:无触发自执行/无 schedule 无运行时/解析/at 下次触发/错误隔离/
卸载停 goroutine/卡死停用+钩子快失败/健康任务不误判/8 插件并发各自调度。
This commit is contained in:
JianFeeeee
2026-10-11 12:59:36 +08:00
parent 57fada3b0d
commit 61f402beef
4 changed files with 463 additions and 741 deletions

View File

@ -1,40 +1,43 @@
// Package lua — 插件长期运行环境(R4-b / Phase 4)
//
// 用户需求:插件需要**长期运行环境(独立 LuaVM 线程化)**,以支持自动签到、
// token 轮换等需要"没有浏览器、没有人操作时也会发生"的能力。
// 用户需求:插件需要**长期运行环境**,以支持自动签到、token 轮换等需要
// "没有浏览器、没有人操作时也会发生"的能力。
//
// 为什么需要独立 VM:现有插件模型是「一个插件 = 一个 Lua 状态 + 一把锁」,
// 因为钩子会往 plugin.state 累加,多个状态会让总额被劈成几份(见 docs 的
// 运行时约束)。这把锁的代价是**一个卡住的钩子会卡住所有插件**——而签到、
// token 轮换这类任务天然是长耗时的网络等待。
// 模型(重要):**一个插件 = 一个 Lua VM = 一个调度器**。
//
// 本文件给出调度层:内核按插件声明的周期,在**独立 VM + 独立 goroutine** 上
// 执行它,从而:
// 钩子(请求路径)和定时任务(内核 ticker 驱动)跑在**同一个** Lua 状态上,
// 由同一把锁串行化。这不是实现细节,而是正确性要求:
//
// - 一个插件的长任务不阻塞其它插件的钩子(独立 VM,无共享锁)
// - 任务在没有浏览器、没有人操作时也会执行(内核 ticker 驱动,不是前端轮询)
// - 状态与主 VM 分离,轮换中崩溃不会带走已累计的钩子统计
// 定时任务 refresh_token 写 plugin.state.token
// 请求钩子 读 plugin.state.token
//
// 两者必须是同一个 Lua 状态,否则任务把 token 写进了一个请求永远读不到的
// 副本 —— 轮换看起来"成功了",实际对所有流量无效。同理,任务的写入必须
// 经过同一个 plugin.state,才能被状态持久化(markDirty)看到。
//
// 代价(已知且接受):某个插件的长耗时任务(网络等待)会排在它**自己**的
// 钩子前面,因为同一个 Lua 栈一次只能有一个调用者。跨插件仍然是并行的
// (每个插件各自一把锁、各自一个 VM)。
//
// 声明方式(插件侧):
//
// PLUGIN.schedule = {
// { fn = "refresh_token", every = 3600 }, -- 每小时
// { fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻
// { fn = "poll_quota", every = 300, jitter = 30 }, -- 带抖动,避免整点齐射
// { 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,不影响钩子与请求转发。
// 崩溃隔离:任务函数出错只记 hookErr,不影响钩子与请求转发。但一个**永不返回**
// 的任务会永久持有该插件的锁(Lua 调用无法中断,见 checkWedged 的能力说明),
// 因此超时后该插件被**停用**而不是"换一个 VM 重来"—— 换 VM 会把累计状态
// (计费总额、已签到的天数)一起丢掉,那比停用更糟。
package lua
import (
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
golua "github.com/aarzilli/golua/lua"
@ -52,11 +55,10 @@ type ScheduledTask struct {
// 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.
// NOTE: a running Lua call CANNOT be interrupted in this LuaJIT build (see
// the capability note in checkWedged). The timeout is therefore enforced at
// the generation level — a task that overruns wedgedAfter has its whole VM
// abandoned — not by stopping the script mid-instruction.
// Timeout is parsed for forward compatibility but is NOT enforced: a running
// Lua call cannot be interrupted in this LuaJIT build (see the capability
// note on checkWedged). Overrun is handled by disarming the plugin at the
// wedgedAfter threshold instead.
Timeout int `json:"timeout,omitempty"`
}
@ -115,112 +117,73 @@ func parseSchedule(L *golua.State) scheduleSpec {
return spec
}
// PluginRuntime owns one plugin's long-lived execution environment.
// PluginRuntime owns one plugin's periodic work.
//
// 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.
// It does NOT own a Lua state. Tasks run on the plugin's own VM (Plugin.state),
// under the plugin's own lock, exactly like a request hook — that is what makes
// a token rotated by a task immediately visible to inference traffic, and what
// makes the task's writes persist through the same state saver.
type PluginRuntime struct {
plugin string
source string // kept so a discarded generation can be re-booted from scratch
mu sync.Mutex
plugin *Plugin
// ps is the owning plugin set, needed to record task failures on the same
// hook_errors surface the request path uses.
ps *Plugins
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
mu sync.Mutex
// nextDue is when each task should next fire.
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.
// running marks a task whose goroutine has been dispatched but has not yet
// finished (it may still be waiting for the plugin lock).
running map[string]bool
// startedAt is set once the Lua call actually BEGINS (i.e. after the plugin
// lock was acquired). Measuring from dispatch would count time spent queued
// behind a slow task as if it were this task's own runtime.
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.
// failures counts consecutive failures per task.
failures map[string]int
// active counts task goroutines that have not returned.
active int
// disarmed is set once a task has overrun wedgedAfter. Scheduling stops and
// hook dispatch fails fast, so a plugin wedged by its own task degrades to
// "this plugin is out" rather than "requests hang forever behind its lock".
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.
// wedgedAfter is how long a task may run before the plugin is declared wedged.
//
// 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
// errPluginWedged is returned to hooks of a plugin whose scheduled task
// overran. It is a real error rather than a silent skip so the operator sees
// WHICH plugin stopped serving and why (it lands in hook_errors).
var errPluginWedged = errors.New("a scheduled task overran and disabled this plugin; reload it to retry")
// startRuntime boots the first generation and starts the scheduler.
// startRuntime attaches a scheduler to a plugin that declared one.
//
// 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 {
// The scheduler runs on p.state — the SAME state a hook runs on. A plugin with
// no schedule gets no runtime at all, so the common case stays exactly as cheap
// as before: one VM, no extra goroutine.
func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime {
if len(spec.tasks) == 0 {
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{}),
plugin: p,
ps: ps,
spec: spec,
nextDue: make(map[string]time.Time, len(spec.tasks)),
running: make(map[string]bool, len(spec.tasks)),
startedAt: make(map[string]time.Time, len(spec.tasks)),
failures: 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)
}
@ -228,42 +191,6 @@ func (ps *Plugins) startRuntime(name, source string, spec scheduleSpec) *PluginR
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 {
@ -308,136 +235,70 @@ func (rt *PluginRuntime) loop() {
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.
// checkWedged disarms a plugin 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.
// Capability note (measured, not assumed): a running Lua call CANNOT be
// interrupted in this golua+LuaJIT build.
//
// 1. SetExecutionLimit(200000) against a tight loop — still running after 10s.
// 2. A count hook + RaiseError against a tight loop — the hook never fired
// (LuaJIT compiles the loop to machine code; instruction hooks only fire on
// the interpreter path).
// 3. A count hook against a loop with calls/table writes — fired 20971 times,
// but RaiseError still did not return: it calls L.StackTrace(), which is the
// known SIGSEGV path in vm.go.
//
// So the response cannot be "stop it". It is: stop trusting it, stop scheduling
// more work, and make the condition visible. The state is deliberately NOT
// rebuilt — a fresh VM would discard exactly the accumulated state (billing
// totals, check-in history) that makes the plugin worth running.
func (rt *PluginRuntime) checkWedged() {
rt.mu.Lock()
gen := rt.gen
if gen == nil {
if rt.disarmed {
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
for fn, at := range rt.startedAt {
if time.Since(at) >= wedgedAfter {
wedged = append(wedged, fn)
}
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
rt.disarmed = true
rt.mu.Unlock()
for _, fn := range wedged {
rt.abandons[fn]++
rt.failures[fn]++
n := rt.abandons[fn]
if n >= maxAbandons {
rt.disarmed = true
disarmedNow = true
ps := rt.ps
if ps != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("%s: task %s overran %s; plugin disabled",
rt.pluginInfoName(), fn, wedgedAfter))
}
rt.logf("[scheduler] plugin %s task %s exceeded %s; abandoning its VM (abandon #%d/%d)",
rt.plugin, fn, wedgedAfter, n, maxAbandons)
log.Printf("[scheduler] plugin %s task %s exceeded %s; disabling the plugin "+
"(reload it to retry)", rt.pluginInfoName(), fn, wedgedAfter)
}
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.
// runDue dispatches every task whose due time has arrived. Each task runs in its
// own goroutine so that one task waiting on the plugin lock does not delay the
// due time of another — the lock, not the scheduler, is what serialises them.
func (rt *PluginRuntime) runDue() {
rt.mu.Lock()
if rt.disarmed || rt.gen == nil {
if rt.disarmed {
rt.mu.Unlock()
return
}
gen := rt.gen
now := time.Now()
var due []ScheduledTask
for _, t := range rt.spec.tasks {
@ -448,16 +309,13 @@ func (rt *PluginRuntime) runDue() {
continue
}
rt.running[t.Fn] = true
if rt.startedAt == nil {
rt.startedAt = map[string]time.Time{}
}
rt.startedAt[t.Fn] = now
rt.active++
rt.nextDue[t.Fn] = rt.nextAfter(t, now)
due = append(due, t)
}
rt.mu.Unlock()
for _, t := range due {
go rt.runOne(gen, t)
go rt.runOne(t)
}
}
@ -473,80 +331,104 @@ func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time {
return now.Add(d)
}
// runOne executes one task on the given generation.
// runOne executes one task on the plugin's own VM, under the plugin's own lock.
//
// 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()
// Holding that lock for the whole call is what makes state sharing correct — and
// it is why a task that never returns wedges its own plugin. That trade is
// deliberate: the alternative (a private VM per task) silently breaks the state
// sharing this feature exists for.
func (rt *PluginRuntime) runOne(t ScheduledTask) {
p := rt.plugin
defer func() {
rt.mu.Lock()
delete(gen.busy, t.Fn)
delete(rt.running, t.Fn)
delete(rt.startedAt, t.Fn)
rt.active--
rt.mu.Unlock()
}()
p := gen.vm.pool(gen.poolName)
if p == nil {
rt.recordFailure(t.Fn, "no pool")
rt.recordFailure(t.Fn, "plugin is gone")
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 {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == nil || p.state.L == nil {
rt.recordFailure(t.Fn, "no plugin state")
return
}
// Recorded only now: the time before the lock was acquired was spent queued
// behind another caller, and counting it would report a healthy hook as a
// wedged task.
rt.mu.Lock()
rt.startedAt[t.Fn] = time.Now()
rt.mu.Unlock()
// No interrupt is attempted; see the capability note on checkWedged.
if err := callFnOn(p.state.L, t.Fn); err != nil {
rt.recordFailure(t.Fn, err.Error())
return
}
// A task mutates plugin.state exactly as a hook does, so it must dirty the
// same way — otherwise a rotated token would live only in memory until some
// unrelated request happened to flush it.
if ps := rt.ps; ps != nil {
ps.markDirtyLocked(p)
}
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.
// recordFailure notes a task error 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...)
if ps := rt.ps; ps != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("%s: task %s failed: %s",
rt.pluginInfoName(), fn, msg))
}
log.Printf("[scheduler] plugin %s task %s failed: %s", rt.pluginInfoName(), fn, msg)
}
// Stop halts the scheduler and abandons the current generation.
func (rt *PluginRuntime) pluginInfoName() string {
if rt.plugin == nil {
return "?"
}
return rt.plugin.Info.Name
}
// InFlight reports whether any task goroutine has not yet returned. Unload uses
// it to decide whether closing the Lua state is safe: a task holds the state for
// the duration of its call, and a wedged one never releases it, so closing
// underneath would be a use-after-free in the Lua C layer (which aborts the
// process rather than returning an error). Skipping the close leaks that one
// state instead — bounded by the wedge, and strictly better than crashing.
func (rt *PluginRuntime) InFlight() bool {
if rt == nil {
return false
}
rt.mu.Lock()
defer rt.mu.Unlock()
return rt.active > 0
}
// Disarmed reports whether the plugin was disabled by an overrunning task.
func (rt *PluginRuntime) Disarmed() bool {
if rt == nil {
return false
}
rt.mu.Lock()
defer rt.mu.Unlock()
return rt.disarmed
}
// Stop halts the scheduler. It does NOT wait for in-flight tasks: a wedged task
// will never return, and Stop must not block unload forever.
func (rt *PluginRuntime) Stop() {
if rt == nil {
return
@ -555,26 +437,12 @@ func (rt *PluginRuntime) 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.
// callFnOn invokes a plugin function on an already-locked 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.
// The stack discipline mirrors Plugins.invoke: [global, guard, fn] then call
// with one argument. A return value other than an error string is 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)
@ -605,16 +473,6 @@ func callFnOn(L *golua.State, fn string) error {
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 {