mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
5 Commits
v1.3.11
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 48385858f7 | |||
| bc5bfd6823 | |||
| 8313120d2a | |||
| 5f63f6ec6c | |||
| a13504be38 |
@ -404,9 +404,30 @@ func (a *Agent) Start() {
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
// (设计 §7 I5、§11.3 X2/X4)。必须在 cancel 之前做:cancel 会让调度器直接 return。
|
||||
a.drainPendingInterrupts("agent_stopped")
|
||||
a.cancel()
|
||||
}
|
||||
|
||||
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
|
||||
// skipped 终态(复用 emitSkippedReply:非阻塞写,不对外发 agent_output 事件)。
|
||||
func (a *Agent) drainPendingInterrupts(reason string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
pending := a.sched.pendingEvents()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, evt := range pending {
|
||||
a.emitSkippedReply(evt, reason)
|
||||
}
|
||||
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
|
||||
}
|
||||
|
||||
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||
//
|
||||
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||
|
||||
@ -331,13 +331,20 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次
|
||||
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
|
||||
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
log.Printf("[agent] ResponseCh 已满,终态回执被丢弃(request=%s,可能违反不变量 I5)", evt.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -198,8 +198,13 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||
|
||||
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||
//
|
||||
// inboundChannelName 只拼名字,不产生副作用(注销路径要用它算出同一个名字,
|
||||
// 不能再去调 residentInboundChannel——那会顺手把刚摘掉的登记又写回去)。
|
||||
func inboundChannelName(childID string) string { return "child/" + childID }
|
||||
|
||||
func (a *Agent) residentInboundChannel(childID string) string {
|
||||
ch := "child/" + childID
|
||||
ch := inboundChannelName(childID)
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
// 归属父自己:它是父的入站 inputch。
|
||||
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
|
||||
@ -246,6 +251,14 @@ func (a *Agent) teardownResident(rc *residentChild) {
|
||||
for _, ch := range rc.inputChs {
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
// 注销"父接收该子消息"的入站 inputch(child/<id>)。
|
||||
//
|
||||
// 它由 residentInboundChannel 在 create 时登记(Owner=父),销毁时必须
|
||||
// 一并摘掉:登记表是共享的、按 name 全局唯一,残留会随 create/destroy
|
||||
// 次数单调累积脏数据。实测:destroy 后 child/<id> 仍挂在根 agent 名下,
|
||||
// 而外部没有任何工具能单独注销 inputch,只能重启 homed 清。
|
||||
// 注意用纯函数算名字,不要再走 residentInboundChannel(会重新登记)。
|
||||
reg.Unregister(inboundChannelName(rc.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -142,6 +142,11 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||||
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||||
}
|
||||
// 父的入站 inputch(child/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
|
||||
// HomeAgent 实测:destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
|
||||
if inbound, ok := reg.Lookup("child/child-1"); ok {
|
||||
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
|
||||
}
|
||||
if err := parent.DestroyResident("child-1"); err == nil {
|
||||
t.Fatal("重复销毁应报错")
|
||||
}
|
||||
@ -159,6 +164,9 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||||
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||||
}
|
||||
if _, ok := reg.Lookup(inboundChannelName(id)); ok {
|
||||
t.Fatalf("父退出后子 %s 的入站 inputch 应被注销", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -229,6 +229,10 @@ type SchedulerStats struct {
|
||||
Executed uint64
|
||||
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
|
||||
Rejected uint64
|
||||
// Backpressure 是就绪队列满、输入被挡回 channel 的次数
|
||||
// (设计 §4.4 / §11.4 Q4:满时阻塞发送方,**必须计数并打日志**)。
|
||||
// 与 Rejected 的区别:Rejected 是「丢了」,Backpressure 是「暂时不收、发送方在等」。
|
||||
Backpressure uint64
|
||||
// Suspended / Resumed 是挂起与恢复的次数。
|
||||
// 不变量:系统排空后 Suspended == Resumed(挂起必然被恢复),
|
||||
// 因此两者各自只在**一处**计数(suspend / resumeTask)。
|
||||
@ -284,7 +288,13 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||
Rejected: snap.Stats.Rejected,
|
||||
Suspended: snap.Stats.Suspended,
|
||||
Resumed: snap.Stats.Resumed,
|
||||
Preempted: snap.Stats.Suspended,
|
||||
Backpressure: snap.Stats.Backpressure,
|
||||
}
|
||||
// Preempted 是「各级抢占成功次数之和」,**不是** Suspended:受害者可能在
|
||||
// 让位信号生效前就自行结束,此时有抢占而没有挂起(见 PreemptsByLevel 注释)。
|
||||
// 此前这里直接拿 Suspended 顶替,导致 DTO 里 preempted 与 preempts_by_level 自相矛盾。
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
out.Preempted += snap.Stats.PreemptsByLevel[lv]
|
||||
}
|
||||
if snap.Running != nil {
|
||||
out.Running = &sdk.SchedulerTask{
|
||||
@ -320,6 +330,9 @@ type scheduler struct {
|
||||
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
|
||||
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
|
||||
critical atomic.Bool
|
||||
// backpressured 记录「就绪队列满」这一状态的翻转,用于只打一次日志。
|
||||
// 满着的时候 pumpInbox 每轮都会走到,逐轮打日志会把日志刷爆。
|
||||
backpressured bool
|
||||
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||
wake chan struct{}
|
||||
@ -381,6 +394,28 @@ func (s *scheduler) hasRoom() bool {
|
||||
return len(s.queue) < s.maxQueue
|
||||
}
|
||||
|
||||
// noteBackpressure 记一次背压,并报告这是否是「从有空间 → 满」的翻转。
|
||||
//
|
||||
// 为什么需要翻转信息:满的时候每轮泵入都会调用本函数,逐轮打日志会刷爆;
|
||||
// 而设计 §4.4 要求「必须计数并打日志」——两者靠这个布尔量同时满足。
|
||||
func (s *scheduler) noteBackpressure() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stats.Backpressure++
|
||||
if s.backpressured {
|
||||
return false
|
||||
}
|
||||
s.backpressured = true
|
||||
return true
|
||||
}
|
||||
|
||||
// clearBackpressure 在就绪队列重新可收(泵空)时复位翻转标记。
|
||||
func (s *scheduler) clearBackpressure() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.backpressured = false
|
||||
}
|
||||
|
||||
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||
s.seq++
|
||||
@ -490,10 +525,22 @@ func (s *scheduler) interruptCountLocked() int {
|
||||
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||
//
|
||||
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
// 返回 true 表示 t **确实占住了 immediate 槽**;false 表示它被降级进了自己的
|
||||
// 级别队列(immediate 是单槽,这是设计要求的降级分支,见设计 §2「至多一个」)。
|
||||
//
|
||||
// 调用方必须用返回值决定是否计入 PreemptsByLevel:那条计数器的语义是
|
||||
// 「进入 immediate 的次数」,被降级的中断从未进过 immediate。
|
||||
// setImmediateLocked 尝试把 t 放进 immediate 槽。
|
||||
//
|
||||
// 返回 true:t 已占住 immediate(若原有抢占者被顶掉,它**已被**降级入队)。
|
||||
// 返回 false:t 没有进 immediate,且本函数**未动 t** —— 调用方负责按级别入队。
|
||||
//
|
||||
// 把「降级入队」的责任留给调用方,是为了让「到底入队了几次」只有一个出口:
|
||||
// 早先由本函数在返回 false 前自行入队,调用方又照着 false 再入一次,
|
||||
// 同一任务就会在队列里出现两份(实测:中断任务被执行两次、Executed 虚高)。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) bool {
|
||||
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||
s.enqueueInterruptLocked(t)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if s.immediate != nil {
|
||||
s.enqueueInterruptLocked(s.immediate)
|
||||
@ -501,6 +548,7 @@ func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.immediate = t
|
||||
return true
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
@ -571,11 +619,16 @@ func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||
arm := false
|
||||
if !critical && canPreempt(t, running) {
|
||||
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
s.setImmediateLocked(t)
|
||||
// 只有**真的占住 immediate 槽**才算一次抢占,才计入 PreemptsByLevel:
|
||||
// immediate 是单槽,若它被另一个更高级的抢占者占着,t 会走上而下的
|
||||
// 「否则入队」分支——那种情况 t 从未进入 immediate(否则同一安全点前
|
||||
// 到达两条同级中断时该计数会高估)。
|
||||
if s.setImmediateLocked(t) {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !arm {
|
||||
@ -606,6 +659,54 @@ func (s *scheduler) clearPreempt() {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// rearmPending 在**安全点重新求值**中断队列(设计 §4.3 / §5.2)。
|
||||
//
|
||||
// 为什么必须有这一步:中断只在 registerInterrupt 里被武装一次,而那一刻运行任务
|
||||
// 可能正在临界区(S_TOOL_EXEC / ONNX / CAS)或处于抢占冷却期,于是请求只能入队。
|
||||
// 若安全点不再回头看队列,它就永远等不到执行——只能等当前任务**自然结束**,
|
||||
// 这违背设计承诺的「临界区期间到达的抢占请求……在临界区结束后的第一个安全点
|
||||
// 重新求值」。可复现症状:WebUI 终止按钮连按两次,第二次(落在 2s 冷却窗内)
|
||||
// 入队后再也不会被求值,「终止」看起来没反应。
|
||||
//
|
||||
// 判据与 registerInterrupt **完全同一套**(canPreempt + 冷却 + 临界区闸门),
|
||||
// 因此不会凭空制造设计之外的抢占。
|
||||
func (s *scheduler) rearmPending() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 已有让位信号、或 immediate 槽已被占用:下一个安全点的选择已经在路上,
|
||||
// 不必(也不该)重复武装。
|
||||
if s.preemptArmed || s.immediate != nil || s.running == nil || s.critical.Load() {
|
||||
return
|
||||
}
|
||||
// 冷却期内不武装:与 registerInterrupt 同一判据(抗饥饿)。
|
||||
if !s.running.LastPreemptAt.IsZero() && time.Since(s.running.LastPreemptAt) < preemptCooldown {
|
||||
return
|
||||
}
|
||||
// 中断队列本就按级别组织:从最高级往下找第一条能抢占的队头。
|
||||
// (队列里的任务有效级恒等于基础级,故「第一条能抢」= 最高级可抢占者。)
|
||||
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||
q := s.interruptQueues[lv]
|
||||
if len(q) == 0 {
|
||||
continue
|
||||
}
|
||||
t := q[0]
|
||||
if !canPreempt(t, s.running) {
|
||||
continue
|
||||
}
|
||||
s.popInterruptLocked(lv)
|
||||
if s.setImmediateLocked(t) {
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
} else {
|
||||
// immediate 槽没拿到(理论上进不来,顶部已判 immediate == nil):放回队列,
|
||||
// 否则任务会凭空消失。
|
||||
s.enqueueInterruptLocked(t)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// suspend 保存现场。
|
||||
//
|
||||
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||
@ -638,6 +739,37 @@ func (s *scheduler) canSuspend() bool {
|
||||
return len(s.suspendStack) < s.maxInterruptFrames
|
||||
}
|
||||
|
||||
// pendingEvents 收集**尚未执行**(排队队列 / 四条中断队列 / immediate)与
|
||||
// **已挂起**(中断栈)任务所携带的、且带同步回执通道的输入事件。
|
||||
//
|
||||
// 用途只有一个:停机收尾。这些任务不会再被调度,若不给它们补终态,
|
||||
// 无超时的同步注入方(cli / clawhubadapter)会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func (s *scheduler) pendingEvents() []*agentIO.InputEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*agentIO.InputEvent
|
||||
add := func(t *Task) {
|
||||
if t != nil && t.Event != nil && t.Event.ResponseCh != nil {
|
||||
out = append(out, t.Event)
|
||||
}
|
||||
}
|
||||
for _, t := range s.queue {
|
||||
add(t)
|
||||
}
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
for _, t := range s.interruptQueues[lv] {
|
||||
add(t)
|
||||
}
|
||||
}
|
||||
add(s.immediate)
|
||||
for _, f := range s.suspendStack {
|
||||
if f != nil {
|
||||
add(f.Task)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// done 标记任务执行结束。
|
||||
func (s *scheduler) done(t *Task) {
|
||||
s.mu.Lock()
|
||||
@ -796,9 +928,11 @@ func (a *Agent) schedulerLoop() {
|
||||
// 无待办:阻塞等新输入、新中断(wake)或退出。
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
_ = a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.sched.wake:
|
||||
// 中断已入 pendingInterrupts,回到循环顶部重新挑选。
|
||||
case <-a.ctx.Done():
|
||||
@ -825,15 +959,28 @@ func (a *Agent) pumpInbox() {
|
||||
for a.sched.hasRoom() {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
// 返回值必须处理:静默丢弃会让同步调用方永久挂起(回执路径 E)。
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.sched.noteBackpressure()
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
// 自循环输入没有同步调用方,满时记一次背压即可。
|
||||
if !a.sched.enqueue(newSelfTask(msg)) {
|
||||
a.sched.noteBackpressure()
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
a.sched.clearBackpressure()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 队列满:输入留在 channel 里,发送方阻塞(设计 §4.4「阻塞发送方」)。
|
||||
// 必须计数并打日志——否则运维看到 Rejected=0 会以为没背压,而输入正卡在 channel。
|
||||
if a.sched.noteBackpressure() {
|
||||
log.Printf("[agent] ready queue full (%d), input channel backpressured", a.sched.maxQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||
|
||||
186
internal/agent/core/scheduler_rearm_test.go
Normal file
186
internal/agent/core/scheduler_rearm_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package core
|
||||
|
||||
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
|
||||
//
|
||||
// 对照设计稿原文修正的四条:
|
||||
//
|
||||
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
|
||||
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
|
||||
// 唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
|
||||
// **自然结束**。可复现症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
|
||||
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
|
||||
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
|
||||
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
|
||||
// 中断里、被降级入队的那条也被计入(immediate 是单槽,降级是设计要求的路径)。
|
||||
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
|
||||
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
|
||||
//
|
||||
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
|
||||
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
|
||||
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = victim
|
||||
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2)。
|
||||
victim.PreemptCount = 1
|
||||
victim.LastPreemptAt = time.Now()
|
||||
|
||||
evt, _ := textEvent("cli", "第二次终止")
|
||||
evt.Payload["interrupt"] = true
|
||||
if a.sched.requestPreempt(evt, LevelCritical) {
|
||||
t.Fatal("抢占冷却期内不得抢占(应入队)")
|
||||
}
|
||||
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
|
||||
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
|
||||
}
|
||||
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
|
||||
t.Fatalf("应恰好入队一条,实际 %d(>1 说明入队路径重复)", n)
|
||||
}
|
||||
|
||||
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
|
||||
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
|
||||
a.sched.rearmPending()
|
||||
if !a.sched.preemptGrantedFor() {
|
||||
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
|
||||
}
|
||||
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
|
||||
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
|
||||
}
|
||||
// 重新求值不该把任务复制一份:队列必须空、immediate 恰好一条。
|
||||
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
|
||||
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一安全点前到达的两条同级中断:immediate 是单槽,第二条只能降级入队;
|
||||
// 它**没有**进入 immediate,因此不得计入 PreemptsByLevel,也不得被入队两次。
|
||||
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = 排队任务(有效级 0,任何中断都能抢)
|
||||
|
||||
e1, _ := textEvent("cli", "irq-1")
|
||||
if !a.sched.requestPreempt(e1, LevelInteractive) {
|
||||
t.Fatal("第一条 L3 应抢占排队任务")
|
||||
}
|
||||
e2, _ := textEvent("cli", "irq-2")
|
||||
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
|
||||
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1,实际 %d)", got)
|
||||
}
|
||||
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
|
||||
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
|
||||
}
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
|
||||
}
|
||||
}
|
||||
|
||||
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
|
||||
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
|
||||
if a.sched == nil {
|
||||
t.Fatal("agent 应带调度器")
|
||||
}
|
||||
a.sched.mu.Lock()
|
||||
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
|
||||
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
|
||||
a.sched.stats.Suspended = 99 // 故意与抢占数不等
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
got := a.schedulerStatus()
|
||||
if got.Preempted != 5 {
|
||||
t.Fatalf("Preempted 应为各级抢占数之和 5,实际 %d(拿 Suspended 顶替会得 99)", got.Preempted)
|
||||
}
|
||||
if got.Suspended != 99 {
|
||||
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
|
||||
}
|
||||
}
|
||||
|
||||
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
|
||||
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
|
||||
if !a.sched.enqueue(newInputTask(queuedEvt)) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
|
||||
a.sched.suspend(
|
||||
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
|
||||
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
|
||||
)
|
||||
|
||||
a.Stop()
|
||||
|
||||
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r == nil || r.Payload["skipped"] != true {
|
||||
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
|
||||
func TestBackpressure_CounterAndTransition(t *testing.T) {
|
||||
s := newScheduler(1)
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("首次背压应报告「翻转」")
|
||||
}
|
||||
if s.noteBackpressure() {
|
||||
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
|
||||
}
|
||||
if s.stats.Backpressure != 2 {
|
||||
t.Fatalf("背压计数应为 2,实际 %d", s.stats.Backpressure)
|
||||
}
|
||||
s.clearBackpressure()
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("队列恢复后再满应再次报告翻转")
|
||||
}
|
||||
}
|
||||
|
||||
// 集成:就绪队列满时 pumpInbox 必须计一次背压(Rejected 保持 0——背压不是丢弃)。
|
||||
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.sched.maxQueue = 1
|
||||
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
|
||||
a.pumpInbox()
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.Queue) != 1 {
|
||||
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
|
||||
}
|
||||
if snap.Stats.Backpressure == 0 {
|
||||
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4:阻塞发送方 + 计数)")
|
||||
}
|
||||
if snap.Stats.Rejected != 0 {
|
||||
t.Fatalf("背压不是丢弃,Rejected 必须保持 0,实际 %d", snap.Stats.Rejected)
|
||||
}
|
||||
}
|
||||
@ -178,6 +178,10 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
|
||||
for i := 0; i < maxSteps; i++ {
|
||||
// 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列,
|
||||
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
|
||||
//
|
||||
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
|
||||
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
|
||||
a.sched.rearmPending()
|
||||
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
|
||||
return outcomeSuspended
|
||||
}
|
||||
|
||||
@ -411,7 +411,12 @@ func (b *Bridge) readLoop() {
|
||||
_ = ws.writePong()
|
||||
continue
|
||||
}
|
||||
// 超时或其他错误,退出
|
||||
// 超时或其他错误,退出。
|
||||
//
|
||||
// **必须记日志**:此前这里静默 return,设备断线的真因(读超时 / 对端
|
||||
// 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。
|
||||
// 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。
|
||||
log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err)
|
||||
return
|
||||
}
|
||||
if isClose {
|
||||
|
||||
@ -41,7 +41,11 @@ var (
|
||||
// 注入标志位、中断优先级、事件订阅、动态输出通道注销补进 Lua 侧
|
||||
// (此前只在 Go 侧存在而文档宣称“完全对齐”)。公开 Go SDK 接口
|
||||
// 零变更,故 SDK 保持 1.3.0。
|
||||
Version = "1.3.11"
|
||||
// 1.3.12:修 1.3.11 引入的两个真问题 —— ① 驻留子销毁后残留入站 inputch
|
||||
// child/<id>(改用纯函数名并 Unregister,覆盖 destroy/reclaim/StopResidents);
|
||||
// ② sdk.events.subscribe 用了从未注入的公共 Events(),且订阅生命周期
|
||||
// 管理会自死锁/use-after-close(改用内部 Subscribe + 独立 subsMu + Stop 取消)。
|
||||
Version = "1.3.12"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
agentEvents "gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
@ -42,7 +42,16 @@ type luaPlugin struct {
|
||||
stages map[sdk.Stage]*stageReg
|
||||
outputChs map[string]*outputChReg
|
||||
inputDefs map[string]sdk.ChannelDef
|
||||
mu sync.Mutex
|
||||
// subs 是本插件注册的事件订阅取消函数;Stop 时兜底取消,
|
||||
// 避免 L 已 Close 后残留回调被触发(use-after-close)。
|
||||
// 用独立的 subsMu 而非 mu:subscribe 会在 Lua 的 start 回调里被调,
|
||||
// 而 Start 正持着 mu —— 用 mu 就是不可重入的自死锁。
|
||||
subs []func()
|
||||
subsMu sync.Mutex
|
||||
// closed 在 Stop 里置位(持 mu);事件回调持 mu 后先查它,
|
||||
// 防止“回调已通过取消订阅检查、但等锁期间 L 被 Close”的竞态。
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newLuaPlugin(luaPath, name string) (*luaPlugin, error) {
|
||||
@ -791,20 +800,25 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
return pushVal([]interface{}{})
|
||||
}))
|
||||
|
||||
// ---- sdk.events.*(只读事件订阅,与外部插件的 Events() 对齐)----
|
||||
// 回调在内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁);
|
||||
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数。
|
||||
// ---- sdk.events.*(只读事件订阅)----
|
||||
//
|
||||
// 用内部 SDK 的 Subscribe(内置插件用的是同一条路径);
|
||||
// 不用公共 SDK 的 Events()——那个 subscriber 在本内核里从未被注入
|
||||
// (SetEventSubscriber 无调用点),拿到的永远是 nil。
|
||||
//
|
||||
// 回调用内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁);
|
||||
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数,并在 Stop 时兜底取消
|
||||
// (否则插件停掉/重载后 L 已 Close,残留回调再触发就是 use-after-close)。
|
||||
evTbl := subTable("events")
|
||||
evTbl.RawSetString("subscribe", L.NewFunction(func(L *lua.LState) int {
|
||||
eventType := L.CheckString(1)
|
||||
fn := L.CheckFunction(2)
|
||||
sub := s.Events()
|
||||
if sub == nil {
|
||||
return pushErr(fmt.Errorf("events unavailable"))
|
||||
}
|
||||
unsub := sub.Subscribe(pubsdk.EventType(eventType), func(evt *pubsdk.Event) {
|
||||
unsub := s.Subscribe(agentEvents.EventType(eventType), func(evt *agentEvents.Event) {
|
||||
plg.mu.Lock()
|
||||
defer plg.mu.Unlock()
|
||||
if plg.closed {
|
||||
return
|
||||
}
|
||||
L2 := plg.L
|
||||
tbl := L2.NewTable()
|
||||
tbl.RawSetString("type", lua.LString(string(evt.Type)))
|
||||
@ -817,8 +831,11 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
fmt.Printf("[lua-plugin/%s] event handler error: %v\n", plg.name, err)
|
||||
}
|
||||
})
|
||||
plg.subsMu.Lock()
|
||||
plg.subs = append(plg.subs, unsub)
|
||||
plg.subsMu.Unlock()
|
||||
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||
unsub()
|
||||
unsub() // 事件总线的取消订阅是幂等的(重复调用只会匹配不到)
|
||||
return 0
|
||||
}))
|
||||
L.Push(lua.LNil)
|
||||
@ -826,18 +843,31 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
|
||||
}))
|
||||
|
||||
// ---- sdk.plugin_mgr.*(插件管理,与外部插件的 PluginMgrAPI 对齐)----
|
||||
// PluginMgr 可能未装配(如部分单测的 SDK 构造),此时返回"不可用"而不是 panic。
|
||||
pmTbl := subTable("plugin_mgr")
|
||||
pmTbl.RawSetString("reload_one", L.NewFunction(func(L *lua.LState) int {
|
||||
if err := s.PluginMgr().ReloadOne(L.CheckString(1)); err != nil {
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushErr(fmt.Errorf("plugin manager unavailable"))
|
||||
}
|
||||
if err := pm.ReloadOne(L.CheckString(1)); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
return pushNil()
|
||||
}))
|
||||
pmTbl.RawSetString("list_loaded", L.NewFunction(func(L *lua.LState) int {
|
||||
return pushList(s.PluginMgr().ListLoadedPlugins())
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushList([]interface{}{})
|
||||
}
|
||||
return pushList(pm.ListLoadedPlugins())
|
||||
}))
|
||||
pmTbl.RawSetString("is_disabled", L.NewFunction(func(L *lua.LState) int {
|
||||
return pushVal(s.PluginMgr().IsPluginDisabled(L.CheckString(1)))
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushVal(false)
|
||||
}
|
||||
return pushVal(pm.IsPluginDisabled(L.CheckString(1)))
|
||||
}))
|
||||
}
|
||||
|
||||
@ -1207,8 +1237,21 @@ func (p *luaPlugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *luaPlugin) Stop() error {
|
||||
// ① 先取消事件订阅。**不持 p.mu**:Bus.Publish 持总线锁回调 handler,
|
||||
// 而 handler 要 p.mu;若此处持 p.mu 再取总线锁,就是锁序反转死锁。
|
||||
p.subsMu.Lock()
|
||||
subs := p.subs
|
||||
p.subs = nil
|
||||
p.subsMu.Unlock()
|
||||
for _, unsub := range subs {
|
||||
unsub()
|
||||
}
|
||||
|
||||
// ② 置 closed 并关 L。置位在持锁下完成:已进入但等锁的 event 回调
|
||||
// 拿到锁后会先看到 closed 而直接返回,不会碰已关的 L。
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.closed = true
|
||||
|
||||
if p.tbl != nil {
|
||||
fn := p.tbl.RawGetString("stop")
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
@ -597,3 +598,66 @@ return plugin
|
||||
t.Errorf("llm_text writeback: got %q, want %q", sc2.LLMText, "模型输出[尾部标记]")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaEventsSubscribeAndStopCleanup 覆盖 sdk.events.subscribe:
|
||||
// 1. 订阅真的能收到内核事件(走内部 SDK 的 Subscribe,不是永远为 nil 的公共 Events());
|
||||
// 2. Stop 会取消订阅,之后 Publish 不得再触碰已 Close 的 LState。
|
||||
func TestLuaEventsSubscribeAndStopCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"evlua","entry":"main.lua"}`), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
|
||||
local plugin = { name = "evlua" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
_G.hits = 0
|
||||
local unsub, err = sdk.events.subscribe("agent_output", function(evt)
|
||||
_G.hits = _G.hits + 1
|
||||
_G.last_type = evt.type
|
||||
_G.last_source = evt.source
|
||||
end)
|
||||
_G.sub_err = err
|
||||
_G.unsub_type = type(unsub)
|
||||
end
|
||||
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
`), 0644)
|
||||
|
||||
plg, err := tryLoadLua(dir, "evlua", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tryLoadLua failed: %v", err)
|
||||
}
|
||||
lp := plg.(*luaPlugin)
|
||||
|
||||
bus := events.NewBus()
|
||||
reg := internalConfig.NewConfigRegistry("")
|
||||
sett := sdk.NewSettings("evlua", reg)
|
||||
s := sdk.New("evlua", sdk.SDKConfig{EventBus: bus, Settings: sett})
|
||||
|
||||
if err := plg.Start(s); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
L := lp.L
|
||||
if errStr := L.GetGlobal("sub_err").String(); errStr != "nil" {
|
||||
t.Fatalf("subscribe returned error: %s", errStr)
|
||||
}
|
||||
if got := L.GetGlobal("unsub_type").String(); got != "function" {
|
||||
t.Fatalf("subscribe should return an unsubscribe function, got %s", got)
|
||||
}
|
||||
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "test-src"})
|
||||
if hits := int(lua.LVAsNumber(L.GetGlobal("hits"))); hits != 1 {
|
||||
t.Fatalf("event handler hits = %d, want 1", hits)
|
||||
}
|
||||
if got := L.GetGlobal("last_source").String(); got != "test-src" {
|
||||
t.Fatalf("event source = %q, want test-src", got)
|
||||
}
|
||||
|
||||
// Stop 取消订阅 + 关 L;此后再 Publish 不得 panic / use-after-close。
|
||||
if err := plg.Stop(); err != nil {
|
||||
t.Fatalf("Stop failed: %v", err)
|
||||
}
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "after-stop"})
|
||||
}
|
||||
|
||||
53
internal/plugins/remotedevice/ping_test.go
Normal file
53
internal/plugins/remotedevice/ping_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package remotedevice
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 心跳回包必须**真的发出去**:pong 只有两个字节,且设备空闲时没有任何别的写
|
||||
// 会顺带把 bufio 缓冲刷出去——`writePong` 一旦忘了 Flush,pong 就永远留在
|
||||
// 服务端缓冲里。
|
||||
//
|
||||
// 这就是「device channel 不稳定」的真因(实测):客户端每 30s 发一个 ping,
|
||||
// 服务端算好了 pong 却没发;客户端的读循环设的是 2 倍 ping 间隔(默认 60s)
|
||||
// 读超时,于是**每 60 秒准点断开一次**,重连后 outputch 被注销又注册,
|
||||
// 模型侧看到的就是工具/通道凭空消失又出现。
|
||||
//
|
||||
// 本用例只发一个 ping,随后**什么都不发**:pong 必须在无后续流量的情况下到达。
|
||||
func TestWSPingGetsPongWhileIdle(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
token := "test-token-ping"
|
||||
reg.SetAcceptToken(func(provided string) bool { return provided == token })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||
defer srv.Close()
|
||||
|
||||
cli := dialTestWS(t, srv.URL, token)
|
||||
defer cli.close()
|
||||
|
||||
// 先走完 hello + bind(服务端要先把设备登记进 conns,pong 才写得回来)。
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"ping-dev","name":"前端机","kind":"computer","caps":["cmd"]}}`))
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
cli.sendFrame(0x9, nil) // ping
|
||||
|
||||
if err := cli.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
payload, isClose, opcode, err := readFrame(cli.rw.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("2s 内没收到 pong(writePong 忘了 Flush?): %v", err)
|
||||
}
|
||||
if isClose {
|
||||
t.Fatal("连接被关闭,而不是回了 pong")
|
||||
}
|
||||
if opcode != 0xa {
|
||||
t.Fatalf("期望 pong(0xa),实际 opcode=%#x payload=%q", opcode, payload)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("pong 不该带负载,实际 %q", payload)
|
||||
}
|
||||
}
|
||||
@ -606,7 +606,13 @@ func writeFrame(w *bufio.Writer, opcode byte, payload []byte) error {
|
||||
}
|
||||
|
||||
func writePong(w *bufio.Writer) error {
|
||||
return writeFrameHeader(w, 0xa, 0)
|
||||
// 必须走 writeFrame(它 Flush)。
|
||||
//
|
||||
// 回归的 bug:这里原先是裸的 writeFrameHeader,**不 Flush**。设备空闲时
|
||||
// 没有任何别的写会顺带把 bufio 缓冲刷出去,于是 pong 永远留在服务端缓冲里,
|
||||
// 客户端等 2 倍 ping 间隔(默认 30s×2 = 60s)读超时断开、重连——
|
||||
// 实测表现就是「设备通道每 60 秒掉线一次」,连带着 outputch 反复注销/注册。
|
||||
return writeFrame(w, 0xa, nil)
|
||||
}
|
||||
|
||||
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {
|
||||
|
||||
@ -68,7 +68,11 @@ type SchedulerStatus struct {
|
||||
Rejected uint64 `json:"rejected"`
|
||||
Suspended uint64 `json:"suspended"`
|
||||
Resumed uint64 `json:"resumed"`
|
||||
// Preempted = Σ PreemptsByLevel[1..4],即「真正抢占成功」的次数。
|
||||
// 它与 Suspended 不等价(受害者可能先自行结束),因此不是 Suspended 的别名。
|
||||
Preempted uint64 `json:"preempted"`
|
||||
// Backpressure 是就绪队列满、输入被挡回 channel 的次数(暂时不收,不是丢弃)。
|
||||
Backpressure uint64 `json:"backpressure"`
|
||||
}
|
||||
|
||||
// SchedulerTask 是任务的最小标识(不暴露帧内容)。
|
||||
|
||||
Reference in New Issue
Block a user