From 192c4da4e1e145c99b971a0fada73d323545b5ab Mon Sep 17 00:00:00 2001 From: Dhruv Thakur <13575379+dhth@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:42:01 +0000 Subject: [PATCH] stop spawned commands on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move external command execution behind an application-scoped executor and propagate Cobra’s context through Bubble Tea and command runs. When the TUI exits, the executor stops new runs, cancels active direct children, and waits for cleanup. A one-second wait delay prevents inherited output pipes from blocking shutdown; terminating descendant process trees remains out of scope. --- internal/cmd/root.go | 15 ++++++-- internal/executor/runner.go | 71 +++++++++++++++++++++++++++++++++++++ internal/ui/cmds.go | 16 +++------ internal/ui/initial.go | 4 ++- internal/ui/model.go | 10 +++--- internal/ui/ui.go | 6 ++-- internal/ui/update.go | 4 +-- 7 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 internal/executor/runner.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4f6819e..0c93613 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,13 +3,18 @@ package cmd import ( "errors" "fmt" + "time" d "github.com/dhth/mult/internal/domain" + "github.com/dhth/mult/internal/executor" "github.com/dhth/mult/internal/ui" "github.com/spf13/cobra" ) -const maxNumRuns = 1000 +const ( + maxNumRuns = 1000 + processWaitDelay = time.Second +) var ( errInvalidNumRunsRequested = errors.New("invalid number of runs requested") @@ -62,7 +67,7 @@ func NewRootCommand() *cobra.Command { return nil }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { var nRuns int if interactive { fmt.Printf("number of runs?\n") @@ -87,7 +92,11 @@ func NewRootCommand() *cobra.Command { StopOnFirstSuccess: stopOnFirstSuccess, } - return ui.RenderUI(args, config) + ctx := cmd.Context() + runner := executor.New(ctx, processWaitDelay) + defer runner.Shutdown() + + return ui.RenderUI(ctx, args, config, runner) }, } diff --git a/internal/executor/runner.go b/internal/executor/runner.go new file mode 100644 index 0000000..b53122a --- /dev/null +++ b/internal/executor/runner.go @@ -0,0 +1,71 @@ +package executor + +import ( + "context" + "errors" + "os/exec" + "sync" + "time" +) + +var ( + ErrClosed = errors.New("executor is closed") + errEmptyCommand = errors.New("command cannot be empty") +) + +type Runner struct { + ctx context.Context + cancel context.CancelFunc + waitDelay time.Duration + + mu sync.Mutex + closed bool + wg sync.WaitGroup +} + +func New(parent context.Context, waitDelay time.Duration) *Runner { + ctx, cancel := context.WithCancel(parent) + + return &Runner{ + ctx: ctx, + cancel: cancel, + waitDelay: waitDelay, + } +} + +func (r *Runner) Run(command, env []string) ([]byte, error) { + if len(command) == 0 { + return nil, errEmptyCommand + } + + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return nil, ErrClosed + } + if err := r.ctx.Err(); err != nil { + r.mu.Unlock() + return nil, err + } + r.wg.Add(1) + r.mu.Unlock() + + defer r.wg.Done() + + c := exec.CommandContext(r.ctx, command[0], command[1:]...) + c.Env = env + c.WaitDelay = r.waitDelay + + return c.CombinedOutput() +} + +func (r *Runner) Shutdown() { + r.mu.Lock() + if !r.closed { + r.closed = true + r.cancel() + } + r.mu.Unlock() + + r.wg.Wait() +} diff --git a/internal/ui/cmds.go b/internal/ui/cmds.go index 39c34a4..a668492 100644 --- a/internal/ui/cmds.go +++ b/internal/ui/cmds.go @@ -3,10 +3,10 @@ package ui import ( "fmt" "os" - "os/exec" "time" tea "charm.land/bubbletea/v2" + "github.com/dhth/mult/internal/executor" ) func chooseRunEntry(runNum int) tea.Cmd { @@ -27,19 +27,11 @@ func runAfterDelay(interval time.Duration, iterationNum int) tea.Cmd { }) } -func runCmd(cmd []string, iterationNum int) tea.Cmd { +func runCmd(runner *executor.Runner, cmd []string, iterationNum int) tea.Cmd { return func() tea.Msg { - var c *exec.Cmd - - if len(cmd) == 1 { - c = exec.Command(cmd[0]) - } else { - c = exec.Command(cmd[0], cmd[1:]...) - } - - c.Env = append(os.Environ(), fmt.Sprintf("MULT_RUN_NUM=%d", iterationNum+1)) + env := append(os.Environ(), fmt.Sprintf("MULT_RUN_NUM=%d", iterationNum+1)) startTime := time.Now() - out, err := c.CombinedOutput() + out, err := runner.Run(cmd, env) endTime := time.Now() return CmdRanMsg{ iterationNum: iterationNum, diff --git a/internal/ui/initial.go b/internal/ui/initial.go index 086d205..10a5b42 100644 --- a/internal/ui/initial.go +++ b/internal/ui/initial.go @@ -4,9 +4,10 @@ import ( "charm.land/bubbles/v2/list" "charm.land/lipgloss/v2" d "github.com/dhth/mult/internal/domain" + "github.com/dhth/mult/internal/executor" ) -func InitialModel(cmd []string, config d.Config) Model { +func InitialModel(cmd []string, config d.Config, runner *executor.Runner) Model { stackItems := make([]list.Item, config.NumRuns) for i := range config.NumRuns { @@ -29,6 +30,7 @@ func InitialModel(cmd []string, config d.Config) Model { m := Model{ cmd: cmd, + runner: runner, msg: userMsg{}, config: config, lastRunIndex: -1, diff --git a/internal/ui/model.go b/internal/ui/model.go index 08880ff..22185a8 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" d "github.com/dhth/mult/internal/domain" + "github.com/dhth/mult/internal/executor" ) type Pane uint @@ -33,6 +34,7 @@ type userMsg struct { type Model struct { cmd []string + runner *executor.Runner config d.Config runList list.Model lastRunIndex int @@ -61,14 +63,14 @@ type Model struct { func (m Model) Init() tea.Cmd { var cmds []tea.Cmd cmds = append(cmds, hideHelp(time.Second*30)) - cmds = append(cmds, runCmd(m.cmd, 0)) + cmds = append(cmds, runCmd(m.runner, m.cmd, 0)) if m.config.Sequential { return tea.Batch(cmds...) } for i := 1; i < m.config.NumRuns; i++ { - cmds = append(cmds, runCmd(m.cmd, i)) + cmds = append(cmds, runCmd(m.runner, m.cmd, i)) } return tea.Batch(cmds...) @@ -115,12 +117,12 @@ func (m *Model) clearRunList() tea.Cmd { func (m Model) restartRuns() tea.Cmd { if m.config.Sequential { - return runCmd(m.cmd, 0) + return runCmd(m.runner, m.cmd, 0) } var cmds []tea.Cmd for i := 0; i < m.config.NumRuns; i++ { - cmds = append(cmds, runCmd(m.cmd, i)) + cmds = append(cmds, runCmd(m.runner, m.cmd, i)) } return tea.Batch(cmds...) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 1999818..090ba1f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,17 +1,19 @@ package ui import ( + "context" "errors" "fmt" "os" tea "charm.land/bubbletea/v2" d "github.com/dhth/mult/internal/domain" + "github.com/dhth/mult/internal/executor" ) var errFailedToConfigureDebugging = errors.New("failed to configure debugging") -func RenderUI(cmd []string, config d.Config) error { +func RenderUI(ctx context.Context, cmd []string, config d.Config, runner *executor.Runner) error { if len(os.Getenv("DEBUG")) > 0 { f, err := tea.LogToFile("debug.log", "debug") if err != nil { @@ -20,7 +22,7 @@ func RenderUI(cmd []string, config d.Config) error { defer f.Close() } - p := tea.NewProgram(InitialModel(cmd, config)) + p := tea.NewProgram(InitialModel(cmd, config, runner), tea.WithContext(ctx)) _, err := p.Run() return err diff --git a/internal/ui/update.go b/internal/ui/update.go index 1b11afa..0fdef2c 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -180,7 +180,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ok { nextRunItem.RunStatus = d.Running cmds = append(cmds, m.runList.SetItem(i+1, nextRunItem)) - cmds = append(cmds, runCmd(m.cmd, i+1)) + cmds = append(cmds, runCmd(m.runner, m.cmd, i+1)) } } else { nextRunItem, ok := m.runList.Items()[i+1].(cmdRunItem) @@ -200,7 +200,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { runItem.RunStatus = d.Running cmds = append(cmds, m.runList.SetItem(msg.iterationNum, runItem)) - cmds = append(cmds, runCmd(m.cmd, msg.iterationNum)) + cmds = append(cmds, runCmd(m.runner, m.cmd, msg.iterationNum)) case CmdRunChosenMsg: if m.config.FollowResults {