ws是如何订阅 session消息,以及如何实现在 terminal_close_policy=ws_disconnect 时删除session和容器

最后发布时间 : 2026-11-02 22:35:23 浏览量 :

我先看 WebSocket 订阅入口和断连后的回收逻辑。

Searched for regex func \(s \*ManagedTerminalSession\) (Attach|Detach|SendEvent|snapshotStatus)|type TerminalSubscriber|func newTerminalSubscriber|func \(s \*TerminalSubscriber\) close (**/gobrave/internal/manager/terminal_session.go), 7 results

Read , lines 1 to 160

Read , lines 180 to 240

Read , lines 1055 to 1128

Searched for regex func bridgeManagedTerminalWebSocket (**/gobrave/internal/handler/container_terminal.go), no results

Searched for regex bridgeManagedTerminalWebSocket, 3 results

Read

Read , lines 120 to 340

一、WS 如何订阅 session

1. 入口:GET /container/template/{templateId}/terminal

ContainerHandler.ContainerTemplateTerminal 的关键顺序是:

key, ok := h.resolveTerminalSessionKey(c)      // (userID, projectID, templateID)
conn, _ := upgrader.Upgrade(...)               // 先升级 WS
session, _, err := h.terminalRegistry.Ensure(ctx, key)   // 复用或创建容器
sub, replay := session.Attach(sinceSeq)        // ★ 订阅
defer session.Detach(sub)                      // ★ 断开时注销
conn.WriteMessage(BinaryMessage, replay)       // 回放历史输出
session.SendEvent({"type":"session","status":"ready"})
bridgeManagedTerminalWebSocket(conn, session, sub)

?since=<seq> 是断线重放游标:0 表示从头回放,超出当前序号表示只要新输出。

2. Attach:原子地“注册订阅者 + 生成重放快照”

func (s *ManagedTerminalSession) Attach(sinceSeq int64) (*TerminalSubscriber, []byte) {
    s.mu.Lock(); defer s.mu.Unlock()
    if s.reapTimer != nil { s.reapTimer.Stop(); s.reapTimer = nil }  // ★ 有人来了,取消回收倒计时
    s.nextSubID++
    sub := newTerminalSubscriber(s.nextSubID)   // Data chan(2048) + Events chan(64) + done
    s.subs[sub.ID] = sub
    s.attachedOnce = true                       // 标记「订阅过」,之后改用 IdleGrace 而不是 UnattachedGrace
    s.lastActive = time.Now()
    // 从 max(sinceSeq, bufStart) 到 buf 末尾切出 replay
}

重放与订阅在同一临界区内完成,因此 publish 不可能在两者之间插入数据丢帧。

3. 两个桥接协程(bridgeManagedTerminalWebSocket)

写协程(唯一写 conn 的 goroutine):

select {
case chunk := <-sub.Data:    conn.WriteMessage(BinaryMessage, chunk)  // 原始 PTY 字节
case event := <-sub.Events:  conn.WriteMessage(TextMessage, event)    // JSON 控制事件
case <-sub.Done():           return                                    // 会话关闭/被踢
}

读协程(主 goroutine):文本帧若是 {"type":"resize","rows":..,"cols":..} 就走 session.Resize(),否则 session.WriteInput(data) 当按键写入 PTY。

数据流入订阅者的路径在 publish(readLoop 里):

for _, sub := range s.subs {
    select { case sub.Data <- chunk: default: }   // 非阻塞,消费不过来就丢(前端可用 since 补)
}

所以 WS 只是“众多订阅者之一”,它不拥有 PTY,断了也不影响 shell。

二、ws_disconnect 下如何删除 session 和容器

1. 触发点:Detach

func (s *ManagedTerminalSession) Detach(sub *TerminalSubscriber) {
    s.mu.Lock()
    delete(s.subs, sub.ID)
    empty := len(s.subs) == 0
    s.mu.Unlock()
    sub.close()
    if empty { s.registry.scheduleReap(s) }   // ★ 最后一个订阅者离开
}

defer session.Detach(sub) 在 conn.ReadMessage 报错(刷新/关标签/断网)时执行。

2. 决策:scheduleReap

func (r *TerminalSessionRegistry) scheduleReap(sess *ManagedTerminalSession) {
    if sess.closed || len(sess.subs) > 0 { return }

    if r.closePolicy() == TerminalClosePolicyExplicit {
        return                                  // explicit:什么都不做,等 DELETE 或 TTL
    }

    grace := r.idleGrace()                      // = terminal_idle_grace_seconds
    if !sess.attachedOnce { grace = r.unattachedGrace() }   // 从没被订阅过 → 用 120s

    if grace <= 0 {                             // ★ 0 = 立即删除
        r.forget(sess)      // 从 r.sessions 移除
        sess.Close()        // 关 PTY + 删容器 + 删库记录 + 删滚动缓冲
        return
    }
    sess.reapTimer = time.AfterFunc(grace, func() {
        // 到点再确认一次「仍然没人、也没被关闭」,避免误杀刚重连的会话
        if !empty { return }
        r.forget(sess); sess.Close()
    })
}

按你的配置 terminal_idle_grace_seconds: 0,就是断开即同步删除,没有宽限期。

3. 真正删除:Close()

func (s *ManagedTerminalSession) Close() {
    s.closeOnce.Do(func() {
        // 1. 标记 closed/dead,close(s.done),取出全部 subscriber 并 close
        if s.sess != nil { _ = s.sess.Close() }   // 2. 关 PTY:docker exec attach.Close()
        if s.cleanup != nil { s.cleanup() }       // 3. 删容器:deleteProbeRuntime(rt, runtimeID)
        s.store.Delete(ctx, s.key)                // 4. 删 go_terminal_session 记录
        s.scrollback.Remove()                     // 5. 删滚动缓冲文件
    })
}

cleanup 是 StartTerminalRuntime 里闭包捕获的 func() { deleteProbeRuntime(ctx, plan.rt, runtimeID) },即 docker rm -f(或 k8s 删 workload)。

close(s.done) 会让阻塞在 runCommand 的 select 立即返回 ErrTerminalSessionClosed,也会让写协程的 <-sub.Done() 退出。

三、时序图

sequenceDiagram participant B as 浏览器 WS participant H as handler participant S as ManagedTerminalSession participant R as Registry participant D as docker B->>H: GET /container/template/{id}/terminal?since=0 H->>R: Ensure(key) R->>D: Create + StartForTerminal + OpenTerminal (bash -l) H->>S: Attach(0) S-->>H: sub + replay H-->>B: binary replay H-->>B: {"type":"session","status":"ready"} loop PTY 输出 S->>S: readLoop → publish → sub.Data S-->>B: BinaryMessage end B->>H: 断开(刷新/关标签) H->>S: Detach(sub) [defer] S->>R: scheduleReap (empty=true) alt ws_disconnect && grace==0 R->>S: forget + Close() S->>D: PTY Close + 删除容器 S->>S: 删 session 记录 + 滚动缓冲 else grace>0 R->>R: AfterFunc(grace) → 再检查后 Close() end

四、几个容易踩的点

场景行为
Attach 时 reapTimer.Stop()宽限期内重连会取消删除,PTY 与 cwd/env 全部保留
runCommand 里 suspendReap()Agent 执行命令期间,即便没有 WS 订阅者也不会被回收
attachedOnce 区分 graceAgent 先建会话(HTTP exec)、前端还没连上时用 120s 的 UnattachedGrace,防止刚建就被删
多标签/多 WSsubs 是 map,只有 len==0 才触发回收
读错误路径handleReadError 走 forget + Close(),与策略无关(shell 真死了必须清理)
explicit 策略scheduleReap 直接 return,只能靠前端 DELETE /container/template/{id}/terminal(CloseSession)或 maintain() 的空闲 TTL 删

补充一点:maintain()(每分钟一轮)在 ws_disconnect 下同样会兜底——sess.isBusy() || sess.hasSubscribers() 为假且 idleFor(now) > ttl(默认 120 分钟)时也会 forget + Close(),并清理数据库里无人认领的残留记录。