@@ -1,6 +1,7 @@
11package shell
22
33import (
4+ "cmp"
45 "context"
56 "errors"
67 "fmt"
@@ -67,16 +68,15 @@ func newPersistentShell(cwd string) *PersistentShell {
6768 shellArgs = cfg.Shell.Args
6869 }
6970
70− if shellPath == "" {
71− shellPath = os.Getenv("SHELL")
72− if shellPath == "" {
73− shellPath = "/bin/bash"
74− }
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"
7575 }
7676
7777 // Default shell args
7878 if len(shellArgs) == 0 {
79− shellArgs = []string{"-l"}
79+ shellArgs = []string{"--login"}
8080 }
8181
8282 cmd := exec.Command(shellPath, shellArgs...)
@@ -127,12 +127,15 @@ func newPersistentShell(cwd string) *PersistentShell {
127127
128128func (s *PersistentShell) processCommands() {
129129 for cmd := range s.commandQueue {
130− result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
131− cmd.resultChan <- result
130+ cmd.resultChan <- s.execCommand(cmd.ctx, cmd.command, cmd.timeout)
132131 }
133132}
134133
135−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 {
136139 s.mu.Lock()
137140 defer s.mu.Unlock()
138141
@@ -144,34 +147,22 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
144147 }
145148 }
146149
147− tempDir := os.TempDir()
148− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
149− stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
150− statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
151− 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))
152156
153157 defer func() {
154− os.Remove(stdoutFile)
155− os.Remove(stderrFile)
156− os.Remove(statusFile)
157− os.Remove(cwdFile)
158+ _ = os.Remove(stdoutFile)
159+ _ = os.Remove(stderrFile)
160+ _ = os.Remove(statusFile)
161+ _ = os.Remove(cwdFile)
158162 }()
159163
160− fullCommand := fmt.Sprintf(`
161−eval %s < /dev/null > %s 2> %s
162−EXEC_EXIT_CODE=$?
163−pwd > %s
164−echo $EXEC_EXIT_CODE > %s
165−`,
166− shellQuote(command),
167− shellQuote(stdoutFile),
168− shellQuote(stderrFile),
169− shellQuote(cwdFile),
170− shellQuote(statusFile),
171− )
172−
173− _, err := s.stdin.Write([]byte(fullCommand + "\n"))
174− if err != nil {
164+ script := fmt.Sprintf(runBashCommandFormat, command, stdoutFile, stderrFile, statusFile, cwdFile)
165+ if _, err := s.stdin.Write([]byte(script + "\n")); err != nil {
175166 return commandResult{
176167 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
177168 exitCode: 1,
@@ -180,18 +171,18 @@ echo $EXEC_EXIT_CODE > %s
180171 }
181172
182173 interrupted := false
183−
184− startTime := time.Now()
185−
186174 done := make(chan bool)
187175 go func() {
188176 // Use exponential backoff polling
189− pollInterval := 1 * time.Millisecond
190− maxPollInterval := 100 * time.Millisecond
177+ pollInterval := 10 * time.Millisecond
178+ maxPollInterval := time.Second
191179
192180 ticker := time.NewTicker(pollInterval)
193181 defer ticker.Stop()
194182
183+ timeoutTicker := time.NewTicker(cmp.Or(timeout, time.Hour*99999))
184+ defer timeoutTicker.Stop()
185+
195186 for {
196187 select {
197188 case <-ctx.Done():
@@ -200,22 +191,18 @@ echo $EXEC_EXIT_CODE > %s
200191 done <- true
201192 return
202193
194+ case <-timeoutTicker.C:
195+ s.killChildren()
196+ interrupted = true
197+ done <- true
198+ return
199+
203200 case <-ticker.C:
204− if fileExists(statusFile) && fileSize(statusFile) > 0 {
201+ if fileSize(statusFile) > 0 {
205202 done <- true
206203 return
207204 }
208205
209− if timeout > 0 {
210− elapsed := time.Since(startTime)
211− if elapsed > timeout {
212− s.killChildren()
213− interrupted = true
214− done <- true
215− return
216− }
217− }
218−
219206 // Exponential backoff to reduce CPU usage for longer-running commands
220207 if pollInterval < maxPollInterval {
221208 pollInterval = min(time.Duration(float64(pollInterval)*1.5), maxPollInterval)
@@ -280,12 +267,10 @@ func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs in
280267 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
281268 }
282269
283− timeout := time.Duration(timeoutMs) * time.Millisecond
284−
285270 resultChan := make(chan commandResult)
286271 s.commandQueue <- &commandExecution{
287272 command: command,
288− timeout: timeout,
273+ timeout: time.Duration(timeoutMs) * time.Millisecond,
289274 resultChan: resultChan,
290275 ctx: ctx,
291276 }
@@ -310,10 +295,6 @@ func (s *PersistentShell) Close() {
310295 s.isAlive = false
311296}
312297
313−func shellQuote(s string) string {
314− return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
315−}
316−
317298func readFileOrEmpty(path string) string {
318299 content, err := os.ReadFile(path)
319300 if err != nil {
@@ -322,11 +303,6 @@ func readFileOrEmpty(path string) string {
322303 return string(content)
323304}
324305
325−func fileExists(path string) bool {
326− _, err := os.Stat(path)
327− return err == nil
328−}
329−
330306func fileSize(path string) int64 {
331307 info, err := os.Stat(path)
332308 if err != nil {