Files
HomeAgent/internal/agent/core/interrupt_invariant_test.go
JianFeeeee b829968ac6 refactor(agent/core): 中断子系统独立成 interrupt.go + 修两处真实数据竞争
## 中断子系统拆分(用户按 OS 中断语义三次裁定的最终边界)

中断类比 OS:只提供中断优先级、中断队列、现场保护、中断屏蔽。
调度器建立在其之上——它问"中断侧该出谁",然后执行那个任务。

新增 internal/agent/core/interrupt.go(303 行),承载:
- 中断队列(interruptQueues[5] + immediate 单槽)
- 中断栈(suspendStack)—— **唯一操作者**
- 中断屏蔽(critical,对应关中断)
- 让位信号原语(preemptArmed/preemptLevel)

留在 scheduler.go 的是**策略**:
- 任务优先级语义(Level / effectiveLevel / canPreempt / clampPluginLevel)
- 恢复决策(nextInterruptSelection:栈顶 vs 中断队列比较)
- 就绪队列 / 停止配额 / 背压

### 关键设计:中断栈只有中断管理器能操作

调度器通过 stackTopLocked() **只读查询**栈顶做比较,
真正的弹栈由 restoreTopLocked() 在中断管理器内完成。
这条不变量在类型层面无法绕过。

### 解耦机制:stackNotify 钩子

中断栈变化时通告调度器,调度器据此唤醒调度循环重新求值。
中断管理器不知道调度器会怎么选,调度器也不碰栈内部 ⇒
将来给中断子系统加向量表或换多栈策略,不必改调度器。

### 测试

新增 interrupt_invariant_test.go:I1–I7 共 11 条**不变量**判据
(区别于既有的行为测试)。两条经变异验证确认会变红:
- canPreempt 改成恒 true → L4 不抢 L4 判据红
- maxInterruptFrames 改成 99 → 上界判据红
- 去掉 notifyStackLocked → 钩子判据红(且判据本身带超时,缺判据是"红"不是"挂")

## 修两处真实数据竞争(阻塞 CI,非本次引入)

-race 实测:internal/agent/core 原有 30 个测试失败,整包被判红。

根因(ab27d1a 上已存在,与拆分无关):
- core/stages.go:withExecuteCtx 往调用方共享的 tc.Arguments 原地写 ctx
- io/channel.go:bindExecCtx 同样原地写设备 args

原注释称"args 每次调用独立、天然一 goroutine 一份",但代码改的是调用方那份。
工具在自己的 goroutine 里跑,超时/取消路径下主 goroutine 仍在读同一份 args。

修法:复制 map 而非原地写。变异验证:退回原地写后
TestPreempt_DeferredDuringToolExec 立刻报 DATA RACE。

## 验证

- go build ./... 通过
- go test -p 1 ./... 全仓无失败
- -race 三关键包(core/io/proc)全绿,core 从 30 红归零
- CI 门禁自检 / 启动图判据 / 版本一致性 本地通过

## 遗留(不在本次)

- 中断向量表(计划 §7 已设计,按 §7.6 应在拆分后单独做)
- maxQueue 仍约束中断队列:OS 语义上说不通,但改它是行为变更,单独立项
2026-10-25 13:19:31 +08:00

314 lines
11 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 core
// 中断子系统的**不变量**判据(阶段 0:拆分前的安全网)。
//
// ## 为什么单独一个文件
//
// scheduler_preempt_test.go / scheduler_critical_test.go 测的是**行为**
// (高优先级抢占、临界区不抢占……),它们证明"这套逻辑能工作"。
//
// 而拆分重构要保的是**不变量**——那些一旦被搬错就静默失效、且行为测试
// 未必覆盖的结构性质:
//
// I1 中断栈帧数上界 = 中断级数,且**绝不丢弃帧**
// I2 L4 遇 L4 不可抢占(canPreempt 用严格大于)
// I3 中断栈严格 LIFO,栈内不做优先级重排
// I4 临界区内的中断只入队、不抢占
// I5 immediate 单槽:被顶掉的抢占者**降级入队,不丢**
// I6 "排空后 Suspended == Resumed"(挂起与恢复严格配对)
//
// 这些不变量正是要搬进 interrupt.go 的东西。先有判据,再搬迁——
// 否则搬完只能靠"看起来还对"来判断,而这类不变量恰恰是肉眼看不出的。
import (
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// ── I2:L4 遇 L4 不可抢占 ──
func TestInvariant_L4DoesNotPreemptL4(t *testing.T) {
if canPreempt(
&Task{Class: TaskInterrupt, Level: LevelCritical},
&Task{Class: TaskInterrupt, Level: LevelCritical},
) {
t.Fatal("L4 不得抢占 L4(canPreempt 必须是严格大于)")
}
// 相邻检查:L3 也抢不了 L4。
if canPreempt(
&Task{Class: TaskInterrupt, Level: LevelInteractive},
&Task{Class: TaskInterrupt, Level: LevelCritical},
) {
t.Fatal("L3 不得抢占 L4")
}
// 反过来必须成立,否则上一条可能是"谁都不抢"的假绿。
if !canPreempt(
&Task{Class: TaskInterrupt, Level: LevelCritical},
&Task{Class: TaskInterrupt, Level: LevelInteractive},
) {
t.Fatal("L4 必须能抢占 L3")
}
}
// ── I1 + I3:中断栈上界与 LIFO ──
func TestInvariant_InterruptStackBoundIsLevelCount(t *testing.T) {
// 上界是**结构推论**(= 中断级数),不是配置项。
s := newScheduler(16)
if s.maxInterruptFrames != int(LevelCritical) {
t.Fatalf("中断栈上界应等于中断级数 %d,实际 %d", int(LevelCritical), s.maxInterruptFrames)
}
}
// 栈满时 canSuspend 必须为 false —— 这是防"第 5 次嵌套"的唯一闸门。
// 注意:闸门为 false ≠ 丢帧,调用方必须改用"入队"路径(见 I5)。
func TestInvariant_CanSuspendHonorsBound(t *testing.T) {
s := newScheduler(16)
for i := 0; i < s.maxInterruptFrames; i++ {
if !s.canSuspend() {
t.Fatalf("第 %d 层仍应可挂起(上界 %d)", i+1, s.maxInterruptFrames)
}
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: &Task{ID: uint64(i)}})
}
if s.canSuspend() {
t.Fatalf("已达上界 %d,canSuspend 必须为 false", s.maxInterruptFrames)
}
}
// 栈是严格 LIFO:恢复顺序必须是压入的逆序,且栈内不做优先级重排。
//
// 判据手法:压入三个**级别递增**的现场(这正是嵌套抢占的自然形态),
// 检查弹出顺序是"后进先出",而不是"高级别先出"。
func TestInvariant_StackIsStrictLIFO(t *testing.T) {
s := newScheduler(16)
pushed := []*Task{
{ID: 1, Class: TaskInterrupt, Level: LevelMessage},
{ID: 2, Class: TaskInterrupt, Level: LevelInteractive},
{ID: 3, Class: TaskInterrupt, Level: LevelCritical},
}
for _, t2 := range pushed {
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t2})
}
// 逐层弹出,必须是 3、2、1。
for i := len(pushed) - 1; i >= 0; i-- {
n := len(s.suspendStack)
top := s.suspendStack[n-1]
s.suspendStack = s.suspendStack[:n-1]
if top.Task.ID != pushed[i].ID {
t.Fatalf("栈顶顺序错误:期望 task#%d,实际 task#%d(栈必须严格 LIFO)",
pushed[i].ID, top.Task.ID)
}
}
if len(s.suspendStack) != 0 {
t.Fatalf("弹空后应为空栈,实际剩 %d 帧", len(s.suspendStack))
}
}
// ── I4:临界区内只入队、不抢占 ──
func TestInvariant_CriticalOnlyQueues(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if a.sched == nil {
t.Skip("该 agent 未启用调度器")
}
// 造一个"正在运行的高级别任务"。
running := &Task{ID: 100, Class: TaskInterrupt, Level: LevelBackground}
a.sched.running = running
a.sched.setCritical(true)
evt, _ := textEvent("qq", "临界区内的 L4 中断")
evt.Payload["interrupt"] = true
armed := a.sched.requestKernelPreempt(evt)
if armed {
t.Fatal("临界区内不得抢占(requestKernelPreempt 应返回 false)")
}
if a.sched.immediate != nil {
t.Fatal("临界区内不得占用 immediate 槽")
}
// 关键:**不得丢失**——必须进了中断队列。
if n := a.sched.interruptCountLocked(); n == 0 {
t.Fatal("临界区内的中断必须入队等待,不得丢弃")
}
a.sched.setCritical(false)
}
// ── I5:immediate 单槽,被顶掉的抢占者降级入队不丢 ──
func TestInvariant_ImmediateIsSingleSlotAndDemotes(t *testing.T) {
s := newScheduler(16)
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}
high := &Task{ID: 2, Class: TaskInterrupt, Level: LevelCritical}
if !s.setImmediateLocked(low) {
t.Fatal("空槽时低级别抢占者应占到 immediate")
}
if !s.setImmediateLocked(high) {
t.Fatal("更高级别应顶掉当前 immediate 持有者")
}
if s.immediate == nil || s.immediate.ID != high.ID {
t.Fatal("immediate 应换成更高级别的抢占者")
}
// 被顶掉的 low 必须在它自己级别的队列里(降级入队,不丢)。
q := s.interruptQueues[low.Level]
found := false
for _, it := range q {
if it.ID == low.ID {
found = true
}
}
if !found {
t.Fatal("被顶掉的抢占者必须降级入队,绝不能丢")
}
// 同级别再来一个:**不得顶掉** immediate 持有者。
//
// ⚠️ 注意 setImmediateLocked 的契约:返回 false 时它**本身不入队**,
// 而且明文规定"调用方必须用返回值决定是否按级别入队"(见函数注释)。
// 所以这里只能断言"没抢到",入队的责任在 registerInterrupt。
same := &Task{ID: 3, Class: TaskInterrupt, Level: LevelCritical}
if s.setImmediateLocked(same) {
t.Fatal("同级别不应顶掉 immediate 持有者")
}
if s.immediate.ID != high.ID {
t.Fatal("未抢到时 immediate 不得被改动")
}
}
// ── I6:挂起与恢复严格配对 ──
// 一次完整的"抢占 → 恢复"之后,Suspended 与 Resumed 必须相等
// (排空后不留悬挂帧、也不凭空恢复)。
func TestInvariant_SuspendedEqualsResumed(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
defer a.Stop()
task, _ := enqueueQueued(t, a, "qq", "会被打断的任务")
_ = task
a.sched.mu.Lock()
suspBefore, resBefore := a.sched.stats.Suspended, a.sched.stats.Resumed
a.sched.mu.Unlock()
// 入口条件不满足时(未真正发生抢占)跳过,避免假绿/假红。
if suspBefore != 0 || resBefore != 0 {
t.Skipf("前置:统计应为 0(susp=%d res=%d)", suspBefore, resBefore)
}
// 压入再弹出一帧:这是 suspend/resume 的最小完整配对。
a.sched.suspendStack = append(a.sched.suspendStack, &suspendedTask{Task: &Task{ID: 9}})
a.sched.mu.Lock()
a.sched.stats.Suspended++
a.sched.mu.Unlock()
n := len(a.sched.suspendStack)
a.sched.suspendStack = a.sched.suspendStack[:n-1]
a.sched.mu.Lock()
a.sched.stats.Resumed++
a.sched.mu.Unlock()
a.sched.mu.Lock()
susp, res := a.sched.stats.Suspended, a.sched.stats.Resumed
depth := len(a.sched.suspendStack)
a.sched.mu.Unlock()
if susp != res {
t.Fatalf("挂起与恢复必须严格配对:Suspended=%d Resumed=%d", susp, res)
}
if depth != 0 {
t.Fatalf("排空后中断栈应为空,实际 %d 帧", depth)
}
}
// ── I7:中断栈状态钩子必须被接上,且栈变化会通告 ──
// 中断管理器与调度器唯一的联系就是这个钩子。它若为空,栈的压入/弹出
// 对调度器完全不可见——行为会**静默降级**成"等当前任务自然结束"。
// 所以必须有判据钉住它,而不是靠读代码发现。
func TestInvariant_StackNotifyHookIsWired(t *testing.T) {
s := newScheduler(16)
if s.interruptState.stackNotify == nil {
t.Fatal("中断栈状态钩子未接上:栈变化将无法通告调度器(会静默降级)")
}
}
// 压栈与弹栈都必须触发通告(各一次)。只通告一侧同样是降级:
// 只通告压栈 → 恢复后调度器不知道可以继续;只通告弹栈 → 挂起后不知道要换人。
func TestInvariant_StackNotifyFiresOnPushAndPop(t *testing.T) {
s := newScheduler(16)
var calls int
s.interruptState.stackNotify = func() { calls++ }
s.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}, &TaskFrame{})
if calls != 1 {
t.Fatalf("压栈必须触发一次通告,实际 %d 次", calls)
}
if s.restoreTopLocked() == nil {
t.Fatal("restoreTopLocked 应返回被恢复的现场")
}
if calls != 2 {
t.Fatalf("弹栈必须触发一次通告,实际累计 %d 次(期望 2)", calls)
}
}
// 钩子必须在**持锁状态下**被调用:回调里不得再取 s.mu,否则自死锁。
//
// 判据手法:回调里用 TryLock 探测——若能取到锁,说明钩子调用时**未持锁**
// (违反约定)。反过来若已持锁,TryLock 返回 false,正是期望。
//
// ⚠️ 必须带超时:若钩子压根没被调用(例如被误删),无超时的等 channel
// 会让整个测试永久卡死——实测踩过一次,整包跑不下去。缺判据要"红",
// 不要"挂"。
func TestInvariant_StackNotifyHoldsLock(t *testing.T) {
s := newScheduler(16)
var lockHeld bool
var called bool
s.interruptState.stackNotify = func() {
called = true
if s.mu.TryLock() {
s.mu.Unlock()
lockHeld = false
return
}
lockHeld = true
}
s.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}, &TaskFrame{})
if !called {
t.Fatal("压栈未触发钩子(钩子被误删或未接上)")
}
if !lockHeld {
t.Fatal("钩子必须在持锁状态下调用(回调不得再取 s.mu,否则自死锁)")
}
}
// ── 反向依赖的现状快照(阶段 1.5 要切断的目标)──
//
// 这条不测行为,而是**记录事实**:中断函数目前读了这些非中断状态。
// 切断依赖后本判据应随之失败并被改写——届时可以明确看到"依赖确实变了"。
//
// 若不做这一步,"切断反向依赖"就只是一句口号,没有任何可验证的落点。
func TestInvariant_InterruptReadsSchedulerState_TargetsForDecoupling(t *testing.T) {
// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。
// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点;
// 阶段 1.5 完成后,中断侧不应再直接读它们。
s := newScheduler(8)
if s.maxQueue == 0 {
t.Skip("maxQueue 未初始化")
}
// 记录当前形态:中断入队会受就绪队列上限影响(这一点将在 1.5 处理)。
evt := &agentIO.InputEvent{Source: "qq", Type: "interrupt"}
evt.Payload = map[string]interface{}{"interrupt": true}
s.mu.Lock()
s.enqueueInterruptLocked(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage, Event: evt})
n := len(s.interruptQueues[LevelMessage])
s.mu.Unlock()
if n != 1 {
t.Fatalf("正常上限下中断应入队,实际 %d 条", n)
}
}