fix: address potential panic on shell command execution (#2200)

Andrey Nering created

This panic happen once in a while on CI on Windows specifically.
I personally never saw it happening myself, but I think it's possible
to happen for the end user on Windows as well.

Looks like a potential bug on the interpreter, but in the meantime let's
at least recover from the panic and gracefully handle it.

    panic: ended up with a non-nil exitStatus.err but a zero exitStatus.code

    goroutine 61 [running]:
    mvdan.cc/sh/v3/interp.(*Runner).Run(0xc000220848, {0x1415220e0, 0xc00021a1e0}, {0x14151e088, 0xc00025a600})
    	C:/Users/runneradmin/go/pkg/mod/mvdan.cc/sh/v3@v3.12.1-0.20250902163504-3cf4fd5717a5/interp/api.go:929 +0x6b2
    github.com/charmbracelet/crush/internal/shell.(*Shell).execCommon(0xc000256360, {0x1415220e0, 0xc00021a1e0}, {0x14135a250, 0x9}, {0x14151baa0, 0xc00025a540}, {0x14151baa0, 0xc00025a580})
    	D:/a/crush/crush/internal/shell/shell.go:273 +0x285
    github.com/charmbracelet/crush/internal/shell.(*Shell).execStream(...)
    	D:/a/crush/crush/internal/shell/shell.go:288
    github.com/charmbracelet/crush/internal/shell.(*Shell).ExecStream(0xc000256360, {0x1415220e0, 0xc00021a1e0}, {0x14135a250, 0x9}, {0x14151baa0, 0xc00025a540}, {0x14151baa0, 0xc00025a580})
    	D:/a/crush/crush/internal/shell/shell.go:111 +0x139
    github.com/charmbracelet/crush/internal/shell.(*BackgroundShellManager).Start.func1()
    	D:/a/crush/crush/internal/shell/background.go:122 +0x15f
    created by github.com/charmbracelet/crush/internal/shell.(*BackgroundShellManager).Start in goroutine 28
    	D:/a/crush/crush/internal/shell/background.go:119 +0x72a

Change summary

internal/shell/shell.go | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)

Detailed changes

internal/shell/shell.go 🔗

@@ -259,20 +259,29 @@ func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
 }
 
 // execCommon is the shared implementation for executing commands
-func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) error {
+func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) (err error) {
+	var runner *interp.Runner
+	defer func() {
+		if r := recover(); r != nil {
+			err = fmt.Errorf("command execution panic: %v", r)
+		}
+		if runner != nil {
+			s.updateShellFromRunner(runner)
+		}
+		s.logger.InfoPersist("command finished", "command", command, "err", err)
+	}()
+
 	line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
 	if err != nil {
 		return fmt.Errorf("could not parse command: %w", err)
 	}
 
-	runner, err := s.newInterp(stdout, stderr)
+	runner, err = s.newInterp(stdout, stderr)
 	if err != nil {
 		return fmt.Errorf("could not run command: %w", err)
 	}
 
 	err = runner.Run(ctx, line)
-	s.updateShellFromRunner(runner)
-	s.logger.InfoPersist("command finished", "command", command, "err", err)
 	return err
 }