feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM

用户需求:插件需要长期运行环境(独立 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 任务压力。
This commit is contained in:
JianFeeeee
2026-10-11 10:26:39 +08:00
parent 5f6939b590
commit 4d1407bbb4
4 changed files with 801 additions and 1 deletions

View File

@ -243,6 +243,16 @@ type Plugin struct {
// is still the compiled-in default would immediately overwrite the file it
// was supposed to inherit from.
persisted bool
// Schedule is the periodic work this plugin declared, and Runtime is the
// long-lived environment that runs it (private VM + kernel ticker).
//
// Runtime is nil when the plugin declared nothing — the common case stays
// exactly as cheap as before: one shared state, no extra VM, no goroutine.
// The point of it is auto check-in / token rotation: work that must happen
// with no browser open and nobody clicking anything.
Schedule []ScheduledTask
Runtime *PluginRuntime
}
// Plugins is the loaded plugin set, owned by the VM.
@ -696,6 +706,30 @@ func (ps *Plugins) LoadSource(name, code string) error {
}
}
// ---- schedule ----
//
// A plugin that declares `schedule` gets a LONG-RUNNING environment: a
// private VM plus a kernel-driven ticker, so its periodic work (auto
// check-in, token rotation, quota polling) happens with no browser open and
// no operator clicking anything. Without this the only periodic work a
// plugin could do was a front-end poll, which is exactly the "nobody is
// looking at the dashboard" case the feature exists for.
//
// The parse happens here (right after boot) so a malformed schedule is
// visible at load time rather than silently ignored.
p.Schedule = parseSchedule(w.L).tasks
w.L.SetTop(0)
if len(p.Schedule) > 0 {
p.Runtime = ps.startRuntime(p.Info.Name, code, scheduleSpec{tasks: p.Schedule})
if p.Runtime == nil {
// The private VM failed to boot. That must NOT silently disable the
// plugin — hooks still work — but the operator needs to know their
// scheduled work will never run.
log.Printf("[scheduler] plugin %s: declared %d task(s) but no runtime could be started",
p.Info.Name, len(p.Schedule))
}
}
// ---- UI ----
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
p.UI = ui
@ -1758,6 +1792,13 @@ func (ps *Plugins) Unload(name string) error {
p := ps.plugins[idx]
ps.plugins = append(ps.plugins[:idx], ps.plugins[idx+1:]...)
ps.mu.Unlock()
// Stop the scheduler BEFORE closing the state: a task in flight holds that
// state, and closing it underneath would leave the task writing into a
// closed Lua state — a crash rather than a clean unload.
if p.Runtime != nil {
p.Runtime.Stop()
p.Runtime = nil
}
p.mu.Lock()
if p.state != nil && p.state.L != nil {
p.state.L.Close()