diff --git a/internal/runner/runner.go b/internal/runner/runner.go index a275f30..1d7bf43 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "os/signal" + "sync/atomic" "syscall" "time" @@ -38,10 +39,16 @@ func RunSubprocess(args []string, dryRun bool) error { return cmd.Run() } -// Run executes the given command. If dryRun is true, it prints what would be -// run and returns nil. If vibes is true, it plays background music during -// execution. If notify is true, it plays a notification sound on completion. -// Otherwise it replaces the current process via syscall.Exec. +// Run executes the given command as a subprocess. If dryRun is true, it prints +// what would be run and returns nil. If vibes is true, it plays background +// music during execution. If notify is true, it plays a notification sound on +// completion. +// +// The command runs as a child (rather than via syscall.Exec) so we can drain +// terminal response sequences after it exits. Long-running TUI-style commands +// (e.g. turbo, vite) frequently query the terminal for capabilities; if they +// are killed mid-query the response leaks into the shell's stdin and surfaces +// as random characters at the next prompt. func Run(args []string, dryRun bool, vibes bool, notify bool) error { if len(args) == 0 { return fmt.Errorf("no command to run") @@ -57,12 +64,6 @@ func Run(args []string, dryRun bool, vibes bool, notify bool) error { return fmt.Errorf("%s not found in PATH: %w", args[0], err) } - // Direct exec when no audio features needed. - if !vibes && !notify { - return syscall.Exec(bin, args, os.Environ()) - } - - // Child process mode: run command as subprocess so we can manage audio. var vibesProc *audio.VibesProcess if vibes { var err error @@ -77,29 +78,45 @@ func Run(args []string, dryRun bool, vibes bool, notify bool) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - // Intercept SIGINT so we can stop background tasks before exiting. + if err := cmd.Start(); err != nil { + if vibesProc != nil { + vibesProc.StopImmediately() + } + return fmt.Errorf("start command: %w", err) + } + + // Catch SIGINT/SIGTERM so the parent stays alive long enough to drain the + // terminal after the child exits. Forward the signal to the child so it + // terminates whether the signal arrived via the foreground process group + // (Ctrl+C) or was addressed to spm directly. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigCh) + var caughtSig atomic.Int32 + sigDone := make(chan struct{}) go func() { - s := <-sigCh - if vibesProc != nil { - vibesProc.StopImmediately() + defer close(sigDone) + s, ok := <-sigCh + if !ok { + return } - if s == syscall.SIGTERM { - os.Exit(143) + caughtSig.Store(int32(s.(syscall.Signal))) + if cmd.Process != nil { + _ = cmd.Process.Signal(s) } - os.Exit(130) }() - runErr := cmd.Run() + runErr := cmd.Wait() + signal.Stop(sigCh) + close(sigCh) + <-sigDone + + // Drain any pending terminal response sequences (DECRPM, cursor pos, …) + // the child may have left in stdin after being killed mid-query. + ui.DrainTerminalResponses() - // Signal vibes to fade out (detached — won't block us). if vibesProc != nil { if notify { - // When notify is set, kill music immediately so the - // notification sound is heard cleanly. vibesProc.StopImmediately() } else { vibesProc.FadeOutAndDetach() @@ -111,6 +128,13 @@ func Run(args []string, dryRun bool, vibes bool, notify bool) error { _ = audio.PlayNotification(sound) } + switch syscall.Signal(caughtSig.Load()) { + case syscall.SIGTERM: + os.Exit(143) + case syscall.SIGINT: + os.Exit(130) + } + if runErr != nil { if exitErr, ok := runErr.(*exec.ExitError); ok { os.Exit(exitErr.ExitCode())