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 }