Path: blob/main/components/supervisor/pkg/terminal/terminal.go
2500 views
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package terminal56import (7"bytes"8"context"9"errors"10"fmt"11"io"12"os"13"os/exec"14"strings"15"sync"16"syscall"17"time"1819_pty "github.com/creack/pty"20"github.com/google/uuid"2122"github.com/sirupsen/logrus"23"golang.org/x/sys/unix"24"golang.org/x/xerrors"2526"github.com/gitpod-io/gitpod/common-go/log"27"github.com/gitpod-io/gitpod/common-go/process"28"github.com/gitpod-io/gitpod/supervisor/api"29)3031const (32CBAUD = 0010017 // CBAUD Serial speed settings33CBAUDEX = 0010000 // CBAUDX Serial speed settings3435DEFAULT_COLS = 8036DEFAULT_ROWS = 2437)3839// NewMux creates a new terminal mux.40func NewMux() *Mux {41return &Mux{42terms: make(map[string]*Term),43}44}4546// Mux can mux pseudo-terminals.47type Mux struct {48aliases []string49terms map[string]*Term50mu sync.RWMutex51}5253// Get returns a terminal for the given alias.54func (m *Mux) Get(alias string) (*Term, bool) {55m.mu.RLock()56defer m.mu.RUnlock()57term, ok := m.terms[alias]58return term, ok59}6061// Start starts a new command in its own pseudo-terminal and returns an alias62// for that pseudo terminal.63func (m *Mux) Start(cmd *exec.Cmd, options TermOptions) (alias string, err error) {64m.mu.Lock()65defer m.mu.Unlock()6667uid, err := uuid.NewRandom()68if err != nil {69return "", xerrors.Errorf("cannot produce alias: %w", err)70}71alias = uid.String()7273term, err := newTerm(alias, cmd, options)74if err != nil {75return "", err76}77m.aliases = append(m.aliases, alias)78m.terms[alias] = term7980log.WithField("alias", alias).WithField("cmd", cmd.Path).Info("started new terminal")8182go func() {83term.waitErr = cmd.Wait()84close(term.waitDone)85_ = m.CloseTerminal(context.Background(), alias, false)86}()8788return alias, nil89}9091// Close closes all terminals.92// force kills it's processes when the context gets cancelled93func (m *Mux) Close(ctx context.Context) {94m.mu.Lock()95defer m.mu.Unlock()9697wg := sync.WaitGroup{}98for alias, term := range m.terms {99wg.Add(1)100k := alias101v := term102go func() {103defer wg.Done()104err := v.Close(ctx)105if err != nil {106log.WithError(err).WithField("alias", k).Warn("Error while closing pseudo-terminal")107}108}()109}110wg.Wait()111112m.aliases = m.aliases[:0]113for k := range m.terms {114delete(m.terms, k)115}116}117118// CloseTerminal closes a terminal and ends the process that runs in it.119func (m *Mux) CloseTerminal(ctx context.Context, alias string, forceSuccess bool) error {120m.mu.Lock()121defer m.mu.Unlock()122123return m.doClose(ctx, alias, forceSuccess)124}125126// doClose closes a terminal and ends the process that runs in it.127// First, the process receives SIGTERM and is given gracePeriod time128// to stop. If it still runs after that time, it receives SIGKILL.129//130// Callers are expected to hold mu.131func (m *Mux) doClose(ctx context.Context, alias string, forceSuccess bool) error {132term, ok := m.terms[alias]133if !ok {134return ErrNotFound135}136137log := log.WithField("alias", alias)138log.Info("closing terminal")139140if forceSuccess {141term.ForceSuccess = true142}143144err := term.Close(ctx)145if err != nil {146log.WithError(err).Warn("Error while closing pseudo-terminal")147}148149i := 0150for i < len(m.aliases) && m.aliases[i] != alias {151i++152}153if i != len(m.aliases) {154m.aliases = append(m.aliases[:i], m.aliases[i+1:]...)155}156delete(m.terms, alias)157158return nil159}160161// terminalBacklogSize is the number of bytes of output we'll store in RAM for each terminal.162// The higher this number is, the better the UX, but the higher the resource requirements are.163// For now we assume an average of five terminals per workspace, which makes this consume 1MiB of RAM.164const terminalBacklogSize = 256 << 10165166func newTerm(alias string, cmd *exec.Cmd, options TermOptions) (*Term, error) {167token, err := uuid.NewRandom()168if err != nil {169return nil, err170}171172recorder, err := NewRingBuffer(terminalBacklogSize)173if err != nil {174return nil, err175}176177timeout := options.ReadTimeout178if timeout == 0 {179timeout = NoTimeout180}181182annotations := options.Annotations183if annotations == nil {184annotations = make(map[string]string)185}186187size := _pty.Winsize{Cols: DEFAULT_COLS, Rows: DEFAULT_ROWS}188if options.Size != nil {189if options.Size.Cols != 0 {190size.Cols = options.Size.Cols191}192if options.Size.Rows != 0 {193size.Rows = options.Size.Rows194}195}196197pty, pts, err := _pty.Open()198if err != nil {199pts.Close()200pty.Close()201return nil, xerrors.Errorf("cannot start PTY: %w", err)202}203204if err := _pty.Setsize(pty, &size); err != nil {205pts.Close()206pty.Close()207return nil, err208}209210// Set up terminal (from node-pty)211var attr unix.Termios212attr.Iflag = unix.ICRNL | unix.IXON | unix.IXANY | unix.IMAXBEL | unix.BRKINT | syscall.IUTF8213attr.Oflag = unix.OPOST | unix.ONLCR214attr.Cflag = unix.CREAD | unix.CS8 | unix.HUPCL215attr.Lflag = unix.ICANON | unix.ISIG | unix.IEXTEN | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHOKE | unix.ECHOCTL216attr.Cc[unix.VEOF] = 4217attr.Cc[unix.VEOL] = 0xff218attr.Cc[unix.VEOL2] = 0xff219attr.Cc[unix.VERASE] = 0x7f220attr.Cc[unix.VWERASE] = 23221attr.Cc[unix.VKILL] = 21222attr.Cc[unix.VREPRINT] = 18223attr.Cc[unix.VINTR] = 3224attr.Cc[unix.VQUIT] = 0x1c225attr.Cc[unix.VSUSP] = 26226attr.Cc[unix.VSTART] = 17227attr.Cc[unix.VSTOP] = 19228attr.Cc[unix.VLNEXT] = 22229attr.Cc[unix.VDISCARD] = 15230attr.Cc[unix.VMIN] = 1231attr.Cc[unix.VTIME] = 0232233attr.Ispeed = unix.B38400234attr.Ospeed = unix.B38400235attr.Cflag &^= CBAUD | CBAUDEX236attr.Cflag |= unix.B38400237238err = unix.IoctlSetTermios(int(pts.Fd()), syscall.TCSETS, &attr)239if err != nil {240pts.Close()241pty.Close()242return nil, err243}244245cmd.Stdout = pts246cmd.Stderr = pts247cmd.Stdin = pts248249if cmd.SysProcAttr == nil {250cmd.SysProcAttr = &syscall.SysProcAttr{}251}252cmd.SysProcAttr.Setsid = true253cmd.SysProcAttr.Setctty = true254255if err := cmd.Start(); err != nil {256pts.Close()257pty.Close()258return nil, err259}260261res := &Term{262PTY: pty,263pts: pts,264Command: cmd,265Stdout: &multiWriter{266timeout: timeout,267listener: make(map[*multiWriterListener]struct{}),268recorder: recorder,269logStdout: options.LogToStdout,270logLabel: alias,271},272annotations: annotations,273defaultTitle: options.Title,274275StarterToken: token.String(),276277waitDone: make(chan struct{}),278}279280//nolint:errcheck281go io.Copy(res.Stdout, pty)282return res, nil283}284285// NoTimeout means that listener can block read forever286var NoTimeout time.Duration = 1<<63 - 1287288// TermOptions is a pseudo-terminal configuration.289type TermOptions struct {290// timeout after which a listener is dropped. Use 0 for no timeout.291ReadTimeout time.Duration292293// Annotations are user-defined metadata that's attached to a terminal294Annotations map[string]string295296// Size describes the terminal size.297Size *_pty.Winsize298299// Title describes the terminal title.300Title string301302// LogToStdout forwards the terminal's stdout to supervisor's stdout303LogToStdout bool304}305306// Term is a pseudo-terminal.307type Term struct {308PTY *os.File309pts *os.File310311Command *exec.Cmd312StarterToken string313314mu sync.RWMutex315closed bool316317annotations map[string]string318defaultTitle string319title string320321// ForceSuccess overrides the process' exit code to 0322ForceSuccess bool323324Stdout *multiWriter325326waitErr error327waitDone chan struct{}328}329330func (term *Term) GetTitle() (string, api.TerminalTitleSource, error) {331term.mu.RLock()332title := term.title333term.mu.RUnlock()334if title != "" {335return title, api.TerminalTitleSource_api, nil336}337var b bytes.Buffer338defaultTitle := term.defaultTitle339b.WriteString(defaultTitle)340command, err := term.resolveForegroundCommand()341if defaultTitle != "" && command != "" {342b.WriteString(": ")343}344b.WriteString(command)345return b.String(), api.TerminalTitleSource_process, err346}347348func (term *Term) SetTitle(title string) {349term.mu.Lock()350defer term.mu.Unlock()351term.title = title352}353354func (term *Term) GetAnnotations() map[string]string {355term.mu.RLock()356defer term.mu.RUnlock()357annotations := make(map[string]string, len(term.annotations))358for k, v := range term.annotations {359annotations[k] = v360}361return annotations362}363364func (term *Term) UpdateAnnotations(changed map[string]string, deleted []string) {365term.mu.Lock()366defer term.mu.Unlock()367for k, v := range changed {368term.annotations[k] = v369}370for _, k := range deleted {371delete(term.annotations, k)372}373}374375func (term *Term) resolveForegroundCommand() (string, error) {376pgrp, err := unix.IoctlGetInt(int(term.PTY.Fd()), unix.TIOCGPGRP)377if err != nil {378return "", err379}380content, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pgrp))381if err != nil {382return "", err383}384end := bytes.Index(content, []byte{0})385if end != -1 {386content = content[:end]387}388start := bytes.LastIndex(content, []byte{os.PathSeparator})389if start != -1 {390content = content[(start + 1):]391}392return string(content), nil393}394395// Wait waits for the terminal to exit and returns the resulted process state.396func (term *Term) Wait() (*os.ProcessState, error) {397<-term.waitDone398return term.Command.ProcessState, term.waitErr399}400401func (term *Term) Close(ctx context.Context) error {402term.mu.Lock()403defer term.mu.Unlock()404405if term.closed {406return nil407}408409term.closed = true410411var commandErr error412if term.Command.Process != nil {413commandErr = process.TerminateSync(ctx, term.Command.Process.Pid)414if process.IsNotChildProcess(commandErr) {415commandErr = nil416}417}418419writeErr := term.Stdout.Close()420421slaveErr := errors.New("Slave FD nil")422if term.pts != nil {423slaveErr = term.pts.Close()424}425masterErr := errors.New("Master FD nil")426if term.PTY != nil {427masterErr = term.PTY.Close()428}429430var errs []string431if commandErr != nil {432errs = append(errs, "Process: cannot terminate process: "+commandErr.Error())433}434if writeErr != nil {435errs = append(errs, "Multiwriter: "+writeErr.Error())436}437if slaveErr != nil {438errs = append(errs, "Slave: "+slaveErr.Error())439}440if masterErr != nil {441errs = append(errs, "Master: "+masterErr.Error())442}443444if len(errs) > 0 {445return errors.New(strings.Join(errs, " "))446}447448return nil449}450451// multiWriter is like io.MultiWriter, except that we can listener at runtime.452type multiWriter struct {453timeout time.Duration454closed bool455mu sync.RWMutex456listener map[*multiWriterListener]struct{}457// ring buffer to record last 256kb of pty output458// new listener is initialized with the latest recodring first459recorder *RingBuffer460461logStdout bool462logLabel string463}464465var (466// ErrNotFound means the terminal was not found.467ErrNotFound = errors.New("not found")468// ErrReadTimeout happens when a listener takes too long to read.469ErrReadTimeout = errors.New("read timeout")470)471472type multiWriterListener struct {473io.Reader474timeout time.Duration475476closed bool477once sync.Once478closeErr error479closeChan chan struct{}480cchan chan []byte481done chan struct{}482}483484func (l *multiWriterListener) Close() error {485return l.CloseWithError(nil)486}487488func (l *multiWriterListener) CloseWithError(err error) error {489l.once.Do(func() {490if err != nil {491l.closeErr = err492}493l.closed = true494close(l.closeChan)495496// actual cleanup happens in a go routine started by Listen()497})498return nil499}500501func (l *multiWriterListener) Done() <-chan struct{} {502return l.closeChan503}504505type closedTerminalListener struct{}506507func (closedTerminalListener) Read(p []byte) (n int, err error) {508return 0, io.EOF509}510511var closedListener = io.NopCloser(closedTerminalListener{})512513// TermListenOptions is a configuration to listen to the pseudo-terminal .514type TermListenOptions struct {515// timeout after which a listener is dropped. Use 0 for default timeout.516ReadTimeout time.Duration517}518519// Listen listens in on the multi-writer stream.520func (mw *multiWriter) Listen() io.ReadCloser {521return mw.ListenWithOptions(TermListenOptions{522ReadTimeout: 0,523})524}525526// Listen listens in on the multi-writer stream with given options.527func (mw *multiWriter) ListenWithOptions(options TermListenOptions) io.ReadCloser {528mw.mu.Lock()529defer mw.mu.Unlock()530531if mw.closed {532return closedListener533}534535timeout := options.ReadTimeout536if timeout == 0 {537timeout = mw.timeout538}539r, w := io.Pipe()540cchan, done, closeChan := make(chan []byte), make(chan struct{}, 1), make(chan struct{}, 1)541res := &multiWriterListener{542Reader: r,543cchan: cchan,544done: done,545closeChan: closeChan,546timeout: timeout,547}548549recording := mw.recorder.Bytes()550go func() {551_, _ = w.Write(recording)552553// copy bytes from channel to writer.554// Note: we close the writer independently of the write operation s.t. we don't555// block the closing because the write's blocking.556for b := range cchan {557n, err := w.Write(b)558done <- struct{}{}559if err == nil && n != len(b) {560err = io.ErrShortWrite561}562if err != nil {563_ = res.CloseWithError(err)564}565}566}()567go func() {568// listener cleanup on close569<-closeChan570571if res.closeErr != nil {572log.WithError(res.closeErr).Error("terminal listener droped out")573w.CloseWithError(res.closeErr)574} else {575w.Close()576}577578mw.mu.Lock()579defer mw.mu.Unlock()580close(cchan)581582delete(mw.listener, res)583}()584585mw.listener[res] = struct{}{}586587return res588}589590func (mw *multiWriter) Write(p []byte) (n int, err error) {591mw.mu.Lock()592defer mw.mu.Unlock()593594mw.recorder.Write(p)595if mw.logStdout {596log.WithFields(logrus.Fields{597"terminalOutput": true,598"label": mw.logLabel,599}).Info(string(p))600}601602for lstr := range mw.listener {603if lstr.closed {604continue605}606607select {608case lstr.cchan <- p:609case <-time.After(lstr.timeout):610lstr.CloseWithError(ErrReadTimeout)611}612613select {614case <-lstr.done:615case <-time.After(lstr.timeout):616lstr.CloseWithError(ErrReadTimeout)617}618}619return len(p), nil620}621622func (mw *multiWriter) Close() error {623mw.mu.Lock()624defer mw.mu.Unlock()625626mw.closed = true627628var err error629for w := range mw.listener {630cerr := w.Close()631if cerr != nil {632err = cerr633}634}635return err636}637638func (mw *multiWriter) ListenerCount() int {639mw.mu.Lock()640defer mw.mu.Unlock()641642return len(mw.listener)643}644645646