Files
webterm/go/webterm/terminal_session.go
T
GitHub Copilot fd6c1c4e0d feat: complete Go port with go-te terminal emulator
Full Go implementation under go/ replacing Python pyte with go-te:
- HTTP server with WebSocket protocol, SSE, screenshot SVG rendering
- PTY terminal sessions and Docker exec sessions
- Docker watcher (label-based container discovery + event stream)
- CPU stats collection with sparkline SVG rendering
- Session manager with TwoWayMap routing and replay buffers
- C1 normalization, DA filtering, identity generation, theme palettes

Audit fixes for 9 concurrency/correctness issues:
- HTTP transport leak: shared client pool for Docker socket calls
- WebSocket concurrent writes: all writes routed through send channel
- Closed channel panic: atomic.Bool guard on enqueueWSData
- GetFirstRunningSession: use UnsafeForward under SessionManager lock
- NewSession TOCTOU: re-check routeKey after re-acquiring lock
- waitErr data race: protect with mutex in both session types
- Replay buffer fragmentation: copy to new slice on eviction
- go-te dirty tracking: check screen.Dirty before incrementing counter
- Identity modulo bias: rejection sampling for uniform distribution

All Go tests pass (including -race). Python baseline unchanged (397 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-14 16:18:23 +00:00

258 lines
5.0 KiB
Go

package webterm
import (
"errors"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"github.com/creack/pty"
"github.com/google/shlex"
"github.com/rcarmo/webterm-go-port/terminalstate"
)
type TerminalSession struct {
sessionID string
command string
mu sync.RWMutex
connector SessionConnector
cmd *exec.Cmd
ptyFile *os.File
tracker *terminalstate.Tracker
replay *ReplayBuffer
escapeBuffer []byte
utf8Buffer []byte
width int
height int
started bool
running bool
done chan struct{}
doneOnce sync.Once
waitErr error
writeMu sync.Mutex
}
func NewTerminalSession(sessionID string, command string) *TerminalSession {
return &TerminalSession{
sessionID: sessionID,
command: command,
connector: noopConnector{},
replay: NewReplayBuffer(replayBufferSize),
done: make(chan struct{}),
width: DefaultTerminalWidth,
height: DefaultTerminalHeight,
}
}
func (s *TerminalSession) Open(width, height int) error {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return nil
}
command := strings.TrimSpace(s.command)
if command == "" {
command = os.Getenv("SHELL")
}
if command == "" {
command = "/bin/sh"
}
argv, err := shlex.Split(command)
if err != nil {
return err
}
if len(argv) == 0 {
return errors.New("empty command")
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = append(os.Environ(), "TERM_PROGRAM=webterm-go", "TERM_PROGRAM_VERSION=0.0.0")
file, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)})
if err != nil {
return err
}
s.cmd = cmd
s.ptyFile = file
s.tracker = terminalstate.NewTracker(width, height)
s.width = width
s.height = height
s.running = true
return nil
}
func (s *TerminalSession) Start(connector SessionConnector) error {
s.mu.Lock()
if connector != nil {
s.connector = connector
}
if s.started {
s.mu.Unlock()
return nil
}
if s.ptyFile == nil {
s.mu.Unlock()
return errors.New("session not open")
}
s.started = true
file := s.ptyFile
s.mu.Unlock()
go s.readLoop(file)
return nil
}
func (s *TerminalSession) readLoop(file *os.File) {
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
s.handleOutput(buf[:n])
}
if err != nil {
break
}
}
s.mu.Lock()
if s.cmd != nil {
s.waitErr = s.cmd.Wait()
}
s.running = false
connector := s.connector
s.mu.Unlock()
connector.OnClose()
s.doneOnce.Do(func() { close(s.done) })
}
func (s *TerminalSession) handleOutput(data []byte) {
s.mu.Lock()
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
s.escapeBuffer = escapeBuffer
normalized, utf8Buffer := NormalizeC1Controls(filtered, s.utf8Buffer)
s.utf8Buffer = utf8Buffer
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(normalized) == 0 {
return
}
s.replay.Add(normalized)
if tracker != nil {
_ = tracker.Feed(normalized)
}
connector.OnData(normalized)
}
func (s *TerminalSession) Close() error {
s.mu.Lock()
file := s.ptyFile
cmd := s.cmd
s.ptyFile = nil
s.running = false
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGHUP)
}
if file != nil {
_ = file.Close()
}
s.doneOnce.Do(func() { close(s.done) })
return nil
}
func (s *TerminalSession) Wait() error {
<-s.done
s.mu.RLock()
defer s.mu.RUnlock()
return s.waitErr
}
func (s *TerminalSession) SetTerminalSize(width, height int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptyFile == nil {
return errors.New("session closed")
}
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
if err := pty.Setsize(s.ptyFile, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}); err != nil {
return err
}
s.width = width
s.height = height
if s.tracker != nil {
s.tracker.Resize(width, height)
}
return nil
}
func (s *TerminalSession) ForceRedraw() error {
s.mu.RLock()
width := s.width
height := s.height
s.mu.RUnlock()
return s.SetTerminalSize(width, height)
}
func (s *TerminalSession) SendBytes(data []byte) bool {
s.mu.RLock()
file := s.ptyFile
s.mu.RUnlock()
if file == nil {
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := file.Write(data)
return err == nil
}
func (s *TerminalSession) SendMeta(_ map[string]any) bool {
return true
}
func (s *TerminalSession) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
func (s *TerminalSession) GetReplayBuffer() []byte {
return s.replay.Bytes()
}
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
s.mu.RUnlock()
if tracker == nil {
return terminalstate.Snapshot{
Width: s.width,
Height: s.height,
Buffer: make([][]terminalstate.Cell, s.height),
}
}
return tracker.Snapshot()
}
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
if connector == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.connector = connector
}