Files
agentic-coding-workflow/internal/terminal/session.go
T
2026-06-18 22:56:52 +08:00

186 lines
5.3 KiB
Go

// Package terminal 提供一个仅限管理员的网页端交互式终端:在 server 宿主进程
// (部署时即 runtime 容器)内拉起一个 shell,经 WebSocket 双向桥接 stdin/stdout,
// 主要用途是运维(例如容器内 npm i -g 安装 ACP 客户端 CLI)。
//
// 会话纯内存、不落库:开即用、断即清,server 重启即全部销毁(沿用 run profiles
// 「运行态不持久化」的取舍)。进程树终止复用 internal/infra/proc,断连时整树清理,
// 避免 shell 派生的子孙进程(npm→node…)残留为孤儿。
//
// 跨平台:Unix 走真 PTY(github.com/creack/pty);Windows 走降级的 stdio 管道
// 实现(无 PTY、无 resize),仅供本地开发调试,不追求生产可用。
package terminal
import (
"context"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
)
// SupervisorConfig 是从 config.TerminalConfig 派生的 supervisor 子集。
type SupervisorConfig struct {
Shell string // 启动的 shell,如 /bin/bash
MaxSessions int // 同时存在的会话上限
KillGrace time.Duration // 终止整组时 SIGTERM→SIGKILL 的宽限
// EnvWhitelist 是允许从宿主进程透传给 shell 的环境变量名白名单。
// APP_MASTER_KEY、DB DSN、git 凭据等敏感变量绝不应出现在此名单中。
EnvWhitelist []string
}
// ErrTooManySessions 在达到 MaxSessions 上限时返回,由 Handler 翻译为 errs code。
var ErrTooManySessions = errSentinel("terminal: too many active sessions")
type errSentinel string
func (e errSentinel) Error() string { return string(e) }
// Session 是单个终端会话的运行时句柄。pty 持有平台相关的 shell 进程与 IO,
// done 在进程退出(monitor)时关闭。
type Session struct {
ID uuid.UUID
pty *ptySession
done chan struct{} // monitor 在 shell 退出后 close
}
// Read 从 shell 读输出(PTY master / 管道读端)。
func (s *Session) Read(p []byte) (int, error) { return s.pty.rw.Read(p) }
// Write 向 shell 写输入(PTY master / stdin 管道)。
func (s *Session) Write(p []byte) (int, error) { return s.pty.rw.Write(p) }
// Resize 调整终端窗口尺寸(Windows 降级实现为 no-op)。
func (s *Session) Resize(rows, cols uint16) error { return s.pty.resize(rows, cols) }
// Done 在 shell 进程退出后被关闭,供调用方感知进程自行结束(如用户输入 exit)。
func (s *Session) Done() <-chan struct{} { return s.done }
// Supervisor 是终端会话总管。被动结构:Open 时起 monitor goroutine,无主循环。
// 并发骨架照搬 run.RunSupervisor 已被测试验证的不变量(sessions map 写锁仅在
// Open 插入 / monitor 删除时持有;done 严格 close-once,monitor 唯一持有)。
type Supervisor struct {
mu sync.RWMutex
sessions map[uuid.UUID]*Session
cfg SupervisorConfig
log *slog.Logger
}
// NewSupervisor 构造空 Supervisor。
func NewSupervisor(cfg SupervisorConfig, log *slog.Logger) *Supervisor {
if log == nil {
log = slog.Default()
}
return &Supervisor{
sessions: map[uuid.UUID]*Session{},
cfg: cfg,
log: log,
}
}
// OpenParams 描述一次终端会话的初始参数。
type OpenParams struct {
Rows uint16
Cols uint16
}
// Open 启动一个 shell 会话并托管。达到 MaxSessions 上限时返回 ErrTooManySessions。
func (s *Supervisor) Open(p OpenParams) (*Session, error) {
s.mu.RLock()
full := len(s.sessions) >= s.cfg.MaxSessions
s.mu.RUnlock()
if full {
return nil, ErrTooManySessions
}
rows, cols := p.Rows, p.Cols
if rows == 0 {
rows = defaultRows
}
if cols == 0 {
cols = defaultCols
}
ps, err := startPTY(s.cfg.Shell, buildEnv(s.cfg.EnvWhitelist), rows, cols)
if err != nil {
return nil, err
}
id, err := uuid.NewRandom()
if err != nil {
ps.terminate()
return nil, err
}
sess := &Session{ID: id, pty: ps, done: make(chan struct{})}
s.mu.Lock()
s.sessions[id] = sess
s.mu.Unlock()
go s.monitor(sess)
return sess, nil
}
// monitor 阻塞等待 shell 退出后回收:关闭 done、移除映射、释放平台句柄。
func (s *Supervisor) monitor(sess *Session) {
_ = sess.pty.cmd.Wait() // reap,避免僵尸进程
close(sess.done)
s.mu.Lock()
delete(s.sessions, sess.ID)
s.mu.Unlock()
sess.pty.group.Close()
}
// Close 终止指定会话(整树)。幂等:会话已不存在时直接返回。阻塞直到 done 关闭
// 或宽限耗尽。
func (s *Supervisor) Close(ctx context.Context, id uuid.UUID) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return
}
sess.pty.group.TerminateGroup(sess.done, s.cfg.KillGrace)
select {
case <-sess.done:
case <-ctx.Done():
}
}
// Count 返回当前活跃会话数。
func (s *Supervisor) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.sessions)
}
// CloseAll 在服务关停时终止全部会话。
func (s *Supervisor) CloseAll(ctx context.Context) {
s.mu.RLock()
ids := make([]uuid.UUID, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}
s.mu.RUnlock()
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(sid uuid.UUID) {
defer wg.Done()
s.Close(ctx, sid)
}(id)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
}