@@ -1,87 +1,323 @@
1package shell
1package shell
12− "github.com/charmbracelet/crush/internal/logging"
13+ "time"
13− "mvdan.cc/sh/v3/expand"
14− "mvdan.cc/sh/v3/interp"
15− "mvdan.cc/sh/v3/syntax"
18type PersistentShell struct {
16type PersistentShell struct {
19− env []string
17+ cmd *exec.Cmd
20− cwd string
20+ cwd string
21− mu sync.Mutex
21+ mu sync.Mutex
22+ commandQueue chan *commandExecution
25+type commandExecution struct {
27+ timeout time.Duration
28+ resultChan chan commandResult
32+type commandResult struct {
41+ shellInstance *PersistentShell
25− once sync.Once
42+ shellInstanceOnce sync.Once
26− shellInstance *PersistentShell
29−func GetPersistentShell(cwd string) *PersistentShell {
45+func GetPersistentShell(workingDir string) *PersistentShell {
30− once.Do(func() {
46+ shellInstanceOnce.Do(func() {
31− shellInstance = newPersistentShell(cwd)
47+ shellInstance = newPersistentShell(workingDir)
50+ if shellInstance == nil {
51+ shellInstance = newPersistentShell(workingDir)
52+ } else if !shellInstance.isAlive {
53+ shellInstance = newPersistentShell(shellInstance.cwd)
33 return shellInstance
56 return shellInstance
36func newPersistentShell(cwd string) *PersistentShell {
59func newPersistentShell(cwd string) *PersistentShell {
60+ // Default to environment variable
61+ shellPath := os.Getenv("SHELL")
63+ shellPath = "/bin/bash"
66+ // Default shell args
67+ shellArgs := []string{"-l"}
69+ cmd := exec.Command(shellPath, shellArgs...)
72+ stdinPipe, err := cmd.StdinPipe()
77+ cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
37− return &PersistentShell{
84+ shell := &PersistentShell{
86+ stdin: stdinPipe.(*os.File),
38− cwd: cwd,
88+ cwd: cwd,
39− env: os.Environ(),
89+ commandQueue: make(chan *commandExecution, 10),
94+ if r := recover(); r != nil {
95+ fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
96+ shell.isAlive = false
97+ close(shell.commandQueue)
100+ shell.processCommands()
106+ // Log the error if needed
108+ shell.isAlive = false
109+ close(shell.commandQueue)
43−func (s *PersistentShell) Exec(ctx context.Context, command string) (string, string, error) {
115+func (s *PersistentShell) processCommands() {
116+ for cmd := range s.commandQueue {
117+ result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
118+ cmd.resultChan <- result
122+func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
44 s.mu.Lock()
123 s.mu.Lock()
45 defer s.mu.Unlock()
124 defer s.mu.Unlock()
47− line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
126+ if !s.isAlive {
48− if err != nil {
127+ return commandResult{
49− return "", "", fmt.Errorf("could not parse command: %w", err)
128+ stderr: "Shell is not alive",
130+ err: errors.New("shell is not alive"),
52− var stdout, stderr bytes.Buffer
134+ tempDir := os.TempDir()
53− runner, err := interp.New(
135+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
54− interp.StdIO(nil, &stdout, &stderr),
136+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
55− interp.Interactive(false),
137+ statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
56− interp.Env(expand.ListEnviron(s.env...)),
138+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
57− interp.Dir(s.cwd),
139+
141+ os.Remove(stdoutFile)
142+ os.Remove(stderrFile)
143+ os.Remove(statusFile)
147+ fullCommand := fmt.Sprintf(`
148+eval %s < /dev/null > %s 2> %s
151+echo $EXEC_EXIT_CODE > %s
153+ shellQuote(command),
154+ shellQuote(stdoutFile),
155+ shellQuote(stderrFile),
156+ shellQuote(cwdFile),
157+ shellQuote(statusFile),
160+ _, err := s.stdin.Write([]byte(fullCommand + "\n"))
59 if err != nil {
161 if err != nil {
60− return "", "", fmt.Errorf("could not run command: %w", err)
162+ return commandResult{
163+ stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
63− err = runner.Run(ctx, line)
169+ interrupted := false
64− s.cwd = runner.Dir
170+
65− s.env = []string{}
171+ startTime := time.Now()
66− for name, vr := range runner.Vars {
172+
67− s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
173+ done := make(chan bool)
175+ // Use exponential backoff polling
176+ pollInterval := 1 * time.Millisecond
177+ maxPollInterval := 100 * time.Millisecond
179+ ticker := time.NewTicker(pollInterval)
191+ if fileExists(statusFile) && fileSize(statusFile) > 0 {
197+ elapsed := time.Since(startTime)
198+ if elapsed > timeout {
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)
217+ stdout := readFileOrEmpty(stdoutFile)
218+ stderr := readFileOrEmpty(stderrFile)
219+ exitCodeStr := readFileOrEmpty(statusFile)
220+ newCwd := readFileOrEmpty(cwdFile)
223+ if exitCodeStr != "" {
224+ fmt.Sscanf(exitCodeStr, "%d", &exitCode)
225+ } else if interrupted {
227+ stderr += "\nCommand execution timed out or was interrupted"
231+ s.cwd = strings.TrimSpace(newCwd)
234+ return commandResult{
238+ interrupted: interrupted,
69− logging.InfoPersist("Command finished", "command", command, "err", err)
70− return stdout.String(), stderr.String(), err
73−func IsInterrupt(err error) bool {
242+func (s *PersistentShell) killChildren() {
74− return errors.Is(err, context.Canceled) ||
243+ if s.cmd == nil || s.cmd.Process == nil {
75− errors.Is(err, context.DeadlineExceeded)
244+ return
247+ pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
248+ output, err := pgrepCmd.Output()
253+ for pidStr := range strings.SplitSeq(string(output), "\n") {
254+ if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
256+ fmt.Sscanf(pidStr, "%d", &pid)
258+ proc, err := os.FindProcess(pid)
260+ proc.Signal(syscall.SIGTERM)
78−func ExitCode(err error) int {
267+func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs int) (string, string, int, bool, error) {
79− if err == nil {
268+ if !s.isAlive {
269+ return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
272+ timeout := time.Duration(timeoutMs) * time.Millisecond
274+ resultChan := make(chan commandResult)
275+ s.commandQueue <- &commandExecution{
278+ resultChan: resultChan,
282+ result := <-resultChan
283+ return result.stdout, result.stderr, result.exitCode, result.interrupted, result.err
286+func (s *PersistentShell) Close() {
82− status, ok := interp.IsExitStatus(err)
293+
83− if ok {
294+ s.stdin.Write([]byte("exit\n"))
296+ s.cmd.Process.Kill()
300+func shellQuote(s string) string {
301+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
304+func readFileOrEmpty(path string) string {
305+ content, err := os.ReadFile(path)
84− return int(status)
307+ return ""
86− return 1
309+ return string(content)
312+func fileExists(path string) bool {
313+ _, err := os.Stat(path)
317+func fileSize(path string) int64 {
318+ info, err := os.Stat(path)