@@ -1,9 +1,11 @@
1package shell
1package shell
9 "path/filepath"
11 "path/filepath"
@@ -13,11 +15,13 @@ import (
15 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/config"
18+ "github.com/charmbracelet/crush/internal/logging"
19+ "github.com/shirou/gopsutil/v4/process"
18type PersistentShell struct {
22type PersistentShell struct {
19 cmd *exec.Cmd
23 cmd *exec.Cmd
20− stdin *os.File
24+ stdin io.WriteCloser
21 isAlive bool
25 isAlive bool
22 cwd string
26 cwd string
23 mu sync.Mutex
27 mu sync.Mutex
@@ -39,22 +43,15 @@ type commandResult struct {
43− shellInstance *PersistentShell
46+var shellInstance *PersistentShell
44− shellInstanceOnce sync.Once
47func GetPersistentShell(workingDir string) *PersistentShell {
48func GetPersistentShell(workingDir string) *PersistentShell {
48− shellInstanceOnce.Do(func() {
49− shellInstance = newPersistentShell(workingDir)
52 if shellInstance == nil {
49 if shellInstance == nil {
53 shellInstance = newPersistentShell(workingDir)
50 shellInstance = newPersistentShell(workingDir)
54− } else if !shellInstance.isAlive {
52+ if !shellInstance.isAlive {
55 shellInstance = newPersistentShell(shellInstance.cwd)
53 shellInstance = newPersistentShell(shellInstance.cwd)
58 return shellInstance
55 return shellInstance
@@ -71,16 +68,15 @@ func newPersistentShell(cwd string) *PersistentShell {
71 shellArgs = cfg.Shell.Args
68 shellArgs = cfg.Shell.Args
75− shellPath = os.Getenv("SHELL")
71+ shellPath = cmp.Or(shellPath, os.Getenv("SHELL"), "/bin/bash")
76− if shellPath == "" {
72+ if !strings.HasSuffix(shellPath, "bash") && !strings.HasSuffix(shellPath, "zsh") {
73+ logging.Warn("only bash and zsh are supported at this time", "shell", shellPath)
77− shellPath = "/bin/bash"
74+ shellPath = "/bin/bash"
81 // Default shell args
77 // Default shell args
82 if len(shellArgs) == 0 {
78 if len(shellArgs) == 0 {
83− shellArgs = []string{"-l"}
79+ shellArgs = []string{"--login"}
86 cmd := exec.Command(shellPath, shellArgs...)
82 cmd := exec.Command(shellPath, shellArgs...)
@@ -100,7 +96,7 @@ func newPersistentShell(cwd string) *PersistentShell {
101 shell := &PersistentShell{
97 shell := &PersistentShell{
102 cmd: cmd,
98 cmd: cmd,
103− stdin: stdinPipe.(*os.File),
99+ stdin: stdinPipe,
104 isAlive: true,
100 isAlive: true,
105 cwd: cwd,
101 cwd: cwd,
106 commandQueue: make(chan *commandExecution, 10),
102 commandQueue: make(chan *commandExecution, 10),
@@ -131,12 +127,15 @@ func newPersistentShell(cwd string) *PersistentShell {
132func (s *PersistentShell) processCommands() {
128func (s *PersistentShell) processCommands() {
133 for cmd := range s.commandQueue {
129 for cmd := range s.commandQueue {
134− result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
130+ cmd.resultChan <- s.execCommand(cmd.ctx, cmd.command, cmd.timeout)
135− cmd.resultChan <- result
134+const runBashCommandFormat = `%s </dev/null >%q 2>%q
139−func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
138+func (s *PersistentShell) execCommand(ctx context.Context, command string, timeout time.Duration) commandResult {
140 s.mu.Lock()
139 s.mu.Lock()
141 defer s.mu.Unlock()
140 defer s.mu.Unlock()
@@ -148,34 +147,22 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
151− tempDir := os.TempDir()
150+ tmp := os.TempDir()
151+ now := time.Now().UnixNano()
152− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
152+ stdoutFile := filepath.Join(tmp, fmt.Sprintf("crush-stdout-%d", now))
153− stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
153+ stderrFile := filepath.Join(tmp, fmt.Sprintf("crush-stderr-%d", now))
154− statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
154+ statusFile := filepath.Join(tmp, fmt.Sprintf("crush-status-%d", now))
155− cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
155+ cwdFile := filepath.Join(tmp, fmt.Sprintf("crush-cwd-%d", now))
157 defer func() {
157 defer func() {
158− os.Remove(stdoutFile)
158+ _ = os.Remove(stdoutFile)
159− os.Remove(stderrFile)
159+ _ = os.Remove(stderrFile)
160− os.Remove(statusFile)
160+ _ = os.Remove(statusFile)
161− os.Remove(cwdFile)
161+ _ = os.Remove(cwdFile)
164− fullCommand := fmt.Sprintf(`
164+ script := fmt.Sprintf(runBashCommandFormat, command, stdoutFile, stderrFile, statusFile, cwdFile)
165−eval %s < /dev/null > %s 2> %s
168−echo $EXEC_EXIT_CODE > %s
170− shellQuote(command),
171− shellQuote(stdoutFile),
172− shellQuote(stderrFile),
173− shellQuote(cwdFile),
174− shellQuote(statusFile),
177− _, err := s.stdin.Write([]byte(fullCommand + "\n"))
165+ if _, err := s.stdin.Write([]byte(script + "\n")); err != nil {
179 return commandResult{
166 return commandResult{
180 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
167 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
181 exitCode: 1,
168 exitCode: 1,
@@ -184,18 +171,18 @@ echo $EXEC_EXIT_CODE > %s
186 interrupted := false
173 interrupted := false
188− startTime := time.Now()
190 done := make(chan bool)
174 done := make(chan bool)
191 go func() {
175 go func() {
192 // Use exponential backoff polling
176 // Use exponential backoff polling
193− pollInterval := 1 * time.Millisecond
177+ pollInterval := 10 * time.Millisecond
194− maxPollInterval := 100 * time.Millisecond
178+ maxPollInterval := time.Second
196 ticker := time.NewTicker(pollInterval)
180 ticker := time.NewTicker(pollInterval)
197 defer ticker.Stop()
181 defer ticker.Stop()
183+ timeoutTicker := time.NewTicker(cmp.Or(timeout, time.Hour*99999))
184+ defer timeoutTicker.Stop()
201 case <-ctx.Done():
188 case <-ctx.Done():
@@ -204,28 +191,21 @@ echo $EXEC_EXIT_CODE > %s
204 done <- true
191 done <- true
194+ case <-timeoutTicker.C:
207 case <-ticker.C:
200 case <-ticker.C:
208− if fileExists(statusFile) && fileSize(statusFile) > 0 {
201+ if fileSize(statusFile) > 0 {
209 done <- true
202 done <- true
214− elapsed := time.Since(startTime)
215− if elapsed > timeout {
223 // Exponential backoff to reduce CPU usage for longer-running commands
206 // Exponential backoff to reduce CPU usage for longer-running commands
224 if pollInterval < maxPollInterval {
207 if pollInterval < maxPollInterval {
225− pollInterval = time.Duration(float64(pollInterval) * 1.5)
208+ pollInterval = min(time.Duration(float64(pollInterval)*1.5), maxPollInterval)
226− if pollInterval > maxPollInterval {
227− pollInterval = maxPollInterval
229 ticker.Reset(pollInterval)
209 ticker.Reset(pollInterval)
@@ -263,23 +243,21 @@ func (s *PersistentShell) killChildren() {
263 if s.cmd == nil || s.cmd.Process == nil {
243 if s.cmd == nil || s.cmd.Process == nil {
246+ p, err := process.NewProcess(int32(s.cmd.Process.Pid))
248+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
267− pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
252+ children, err := p.Children()
268− output, err := pgrepCmd.Output()
269 if err != nil {
253 if err != nil {
254+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
273− for pidStr := range strings.SplitSeq(string(output), "\n") {
258+ for _, child := range children {
274− if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
259+ if err := child.SendSignal(syscall.SIGTERM); err != nil {
275− var pid int
260+ logging.WarnPersist("could not kill persistent shell child processes", "err", err, "pid", child.Pid)
276− fmt.Sscanf(pidStr, "%d", &pid)
278− proc, err := os.FindProcess(pid)
280− proc.Signal(syscall.SIGTERM)
@@ -289,12 +267,10 @@ func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs in
289 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
267 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
292− timeout := time.Duration(timeoutMs) * time.Millisecond
294 resultChan := make(chan commandResult)
270 resultChan := make(chan commandResult)
295 s.commandQueue <- &commandExecution{
271 s.commandQueue <- &commandExecution{
296 command: command,
272 command: command,
297− timeout: timeout,
273+ timeout: time.Duration(timeoutMs) * time.Millisecond,
298 resultChan: resultChan,
274 resultChan: resultChan,
299 ctx: ctx,
275 ctx: ctx,
@@ -313,14 +289,12 @@ func (s *PersistentShell) Close() {
314 s.stdin.Write([]byte("exit\n"))
290 s.stdin.Write([]byte("exit\n"))
316− s.cmd.Process.Kill()
292+ if err := s.cmd.Process.Kill(); err != nil {
293+ logging.WarnPersist("could not kill persistent shell", "err", err)
317 s.isAlive = false
295 s.isAlive = false
320−func shellQuote(s string) string {
321− return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
324func readFileOrEmpty(path string) string {
298func readFileOrEmpty(path string) string {
325 content, err := os.ReadFile(path)
299 content, err := os.ReadFile(path)
326 if err != nil {
300 if err != nil {
@@ -329,11 +303,6 @@ func readFileOrEmpty(path string) string {
329 return string(content)
303 return string(content)
332−func fileExists(path string) bool {
333− _, err := os.Stat(path)
337func fileSize(path string) int64 {
306func fileSize(path string) int64 {
338 info, err := os.Stat(path)
307 info, err := os.Stat(path)
339 if err != nil {
308 if err != nil {