@@ -1,312 +1,87 @@
11package shell
22
33import (
4− "cmp"
4+ "bytes"
55 "context"
66 "errors"
77 "fmt"
8− "io"
98 "os"
10− "os/exec"
11− "path/filepath"
129 "strings"
1310 "sync"
14− "syscall"
15− "time"
1611
17− "github.com/charmbracelet/crush/internal/config"
1812 "github.com/charmbracelet/crush/internal/logging"
19− "github.com/shirou/gopsutil/v4/process"
13+ "mvdan.cc/sh/v3/expand"
14+ "mvdan.cc/sh/v3/interp"
15+ "mvdan.cc/sh/v3/syntax"
2016)
2117
2218type PersistentShell struct {
23− cmd *exec.Cmd
24− stdin io.WriteCloser
25− isAlive bool
26− cwd string
27− mu sync.Mutex
28− commandQueue chan *commandExecution
19+ env []string
20+ cwd string
21+ mu sync.Mutex
2922}
3023
31−type commandExecution struct {
32− command string
33− timeout time.Duration
34− resultChan chan commandResult
35− ctx context.Context
36−}
37−
38−type commandResult struct {
39− stdout string
40− stderr string
41− exitCode int
42− interrupted bool
43− err error
44−}
45−
46−var shellInstance *PersistentShell
24+var (
25+ once sync.Once
26+ shellInstance *PersistentShell
27+)
4728
48−func GetPersistentShell(workingDir string) *PersistentShell {
49− if shellInstance == nil {
50− shellInstance = newPersistentShell(workingDir)
51− }
52− if !shellInstance.isAlive {
53− shellInstance = newPersistentShell(shellInstance.cwd)
54− }
29+func GetPersistentShell(cwd string) *PersistentShell {
30+ once.Do(func() {
31+ shellInstance = newPersistentShell(cwd)
32+ })
5533 return shellInstance
5634}
5735
5836func newPersistentShell(cwd string) *PersistentShell {
59− // Get shell configuration from config
60− cfg := config.Get()
61−
62− // Default to environment variable if config is not set or nil
63− var shellPath string
64− var shellArgs []string
65−
66− if cfg != nil {
67− shellPath = cfg.Shell.Path
68− shellArgs = cfg.Shell.Args
69− }
70−
71− shellPath = cmp.Or(shellPath, os.Getenv("SHELL"), "/bin/bash")
72− if !strings.HasSuffix(shellPath, "bash") && !strings.HasSuffix(shellPath, "zsh") {
73− logging.Warn("only bash and zsh are supported at this time", "shell", shellPath)
74− shellPath = "/bin/bash"
75− }
76−
77− // Default shell args
78− if len(shellArgs) == 0 {
79− shellArgs = []string{"--login"}
80− }
81−
82− cmd := exec.Command(shellPath, shellArgs...)
83− cmd.Dir = cwd
84−
85− stdinPipe, err := cmd.StdinPipe()
86− if err != nil {
87− return nil
88− }
89−
90− cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
91−
92− err = cmd.Start()
93− if err != nil {
94− return nil
95− }
96−
97− shell := &PersistentShell{
98− cmd: cmd,
99− stdin: stdinPipe,
100− isAlive: true,
101− cwd: cwd,
102− commandQueue: make(chan *commandExecution, 10),
103− }
104−
105− go func() {
106− defer func() {
107− if r := recover(); r != nil {
108− fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
109− shell.isAlive = false
110− close(shell.commandQueue)
111− }
112− }()
113− shell.processCommands()
114− }()
115−
116− go func() {
117− err := cmd.Wait()
118− if err != nil {
119− // Log the error if needed
120− }
121− shell.isAlive = false
122− close(shell.commandQueue)
123− }()
124−
125− return shell
126−}
127−
128−func (s *PersistentShell) processCommands() {
129− for cmd := range s.commandQueue {
130− cmd.resultChan <- s.execCommand(cmd.ctx, cmd.command, cmd.timeout)
37+ return &PersistentShell{
38+ cwd: cwd,
39+ env: os.Environ(),
13140 }
13241}
13342
134−const runBashCommandFormat = `%s </dev/null >%q 2>%q
135−echo $? >%q
136−pwd >%q`
137−
138−func (s *PersistentShell) execCommand(ctx context.Context, command string, timeout time.Duration) commandResult {
43+func (s *PersistentShell) Exec(ctx context.Context, command string) (string, string, error) {
13944 s.mu.Lock()
14045 defer s.mu.Unlock()
14146
142− if !s.isAlive {
143− return commandResult{
144− stderr: "Shell is not alive",
145− exitCode: 1,
146− err: errors.New("shell is not alive"),
147− }
148− }
149−
150− tmp := os.TempDir()
151− now := time.Now().UnixNano()
152− stdoutFile := filepath.Join(tmp, fmt.Sprintf("crush-stdout-%d", now))
153− stderrFile := filepath.Join(tmp, fmt.Sprintf("crush-stderr-%d", now))
154− statusFile := filepath.Join(tmp, fmt.Sprintf("crush-status-%d", now))
155− cwdFile := filepath.Join(tmp, fmt.Sprintf("crush-cwd-%d", now))
156−
157− defer func() {
158− _ = os.Remove(stdoutFile)
159− _ = os.Remove(stderrFile)
160− _ = os.Remove(statusFile)
161− _ = os.Remove(cwdFile)
162− }()
163−
164− script := fmt.Sprintf(runBashCommandFormat, command, stdoutFile, stderrFile, statusFile, cwdFile)
165− if _, err := s.stdin.Write([]byte(script + "\n")); err != nil {
166− return commandResult{
167− stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
168− exitCode: 1,
169− err: err,
170− }
171− }
172−
173− interrupted := false
174− done := make(chan bool)
175− go func() {
176− // Use exponential backoff polling
177− pollInterval := 10 * time.Millisecond
178− maxPollInterval := time.Second
179−
180− ticker := time.NewTicker(pollInterval)
181− defer ticker.Stop()
182−
183− timeoutTicker := time.NewTicker(cmp.Or(timeout, time.Hour*99999))
184− defer timeoutTicker.Stop()
185−
186− for {
187− select {
188− case <-ctx.Done():
189− s.killChildren()
190− interrupted = true
191− done <- true
192− return
193−
194− case <-timeoutTicker.C:
195− s.killChildren()
196− interrupted = true
197− done <- true
198− return
199−
200− case <-ticker.C:
201− if fileSize(statusFile) > 0 {
202− done <- true
203− return
204− }
205−
206− // Exponential backoff to reduce CPU usage for longer-running commands
207− if pollInterval < maxPollInterval {
208− pollInterval = min(time.Duration(float64(pollInterval)*1.5), maxPollInterval)
209− ticker.Reset(pollInterval)
210− }
211− }
212− }
213− }()
214−
215− <-done
216−
217− stdout := readFileOrEmpty(stdoutFile)
218− stderr := readFileOrEmpty(stderrFile)
219− exitCodeStr := readFileOrEmpty(statusFile)
220− newCwd := readFileOrEmpty(cwdFile)
221−
222− exitCode := 0
223− if exitCodeStr != "" {
224− fmt.Sscanf(exitCodeStr, "%d", &exitCode)
225− } else if interrupted {
226− exitCode = 143
227− stderr += "\nCommand execution timed out or was interrupted"
228− }
229−
230− if newCwd != "" {
231− s.cwd = strings.TrimSpace(newCwd)
232− }
233−
234− return commandResult{
235− stdout: stdout,
236− stderr: stderr,
237− exitCode: exitCode,
238− interrupted: interrupted,
239− }
240−}
241−
242−func (s *PersistentShell) killChildren() {
243− if s.cmd == nil || s.cmd.Process == nil {
244− return
245− }
246− p, err := process.NewProcess(int32(s.cmd.Process.Pid))
47+ line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
24748 if err != nil {
248− logging.WarnPersist("could not kill persistent shell child processes", "err", err)
249− return
49+ return "", "", fmt.Errorf("could not parse command: %w", err)
25050 }
25151
252− children, err := p.Children()
52+ var stdout, stderr bytes.Buffer
53+ runner, err := interp.New(
54+ interp.StdIO(nil, &stdout, &stderr),
55+ interp.Interactive(false),
56+ interp.Env(expand.ListEnviron(s.env...)),
57+ interp.Dir(s.cwd),
58+ )
25359 if err != nil {
254− logging.WarnPersist("could not kill persistent shell child processes", "err", err)
255− return
256− }
257−
258− for _, child := range children {
259− if err := child.SendSignal(syscall.SIGTERM); err != nil {
260− logging.WarnPersist("could not kill persistent shell child processes", "err", err, "pid", child.Pid)
261− }
262− }
263−}
264−
265−func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs int) (string, string, int, bool, error) {
266− if !s.isAlive {
267− return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
268− }
269−
270− resultChan := make(chan commandResult)
271− s.commandQueue <- &commandExecution{
272− command: command,
273− timeout: time.Duration(timeoutMs) * time.Millisecond,
274− resultChan: resultChan,
275− ctx: ctx,
276− }
277−
278− result := <-resultChan
279− return result.stdout, result.stderr, result.exitCode, result.interrupted, result.err
280−}
281−
282−func (s *PersistentShell) Close() {
283− s.mu.Lock()
284− defer s.mu.Unlock()
285−
286− if !s.isAlive {
287− return
60+ return "", "", fmt.Errorf("could not run command: %w", err)
28861 }
28962
290− s.stdin.Write([]byte("exit\n"))
291−
292− if err := s.cmd.Process.Kill(); err != nil {
293− logging.WarnPersist("could not kill persistent shell", "err", err)
63+ err = runner.Run(ctx, line)
64+ s.cwd = runner.Dir
65+ s.env = []string{}
66+ for name, vr := range runner.Vars {
67+ s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
29468 }
295− s.isAlive = false
69+ logging.InfoPersist("Command finished", "command", command, "err", err)
70+ return stdout.String(), stderr.String(), err
29671}
29772
298−func readFileOrEmpty(path string) string {
299− content, err := os.ReadFile(path)
300− if err != nil {
301− return ""
302− }
303− return string(content)
73+func IsInterrupt(err error) bool {
74+ return errors.Is(err, context.Canceled) ||
75+ errors.Is(err, context.DeadlineExceeded)
30476}
30577
306−func fileSize(path string) int64 {
307− info, err := os.Stat(path)
308− if err != nil {
78+func ExitCode(err error) int {
79+ if err == nil {
30980 return 0
31081 }
311− return info.Size()
82+ status, ok := interp.IsExitStatus(err)
83+ if ok {
84+ return int(status)
85+ }
86+ return 1
31287}