Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 46 additions & 22 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"os/signal"
"sync/atomic"
"syscall"
"time"

Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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())
Expand Down
Loading