feat(scheduler): L4 也归内核级插件 —— WebUI 终止按钮可用“立即打断”

上一提交把 L4 写成“内核独占(panic / selfip)”,漏了内核级插件这类来源。
用户澄清:**内核级插件应当能声明 L4,用于实现中断能力**,例如 WebUI 的终止按钮。

判据(两道闸,纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3。在这里夹而不是只按 source 判,
     是因为 source 是插件自报字段、可以冒名;本函数所在位置能确知“来自外部进程”。
  2. core:isKernelLevelSource(source) 查 pluginReg.IsBuiltinPlugin,只有编译期内置
     插件(init() 自注册的工厂)才承认 L4。
source 约定 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段——
否则带设备身份的 WebUI 来源会被误判成外部插件而拿不到 L4。

改动:
- core: interruptLevel(evt, privileged bool);新增 isKernelLevelSource;
  requestPreempt 不再夹取(级别已由 interruptLevel 解析,否则内核级插件的 L4 被削掉)。
- eventloop: 传入 a.isKernelLevelSource(evt.Source)。
- proc 桥: 新增 clampExternalPriority,pubSdkInjectOpts 一律夹取。
- internal/sdk: 再导出 PriorityL1..L4(内置插件用 sdk.PriorityL4)。
- webui handleChatInterrupt(终止按钮)声明 PriorityL4。
- timer 声明 PriorityL3:定时器是“时钟那种实时工作”,比 QQ 那类可无限等待的
  异步消息高(L1)——这是对用户“它不是时钟那种实时工作”的直接推论,可改。
- 测试: L4 特权矩阵(非特权夹取 / 特权承认)、source 判据(内置、内置/实例、
  外部、空、前缀不误匹配)、内核级插件 L4 一路到达调度器、proc 夹取两条。
- 设计稿 §2/§3.2/§11.1/§14/§15 按“L4 = 内核 + 内核级插件”更正。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿(含 webui/timer)。
This commit is contained in:
JianFeeeee
2026-09-13 07:18:03 +08:00
parent cb32032f76
commit f3232000f4
10 changed files with 260 additions and 38 deletions

View File

@ -726,7 +726,25 @@ type injectMediaParams struct {
// 与旧三参数注入等价。
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
return pubsdk.InjectOptions{
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName, Priority: priority,
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
Priority: clampExternalPriority(priority),
}
}
// clampExternalPriority 把外部插件声明的优先级夹到 L1..L3。
//
// 走本桥的必然是外部插件(独立进程/动态库),它们**不是内核级插件**
// 因此不能声明 L4——“立即打断”那类能力只属于编译期内置插件如 WebUI 终止按钮)。
//
// 为什么在这里夹而不是只在内核里按 source 判source 是插件自报的字段,
// 外部插件可以冒用 "webui" 之名;而本函数所在的位置能确知“这来自外部进程”。
// 内核侧的 isKernelLevelSource 是第二道闸(纵深防御)。
func clampExternalPriority(priority string) string {
switch priority {
case "L4", "l4":
return pubsdk.PriorityL3
default:
return priority
}
}

View File

@ -0,0 +1,48 @@
package proc
// 外部插件(走 proc 桥的独立进程/动态库)**不是内核级插件**
// 因此不能声明 L4 —— “立即打断”能力只属于编译期内置插件(如 WebUI 终止按钮)。
//
// 在这里夹取而不是只在内核里按 source 判,是因为 source 是插件自报字段、可以冒名;
// 本函数所在位置能确知“这来自外部进程”。内核侧的 isKernelLevelSource 是第二道闸。
import (
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func TestClampExternalPriority_RejectsL4(t *testing.T) {
cases := []struct {
in string
want string
}{
{"L4", pubsdk.PriorityL3}, // 越权 → 夹到 L3
{"l4", pubsdk.PriorityL3}, // 大小写都要夹
{"L3", pubsdk.PriorityL3},
{"L2", pubsdk.PriorityL2},
{"L1", pubsdk.PriorityL1},
{"", ""}, // 未声明保持空(内核按默认级处理)
{"紧急", "紧急"}, // 未知值原样传给内核,由内核降级为 L1 并留痕
{"L9", "L9"}, // 同上
}
for _, c := range cases {
if got := clampExternalPriority(c.in); got != c.want {
t.Fatalf("clampExternalPriority(%q)=%q期望 %q", c.in, got, c.want)
}
}
}
// 贯穿 pubSdkInjectOptsRPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
got := pubSdkInjectOpts(true, "prune", "cleaner", "L4")
if got.Priority != pubsdk.PriorityL3 {
t.Fatalf("经桥后的优先级=%q期望 L3", got.Priority)
}
if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" {
t.Fatalf("其它字段被改动:%+v", got)
}
if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
}
}