Github开源生信云平台 DEMO
GET /container/template/{templateId}/terminal
Attach
bridgeManagedTerminalWebSocket
ws_disconnect
Detach
scheduleReap
Close()
我先看 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
func \(s \*ManagedTerminalSession\) (Attach|Detach|SendEvent|snapshotStatus)|type TerminalSubscriber|func newTerminalSubscriber|func \(s \*TerminalSubscriber\) close
**/gobrave/internal/manager/terminal_session.go
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
func bridgeManagedTerminalWebSocket
**/gobrave/internal/handler/container_terminal.go
Searched for regex bridgeManagedTerminalWebSocket, 3 results
Read
Read , lines 120 to 340
ContainerHandler.ContainerTemplateTerminal 的关键顺序是:
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 表示从头回放,超出当前序号表示只要新输出。
?since=<seq>
0
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 不可能在两者之间插入数据丢帧。
publish
写协程(唯一写 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。
{"type":"resize","rows":..,"cols":..}
session.Resize()
session.WriteInput(data)
数据流入订阅者的路径在 publish(readLoop 里):
readLoop
for _, sub := range s.subs { select { case sub.Data <- chunk: default: } // 非阻塞,消费不过来就丢(前端可用 since 补) }
所以 WS 只是“众多订阅者之一”,它不拥有 PTY,断了也不影响 shell。
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 报错(刷新/关标签/断网)时执行。
defer session.Detach(sub)
conn.ReadMessage
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,就是断开即同步删除,没有宽限期。
terminal_idle_grace_seconds: 0
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)。
cleanup
StartTerminalRuntime
func() { deleteProbeRuntime(ctx, plan.rt, runtimeID) }
docker rm -f
close(s.done) 会让阻塞在 runCommand 的 select 立即返回 ErrTerminalSessionClosed,也会让写协程的 <-sub.Done() 退出。
close(s.done)
runCommand
select
ErrTerminalSessionClosed
<-sub.Done()
reapTimer.Stop()
suspendReap()
attachedOnce
UnattachedGrace
subs
len==0
handleReadError
forget + Close()
explicit
DELETE /container/template/{id}/terminal
CloseSession
maintain()
补充一点:maintain()(每分钟一轮)在 ws_disconnect 下同样会兜底——sess.isBusy() || sess.hasSubscribers() 为假且 idleFor(now) > ttl(默认 120 分钟)时也会 forget + Close(),并清理数据库里无人认领的残留记录。
sess.isBusy() || sess.hasSubscribers()
idleFor(now) > ttl