package api // Cross-platform terminal session abstraction. // // On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires // startTermSession to creack/pty for a real PTY: full TTY semantics, // resize support, interactive apps (vim, top…) work. // // On Windows the windows-tagged file (terminal_session_windows.go) tries // the kernel32 ConPTY API first, with a pipe-based fallback for older // hosts. pipeSession does NOT carry TTY signals, so most shells go silent // — it's only kept as a last resort. // // Both platforms share the termSession interface, the ptySession type // (used by unix), and the pipeSession type (used by the Windows fallback). import ( "io" "os" "os/exec" "sync" "github.com/creack/pty/v2" ) // termSession is the read/write/resize/close surface used by handleTerminalWS. type termSession interface { Read([]byte) (int, error) Write([]byte) (int, error) Resize(rows, cols uint16) error Close() error Wait() error Pid() int } // ptySession wraps creack/pty's *os.File-backed PTY (unix path). type ptySession struct { ptmx *os.File cmd *exec.Cmd } func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) } func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } func (s *ptySession) Resize(rows, cols uint16) error { return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols}) } func (s *ptySession) Close() error { err := s.ptmx.Close() if s.cmd.Process != nil { s.cmd.Process.Kill() } return err } func (s *ptySession) Wait() error { if s.cmd.Process == nil { return nil } return s.cmd.Wait() } func (s *ptySession) Pid() int { if s.cmd.Process == nil { return 0 } return s.cmd.Process.Pid } // pipeSession is the Windows last-resort fallback when ConPTY is not // available: stdin pipe + merged stdout/stderr, no TTY signals. Most // interactive shells go silent in this mode, so it should rarely be hit on // modern Windows (10 1809+). type pipeSession struct { cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser mu sync.Mutex merged chan []byte closed bool closeCh chan struct{} } func startPipeSession(cmd *exec.Cmd) (termSession, error) { stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { stdin.Close() return nil, err } stderr, err := cmd.StderrPipe() if err != nil { stdin.Close() stdout.Close() return nil, err } if err := cmd.Start(); err != nil { stdin.Close() stdout.Close() stderr.Close() return nil, err } s := &pipeSession{ cmd: cmd, stdin: stdin, stdout: stdout, stderr: stderr, merged: make(chan []byte, 32), closeCh: make(chan struct{}), } go s.pump(stdout) go s.pump(stderr) return s, nil } func (s *pipeSession) pump(r io.ReadCloser) { buf := make([]byte, 4096) for { n, err := r.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) select { case s.merged <- chunk: case <-s.closeCh: return } } if err != nil { return } } } func (s *pipeSession) Read(p []byte) (int, error) { select { case chunk, ok := <-s.merged: if !ok { return 0, io.EOF } n := copy(p, chunk) return n, nil case <-s.closeCh: return 0, io.EOF } } func (s *pipeSession) Write(p []byte) (int, error) { return s.stdin.Write(p) } func (s *pipeSession) Resize(rows, cols uint16) error { // No real TTY → resize is a no-op; the child won't get SIGWINCH. return nil } func (s *pipeSession) Close() error { s.mu.Lock() if s.closed { s.mu.Unlock() return nil } s.closed = true close(s.closeCh) s.mu.Unlock() s.stdin.Close() s.stdout.Close() s.stderr.Close() if s.cmd.Process != nil { s.cmd.Process.Kill() } return nil } func (s *pipeSession) Wait() error { if s.cmd.Process == nil { return nil } return s.cmd.Wait() } func (s *pipeSession) Pid() int { if s.cmd.Process == nil { return 0 } return s.cmd.Process.Pid }