src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/shell/interact.go (about)

     1  package shell
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"strings"
    10  	"syscall"
    11  	"time"
    12  
    13  	"src.elv.sh/pkg/cli"
    14  	"src.elv.sh/pkg/daemon/daemondefs"
    15  	"src.elv.sh/pkg/diag"
    16  	"src.elv.sh/pkg/edit"
    17  	"src.elv.sh/pkg/eval"
    18  	"src.elv.sh/pkg/mods/daemon"
    19  	"src.elv.sh/pkg/mods/store"
    20  	"src.elv.sh/pkg/parse"
    21  	"src.elv.sh/pkg/strutil"
    22  	"src.elv.sh/pkg/sys"
    23  	"src.elv.sh/pkg/ui"
    24  )
    25  
    26  // InteractiveRescueShell determines whether a panic results in a rescue shell
    27  // being launched. It should be set to false by interactive mode unit tests.
    28  var interactiveRescueShell bool = true
    29  
    30  // Configuration for the interactive mode.
    31  type interactCfg struct {
    32  	RC string
    33  
    34  	ActivateDaemon daemondefs.ActivateFunc
    35  	SpawnConfig    *daemondefs.SpawnConfig
    36  }
    37  
    38  // Interface satisfied by the line editor. Used for swapping out the editor with
    39  // minEditor when necessary.
    40  type editor interface {
    41  	ReadCode() (string, error)
    42  	RunAfterCommandHooks(src parse.Source, duration float64, err error)
    43  }
    44  
    45  // Runs an interactive shell session.
    46  func interact(ev *eval.Evaler, fds [3]*os.File, cfg *interactCfg) {
    47  	if interactiveRescueShell {
    48  		defer handlePanic()
    49  	}
    50  
    51  	var daemonClient daemondefs.Client
    52  	if cfg.ActivateDaemon != nil && cfg.SpawnConfig != nil {
    53  		// TODO(xiaq): Connect to daemon and install daemon module
    54  		// asynchronously.
    55  		cl, err := cfg.ActivateDaemon(fds[2], cfg.SpawnConfig)
    56  		if err != nil {
    57  			fmt.Fprintln(fds[2], "Cannot connect to daemon:", err)
    58  			fmt.Fprintln(fds[2], "Daemon-related functions will likely not work.")
    59  		}
    60  		if cl != nil {
    61  			// Even if error is not nil, we install daemon-related
    62  			// functionalities anyway. Daemon may eventually come online and
    63  			// become functional.
    64  			daemonClient = cl
    65  			ev.PreExitHooks = append(ev.PreExitHooks, func() { cl.Close() })
    66  			ev.AddModule("store", store.Ns(cl))
    67  			ev.AddModule("daemon", daemon.Ns(cl))
    68  		}
    69  	}
    70  
    71  	// Build Editor.
    72  	var ed editor
    73  	if sys.IsATTY(fds[0].Fd()) {
    74  		newed := edit.NewEditor(cli.NewTTY(fds[0], fds[2]), ev, daemonClient)
    75  		ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", newed))
    76  		ev.BgJobNotify = func(s string) { newed.Notify(ui.T(s)) }
    77  		ed = newed
    78  	} else {
    79  		ed = newMinEditor(fds[0], fds[2])
    80  	}
    81  
    82  	// Source rc.elv.
    83  	if cfg.RC != "" {
    84  		err := sourceRC(fds, ev, ed, cfg.RC)
    85  		if err != nil {
    86  			diag.ShowError(fds[2], err)
    87  		}
    88  	}
    89  
    90  	cooldown := time.Second
    91  	cmdNum := 0
    92  
    93  	for {
    94  		cmdNum++
    95  
    96  		line, err := ed.ReadCode()
    97  		if err == io.EOF {
    98  			break
    99  		} else if err != nil {
   100  			fmt.Fprintln(fds[2], "Editor error:", err)
   101  			if _, isMinEditor := ed.(*minEditor); !isMinEditor {
   102  				fmt.Fprintln(fds[2], "Falling back to basic line editor")
   103  				ed = newMinEditor(fds[0], fds[2])
   104  			} else {
   105  				fmt.Fprintln(fds[2], "Don't know what to do, pid is", os.Getpid())
   106  				fmt.Fprintln(fds[2], "Restarting editor in", cooldown)
   107  				time.Sleep(cooldown)
   108  				if cooldown < time.Minute {
   109  					cooldown *= 2
   110  				}
   111  			}
   112  			continue
   113  		}
   114  
   115  		// No error; reset cooldown.
   116  		cooldown = time.Second
   117  
   118  		// Execute the command line only if it is not entirely whitespace. This keeps side-effects,
   119  		// such as executing `$edit:after-command` hooks, from occurring when we didn't actually
   120  		// evaluate any code entered by the user.
   121  		if strings.TrimSpace(line) == "" {
   122  			continue
   123  		}
   124  		err = evalInTTY(fds, ev, ed,
   125  			parse.Source{Name: fmt.Sprintf("[tty %v]", cmdNum), Code: line})
   126  		if err != nil {
   127  			diag.ShowError(fds[2], err)
   128  		}
   129  	}
   130  }
   131  
   132  // Interactive mode panic handler.
   133  func handlePanic() {
   134  	r := recover()
   135  	if r != nil {
   136  		println()
   137  		print(sys.DumpStack())
   138  		println()
   139  		fmt.Println(r)
   140  		println("\nExecing recovery shell /bin/sh")
   141  		syscall.Exec("/bin/sh", []string{"/bin/sh"}, os.Environ())
   142  	}
   143  }
   144  
   145  func sourceRC(fds [3]*os.File, ev *eval.Evaler, ed editor, rcPath string) error {
   146  	absPath, err := filepath.Abs(rcPath)
   147  	if err != nil {
   148  		return fmt.Errorf("cannot get full path of rc.elv: %v", err)
   149  	}
   150  	code, err := readFileUTF8(absPath)
   151  	if err != nil {
   152  		if os.IsNotExist(err) {
   153  			return nil
   154  		}
   155  		return err
   156  	}
   157  	return evalInTTY(fds, ev, ed, parse.Source{Name: absPath, Code: code, IsFile: true})
   158  }
   159  
   160  type minEditor struct {
   161  	in  *bufio.Reader
   162  	out io.Writer
   163  }
   164  
   165  func newMinEditor(in, out *os.File) *minEditor {
   166  	return &minEditor{bufio.NewReader(in), out}
   167  }
   168  
   169  func (ed *minEditor) RunAfterCommandHooks(src parse.Source, duration float64, err error) {
   170  	// no-op; minEditor doesn't support this hook.
   171  }
   172  
   173  func (ed *minEditor) ReadCode() (string, error) {
   174  	wd, err := os.Getwd()
   175  	if err != nil {
   176  		wd = "?"
   177  	}
   178  	fmt.Fprintf(ed.out, "%s> ", wd)
   179  	line, err := ed.in.ReadString('\n')
   180  	return strutil.ChopLineEnding(line), err
   181  }