@@ -1,9 +1,11 @@
11package shell
22
33import (
4+ "cmp"
45 "context"
56 "errors"
67 "fmt"
8+ "io"
79 "os"
810 "os/exec"
911 "path/filepath"
@@ -13,11 +15,13 @@ import (
1315 "time"
1416
1517 "github.com/charmbracelet/crush/internal/config"
18+ "github.com/charmbracelet/crush/internal/logging"
19+ "github.com/shirou/gopsutil/v4/process"
1620)
1721
1822type PersistentShell struct {
1923 cmd *exec.Cmd
20− stdin *os.File
24+ stdin io.WriteCloser
2125 isAlive bool
2226 cwd string
2327 mu sync.Mutex
@@ -39,22 +43,15 @@ type commandResult struct {
3943 err error
4044}
4145
42−var (
43− shellInstance *PersistentShell
44− shellInstanceOnce sync.Once
45−)
46+var shellInstance *PersistentShell
4647
4748func GetPersistentShell(workingDir string) *PersistentShell {
48− shellInstanceOnce.Do(func() {
49− shellInstance = newPersistentShell(workingDir)
50− })
51−
5249 if shellInstance == nil {
5350 shellInstance = newPersistentShell(workingDir)
54− } else if !shellInstance.isAlive {
51+ }
52+ if !shellInstance.isAlive {
5553 shellInstance = newPersistentShell(shellInstance.cwd)
5654 }
57−
5855 return shellInstance
5956}
6057
@@ -71,16 +68,15 @@ func newPersistentShell(cwd string) *PersistentShell {
7168 shellArgs = cfg.Shell.Args
7269 }
7370
74− if shellPath == "" {
75− shellPath = os.Getenv("SHELL")
76− if shellPath == "" {
77− shellPath = "/bin/bash"
78− }
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"
7975 }
8076
8177 // Default shell args
8278 if len(shellArgs) == 0 {
83− shellArgs = []string{"-l"}
79+ shellArgs = []string{"--login"}
8480 }
8581
8682 cmd := exec.Command(shellPath, shellArgs...)
@@ -100,7 +96,7 @@ func newPersistentShell(cwd string) *PersistentShell {
10096
10197 shell := &PersistentShell{
10298 cmd: cmd,
103− stdin: stdinPipe.(*os.File),
99+ stdin: stdinPipe,
104100 isAlive: true,
105101 cwd: cwd,
106102 commandQueue: make(chan *commandExecution, 10),
@@ -131,12 +127,15 @@ func newPersistentShell(cwd string) *PersistentShell {
131127
132128func (s *PersistentShell) processCommands() {
133129 for cmd := range s.commandQueue {
134− result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
135− cmd.resultChan <- result
130+ cmd.resultChan <- s.execCommand(cmd.ctx, cmd.command, cmd.timeout)
136131 }
137132}
138133
139−func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
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 {
140139 s.mu.Lock()
141140 defer s.mu.Unlock()
142141
@@ -148,34 +147,22 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
148147 }
149148 }
150149
151− tempDir := os.TempDir()
152− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
153− stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
154− statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
155− cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
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))
156156
157157 defer func() {
158− os.Remove(stdoutFile)
159− os.Remove(stderrFile)
160− os.Remove(statusFile)
161− os.Remove(cwdFile)
158+ _ = os.Remove(stdoutFile)
159+ _ = os.Remove(stderrFile)
160+ _ = os.Remove(statusFile)
161+ _ = os.Remove(cwdFile)
162162 }()
163163
164− fullCommand := fmt.Sprintf(`
165−eval %s < /dev/null > %s 2> %s
166−EXEC_EXIT_CODE=$?
167−pwd > %s
168−echo $EXEC_EXIT_CODE > %s
169−`,
170− shellQuote(command),
171− shellQuote(stdoutFile),
172− shellQuote(stderrFile),
173− shellQuote(cwdFile),
174− shellQuote(statusFile),
175− )
176−
177− _, err := s.stdin.Write([]byte(fullCommand + "\n"))
178− if err != nil {
164+ script := fmt.Sprintf(runBashCommandFormat, command, stdoutFile, stderrFile, statusFile, cwdFile)
165+ if _, err := s.stdin.Write([]byte(script + "\n")); err != nil {
179166 return commandResult{
180167 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
181168 exitCode: 1,
@@ -184,18 +171,18 @@ echo $EXEC_EXIT_CODE > %s
184171 }
185172
186173 interrupted := false
187−
188− startTime := time.Now()
189−
190174 done := make(chan bool)
191175 go func() {
192176 // Use exponential backoff polling
193− pollInterval := 1 * time.Millisecond
194− maxPollInterval := 100 * time.Millisecond
177+ pollInterval := 10 * time.Millisecond
178+ maxPollInterval := time.Second
195179
196180 ticker := time.NewTicker(pollInterval)
197181 defer ticker.Stop()
198182
183+ timeoutTicker := time.NewTicker(cmp.Or(timeout, time.Hour*99999))
184+ defer timeoutTicker.Stop()
185+
199186 for {
200187 select {
201188 case <-ctx.Done():
@@ -204,28 +191,21 @@ echo $EXEC_EXIT_CODE > %s
204191 done <- true
205192 return
206193
194+ case <-timeoutTicker.C:
195+ s.killChildren()
196+ interrupted = true
197+ done <- true
198+ return
199+
207200 case <-ticker.C:
208− if fileExists(statusFile) && fileSize(statusFile) > 0 {
201+ if fileSize(statusFile) > 0 {
209202 done <- true
210203 return
211204 }
212205
213− if timeout > 0 {
214− elapsed := time.Since(startTime)
215− if elapsed > timeout {
216− s.killChildren()
217− interrupted = true
218− done <- true
219− return
220− }
221− }
222−
223206 // Exponential backoff to reduce CPU usage for longer-running commands
224207 if pollInterval < maxPollInterval {
225− pollInterval = time.Duration(float64(pollInterval) * 1.5)
226− if pollInterval > maxPollInterval {
227− pollInterval = maxPollInterval
228− }
208+ pollInterval = min(time.Duration(float64(pollInterval)*1.5), maxPollInterval)
229209 ticker.Reset(pollInterval)
230210 }
231211 }
@@ -263,23 +243,21 @@ func (s *PersistentShell) killChildren() {
263243 if s.cmd == nil || s.cmd.Process == nil {
264244 return
265245 }
246+ p, err := process.NewProcess(int32(s.cmd.Process.Pid))
247+ if err != nil {
248+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
249+ return
250+ }
266251
267− pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
268− output, err := pgrepCmd.Output()
252+ children, err := p.Children()
269253 if err != nil {
254+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
270255 return
271256 }
272257
273− for pidStr := range strings.SplitSeq(string(output), "\n") {
274− if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
275− var pid int
276− fmt.Sscanf(pidStr, "%d", &pid)
277− if pid > 0 {
278− proc, err := os.FindProcess(pid)
279− if err == nil {
280− proc.Signal(syscall.SIGTERM)
281− }
282− }
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)
283261 }
284262 }
285263}
@@ -289,12 +267,10 @@ func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs in
289267 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
290268 }
291269
292− timeout := time.Duration(timeoutMs) * time.Millisecond
293−
294270 resultChan := make(chan commandResult)
295271 s.commandQueue <- &commandExecution{
296272 command: command,
297− timeout: timeout,
273+ timeout: time.Duration(timeoutMs) * time.Millisecond,
298274 resultChan: resultChan,
299275 ctx: ctx,
300276 }
@@ -313,14 +289,12 @@ func (s *PersistentShell) Close() {
313289
314290 s.stdin.Write([]byte("exit\n"))
315291
316− s.cmd.Process.Kill()
292+ if err := s.cmd.Process.Kill(); err != nil {
293+ logging.WarnPersist("could not kill persistent shell", "err", err)
294+ }
317295 s.isAlive = false
318296}
319297
320−func shellQuote(s string) string {
321− return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
322−}
323−
324298func readFileOrEmpty(path string) string {
325299 content, err := os.ReadFile(path)
326300 if err != nil {
@@ -329,11 +303,6 @@ func readFileOrEmpty(path string) string {
329303 return string(content)
330304}
331305
332−func fileExists(path string) bool {
333− _, err := os.Stat(path)
334− return err == nil
335−}
336−
337306func fileSize(path string) int64 {
338307 info, err := os.Stat(path)
339308 if err != nil {